mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-derive
This commit is contained in:
commit
533b75fdcf
70 files changed
+4167
-566
No files matched your search
+16
-9
@@ -41066,9 +41066,9 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` /
|
||||
`glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` / `glucose` within 90 days
|
||||
when the window holds more than 10 000 readings).
|
||||
description: Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse`,
|
||||
`glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:<ISO start>` for an hour
|
||||
bucket (the same kinds within 90 days when the window holds more than 10 000 readings).
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -41141,12 +41141,13 @@ components:
|
||||
- stats
|
||||
additionalProperties: false
|
||||
description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an
|
||||
explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and
|
||||
`glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window
|
||||
holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff
|
||||
or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range
|
||||
as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats`
|
||||
is always computed over the underlying readings, never over the buckets."
|
||||
explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`,
|
||||
`heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within
|
||||
90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or
|
||||
ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries
|
||||
the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its
|
||||
range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading.
|
||||
`stats` is always computed over the underlying readings, never over the buckets."
|
||||
ListMeasurementsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -41339,6 +41340,12 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
windowFrom:
|
||||
description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant
|
||||
the read started from. The window is a year ending at `to` (or now); page further back by moving `to`."
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- total
|
||||
- limit
|
||||
|
||||
@@ -61,6 +61,8 @@ interface DialogCase {
|
||||
prepare?: (page: import("@playwright/test").Page) => Promise<void>;
|
||||
/** Something inside the dialog that proves it rendered its form, not a shell. */
|
||||
ready: string;
|
||||
/** Render in this locale instead of the suite's pinned English. */
|
||||
locale?: "de";
|
||||
}
|
||||
|
||||
const DIALOGS: DialogCase[] = [
|
||||
@@ -92,11 +94,23 @@ const DIALOGS: DialogCase[] = [
|
||||
trigger: /^add$/i,
|
||||
ready: '[data-slot="date-time-field"]',
|
||||
},
|
||||
// The labs sheet's footer holds three buttons (Cancel, Save & add another,
|
||||
// Save). They fit a phone row in English; the German labels are ~100px
|
||||
// wider and pushed Cancel off the sheet's left edge, at every width.
|
||||
{
|
||||
name: "labs add (German)",
|
||||
path: "/labs",
|
||||
trigger: /^hinzufügen$/i,
|
||||
ready: '[data-slot="date-time-field"]',
|
||||
locale: "de",
|
||||
},
|
||||
];
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ label: "desktop 1280", width: 1280, height: 800 },
|
||||
{ label: "phone 390", width: 390, height: 844 },
|
||||
// The narrowest common Android width.
|
||||
{ label: "phone 360", width: 360, height: 780 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -200,7 +214,17 @@ test.describe("add/edit dialogs do not scroll sideways", () => {
|
||||
for (const dialog of DIALOGS) {
|
||||
test(`${dialog.name} fits its surface at ${vp.label}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
context,
|
||||
}, testInfo) => {
|
||||
if (dialog.locale) {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "healthlog-locale",
|
||||
value: dialog.locale,
|
||||
url: testInfo.project.use.baseURL ?? "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
}
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.goto(dialog.path, { waitUntil: "domcontentloaded" });
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import type { APIRequestContext } from "@playwright/test";
|
||||
|
||||
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
|
||||
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* Every signed-in route, at an iPhone width, does not scroll sideways.
|
||||
*
|
||||
* `mobile-horizontal-overflow.spec.ts` sweeps five widths over four
|
||||
* content-heavy routes. This file is the breadth half: one width, every route.
|
||||
* It exists because the lab list panned sideways on a phone for weeks while
|
||||
* the guard was green — `/labs` was simply not on the list. A hand-kept list
|
||||
* goes stale the day a route is added, so the routes here are read from
|
||||
* `src/app` itself, and a route this file cannot open fails the run instead of
|
||||
* dropping out silently:
|
||||
*
|
||||
* - a static route is visited as is;
|
||||
* - a dynamic route needs an entry in `DYNAMIC_ROUTES` that turns its
|
||||
* segments into concrete paths (a settings slug, a seeded record's id);
|
||||
* - a route that cannot be swept signed in (auth, invitation, public
|
||||
* share-link pages) is listed in `NOT_SIGNED_IN` with its reason.
|
||||
*
|
||||
* The account is `E2E_MOBILE_ROUTES`, which `globalSetup` fills with one or
|
||||
* two of everything (`mobile-routes-fixture.ts`): an empty page fits any
|
||||
* viewport and would prove nothing. It renders in German, the locale with the
|
||||
* longest common strings, and at 390 px — the iPhone width the lab list broke
|
||||
* at.
|
||||
*/
|
||||
|
||||
const APP_DIR = join(process.cwd(), "src", "app");
|
||||
const WIDTH = 390;
|
||||
|
||||
/** Route patterns that are not signed-in surfaces, and why. */
|
||||
const NOT_SIGNED_IN: Record<string, string> = {
|
||||
"/auth/login": "the sign-in page; a session redirects away from it",
|
||||
"/auth/register": "registration; a session redirects away from it",
|
||||
"/invite/[token]":
|
||||
"an invitation landing page for somebody without an account",
|
||||
"/c/[token]": "the clinician share-link page, opened without a session",
|
||||
"/confirm-access": "the re-proof page, reached only from a stale session",
|
||||
"/enroll-mfa": "the forced second-factor enrolment of a policy-bound account",
|
||||
"/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts",
|
||||
"/onboarding/[step]":
|
||||
"the first-run setup flow; covered by setup-flow-*.spec.ts",
|
||||
};
|
||||
|
||||
type Resolver = (api: APIRequestContext) => Promise<string[]>;
|
||||
|
||||
async function readData<T>(api: APIRequestContext, path: string): Promise<T> {
|
||||
const res = await api.get(path);
|
||||
expect(res.ok(), `${path} answered ${res.status()}`).toBe(true);
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
const firstId = (ids: Array<string | null | undefined>, what: string) => {
|
||||
const id = ids.find((value): value is string => Boolean(value));
|
||||
if (!id)
|
||||
throw new Error(`the fixture holds no ${what} — globalSetup seeds one`);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** Every dynamic route pattern under `src/app`, turned into real paths. */
|
||||
const DYNAMIC_ROUTES: Record<string, Resolver> = {
|
||||
"/settings/[section]": async () =>
|
||||
SETTINGS_SECTION_SLUGS.map((slug) => `/settings/${slug}`),
|
||||
"/admin/[section]": async () =>
|
||||
ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`),
|
||||
// `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot
|
||||
// load; `settings/layout/[module]` 404s an unknown group, so a stale entry
|
||||
// here fails on the status check below rather than passing quietly.
|
||||
"/settings/layout/[module]": async () =>
|
||||
[
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
].map((group) => `/settings/layout/${group}`),
|
||||
"/insights/scores/[metric]": async () =>
|
||||
["sleep", "readiness", "recovery", "stress", "strain"].map(
|
||||
(metric) => `/insights/scores/${metric}`,
|
||||
),
|
||||
"/insights/values/[type]": async () => [
|
||||
"/insights/values/WEIGHT",
|
||||
"/insights/values/BLOOD_PRESSURE_SYS",
|
||||
],
|
||||
"/labs/[biomarkerId]": async (api) => {
|
||||
const data = await readData<{
|
||||
results: Array<{ biomarkerId: string | null; analyte: string }>;
|
||||
}>(api, "/api/labs?limit=50");
|
||||
const id = firstId(
|
||||
data.results
|
||||
.filter((row) => row.analyte === MOBILE_ROUTES_ANALYTE)
|
||||
.map((row) => row.biomarkerId),
|
||||
"linked lab reading",
|
||||
);
|
||||
return [`/labs/${id}`];
|
||||
},
|
||||
"/labs/[biomarkerId]/values": async (api) =>
|
||||
(await DYNAMIC_ROUTES["/labs/[biomarkerId]"](api)).map(
|
||||
(path) => `${path}/values`,
|
||||
),
|
||||
"/medications/[id]": async (api) => {
|
||||
const meds = await readData<Array<{ id: string }>>(api, "/api/medications");
|
||||
return [
|
||||
`/medications/${firstId(
|
||||
meds.map((m) => m.id),
|
||||
"medication",
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
"/medications/[id]/history": async (api) =>
|
||||
(await DYNAMIC_ROUTES["/medications/[id]"](api)).map(
|
||||
(path) => `${path}/history`,
|
||||
),
|
||||
"/illness/[id]": async (api) => {
|
||||
const episodes = await readData<Array<{ id: string }>>(
|
||||
api,
|
||||
"/api/illness/episodes",
|
||||
);
|
||||
return [
|
||||
`/illness/${firstId(
|
||||
episodes.map((e) => e.id),
|
||||
"illness episode",
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
"/custom-metrics/[id]": async (api) => {
|
||||
const data = await readData<{ customMetrics: Array<{ id: string }> }>(
|
||||
api,
|
||||
"/api/custom-metrics",
|
||||
);
|
||||
const id = firstId(
|
||||
data.customMetrics.map((m) => m.id),
|
||||
"custom metric",
|
||||
);
|
||||
return [`/custom-metrics/${id}`];
|
||||
},
|
||||
"/custom-metrics/[id]/values": async (api) =>
|
||||
(await DYNAMIC_ROUTES["/custom-metrics/[id]"](api)).map(
|
||||
(path) => `${path}/values`,
|
||||
),
|
||||
"/insights/workouts/[id]": async (api) => {
|
||||
const data = await readData<{ workouts: Array<{ id: string }> }>(
|
||||
api,
|
||||
"/api/workouts?limit=1",
|
||||
);
|
||||
return [
|
||||
`/insights/workouts/${firstId(
|
||||
data.workouts.map((w) => w.id),
|
||||
"workout",
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
// An ECG strip only arrives through a device sync; no route writes one. The
|
||||
// detail page is swept in its not-found state, which is still the frame,
|
||||
// header and back link a phone renders.
|
||||
"/insights/ecg/[id]": async () => ["/insights/ecg/unknown-recording"],
|
||||
};
|
||||
|
||||
/** Every `page.tsx` under `src/app`, as a route pattern (`/labs/[biomarkerId]`). */
|
||||
function discoverRoutePatterns(dir = APP_DIR): string[] {
|
||||
const patterns: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "api" || entry.startsWith("_") || entry === "__tests__")
|
||||
continue;
|
||||
patterns.push(...discoverRoutePatterns(full));
|
||||
} else if (entry === "page.tsx") {
|
||||
const rel = relative(APP_DIR, dir).split(sep).join("/");
|
||||
// Route groups `(name)` do not appear in the URL.
|
||||
const path = rel
|
||||
.split("/")
|
||||
.filter((segment) => segment && !/^\(.*\)$/.test(segment))
|
||||
.join("/");
|
||||
patterns.push(`/${path}`);
|
||||
}
|
||||
}
|
||||
return patterns.sort();
|
||||
}
|
||||
|
||||
const PATTERNS = discoverRoutePatterns();
|
||||
const isDynamic = (pattern: string) => pattern.includes("[");
|
||||
|
||||
test.describe("every signed-in route fits a phone", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ context }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "de", url: baseURL },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The bookkeeping the sweep depends on. A new dynamic route without a
|
||||
* resolver, a resolver for a route that no longer exists, or an exclusion
|
||||
* for a route that is gone each fail here by name.
|
||||
*/
|
||||
test("the swept routes are the routes the app has", () => {
|
||||
expect(PATTERNS.length, "no page.tsx found under src/app").toBeGreaterThan(
|
||||
50,
|
||||
);
|
||||
const unresolved = PATTERNS.filter(
|
||||
(p) => isDynamic(p) && !NOT_SIGNED_IN[p] && !DYNAMIC_ROUTES[p],
|
||||
);
|
||||
expect(
|
||||
unresolved,
|
||||
"dynamic route(s) with no entry in DYNAMIC_ROUTES or NOT_SIGNED_IN",
|
||||
).toEqual([]);
|
||||
const stale = [
|
||||
...Object.keys(DYNAMIC_ROUTES),
|
||||
...Object.keys(NOT_SIGNED_IN),
|
||||
].filter((p) => !PATTERNS.includes(p));
|
||||
expect(stale, "entries for routes that no longer exist").toEqual([]);
|
||||
});
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
if (NOT_SIGNED_IN[pattern]) continue;
|
||||
|
||||
test(`${pattern} does not scroll sideways at ${WIDTH}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.slow();
|
||||
const paths = isDynamic(pattern)
|
||||
? await DYNAMIC_ROUTES[pattern](page.request)
|
||||
: [pattern];
|
||||
expect(paths.length, `${pattern} resolved to no path`).toBeGreaterThan(0);
|
||||
|
||||
await page.setViewportSize({ width: WIDTH, height: 844 });
|
||||
for (const path of paths) {
|
||||
const response = await page.goto(path, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
expect(
|
||||
response?.status() ?? 0,
|
||||
`${path} answered ${response?.status()}`,
|
||||
).toBeLessThan(400);
|
||||
// A redirect away (a module switched off, a missing record) would
|
||||
// measure some other page and call it this one.
|
||||
expect(new URL(page.url()).pathname, `${path} redirected`).toBe(
|
||||
new URL(path, page.url()).pathname,
|
||||
);
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, `${path} @${WIDTH}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The lab surfaces, asserted at the elements that broke rather than only at
|
||||
* the page width. The list's name column collapsed to nothing on a phone
|
||||
* while the fixed columns beside it pushed the row off the card; the values
|
||||
* table put its edit and delete buttons in a sideways-scrolling strip.
|
||||
*/
|
||||
test.describe("the lab surfaces stay readable on a phone", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
await page.setViewportSize({ width: WIDTH, height: 844 });
|
||||
});
|
||||
|
||||
test("the lab list shows each marker's name", async ({ page }) => {
|
||||
await page.goto("/labs", { waitUntil: "domcontentloaded" });
|
||||
const names = page.locator('[data-slot="lab-list-analyte"]');
|
||||
await expect(names.first()).toBeVisible();
|
||||
const widths = await names.evaluateAll((nodes) =>
|
||||
nodes.map((node) => Math.round(node.getBoundingClientRect().width)),
|
||||
);
|
||||
expect(widths.length).toBeGreaterThan(1);
|
||||
// A readable name, not the 0 px column the fixed columns left it.
|
||||
for (const width of widths) expect(width).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
test("the values list keeps its actions on screen", async ({ page }) => {
|
||||
const [path] = await DYNAMIC_ROUTES["/labs/[biomarkerId]/values"](
|
||||
page.request,
|
||||
);
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
const list = page.locator('[data-slot="lab-history-mobile-list"]');
|
||||
await expect(list).toBeVisible();
|
||||
await expect(page.locator('[data-slot="lab-history-table"]')).toBeHidden();
|
||||
|
||||
const buttons = list.locator("button");
|
||||
expect(await buttons.count()).toBeGreaterThan(1);
|
||||
const outside = await buttons.evaluateAll(
|
||||
(nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getBoundingClientRect())
|
||||
.filter((r) => r.left < 0 || r.right > window.innerWidth + 1).length,
|
||||
);
|
||||
expect(outside, "a reading's action sits off-screen").toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||||
|
||||
// Resolve relative to the project root rather than __dirname/import.meta.url —
|
||||
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
|
||||
// loading either dirname helper at module scope crashes the test runner.
|
||||
@@ -191,6 +193,27 @@ export const MODULES_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateModules.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`).
|
||||
*
|
||||
* It holds one or two of everything a route can render
|
||||
* (`mobile-routes-fixture.ts`), because the sweep is only worth something
|
||||
* against populated pages, and writing that much into the shared account would
|
||||
* change what every other spec reads. An administrator, so the admin console
|
||||
* is swept too; `FEMALE`, so the cycle surfaces render without a switch.
|
||||
*/
|
||||
export const E2E_MOBILE_ROUTES = {
|
||||
email: "e2e-mobile-routes@healthlog.test",
|
||||
username: "e2e-mobile-routes",
|
||||
password: "Wd7!Kp3mTz9qRv2N",
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateMobileRoutes.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The AI-optional journeys' own account (`ai-optional-*.spec.ts`).
|
||||
*
|
||||
@@ -823,6 +846,43 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The phone-width route sweep's account. Every opt-in module is on, so
|
||||
// the sweep reaches the glucose, documents, wellbeing, nutrients and
|
||||
// environment surfaces; its record is written after the login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
height_cm, date_of_birth, gender,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
|
||||
$8::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_MOBILE_ROUTES.username,
|
||||
E2E_MOBILE_ROUTES.email,
|
||||
await hashPassword(E2E_MOBILE_ROUTES.password),
|
||||
E2E_MOBILE_ROUTES.role,
|
||||
now,
|
||||
new Date("1988-04-12T00:00:00.000Z"),
|
||||
JSON.stringify({
|
||||
glucose: true,
|
||||
environment: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
// The AI-optional journeys' account. Its provider, stored briefing,
|
||||
// consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts`
|
||||
// before each journey.
|
||||
@@ -1441,6 +1501,11 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// The module-surfaces journey's jar — see `E2E_MODULES`.
|
||||
await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH);
|
||||
|
||||
// The phone-width route sweep's jar, then its record — see
|
||||
// `E2E_MOBILE_ROUTES`.
|
||||
await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH);
|
||||
await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH);
|
||||
|
||||
// The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`.
|
||||
await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH);
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* The record `mobile-route-overflow.spec.ts` measures.
|
||||
*
|
||||
* A phone-width overflow guard that visits an empty page proves nothing: an
|
||||
* empty list fits any viewport, and the lab list that panned sideways on a
|
||||
* phone only did so once it held a reading. So the account gets one or two of
|
||||
* everything a signed-in route can render — readings with reference ranges, a
|
||||
* medication, a mood entry, a visit, a vaccination, an illness episode, a
|
||||
* custom metric, a workout, a document — and every dynamic route has a real id
|
||||
* to open.
|
||||
*
|
||||
* Written through the app's own routes, once, from `globalSetup`, so the
|
||||
* parallel workers of the spec never race each other to seed it. An account
|
||||
* that already holds a lab reading is taken as seeded, which keeps a local
|
||||
* re-run against a reused database from doubling every row.
|
||||
*/
|
||||
import { request as playwrightRequest } from "@playwright/test";
|
||||
|
||||
/** Long on purpose: the names are what a narrow column has to give way to. */
|
||||
export const MOBILE_ROUTES_ANALYTE = "Glomeruläre Filtrationsrate (eGFR)";
|
||||
|
||||
export async function seedMobileRoutesRecord(
|
||||
baseURL: string,
|
||||
storageStatePath: string,
|
||||
): Promise<void> {
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: storageStatePath,
|
||||
});
|
||||
try {
|
||||
// The document is the last thing written, so its presence means a
|
||||
// complete record. A run that died half-way is finished by the next one:
|
||||
// a row it already wrote answers 409 and is skipped.
|
||||
const existing = await ctx.get("/api/documents/inbound");
|
||||
const existingBody = (await existing.json()) as {
|
||||
data: { documents?: unknown[] } | unknown[] | null;
|
||||
};
|
||||
const documents = Array.isArray(existingBody.data)
|
||||
? existingBody.data
|
||||
: (existingBody.data?.documents ?? []);
|
||||
if (documents.length > 0) return;
|
||||
|
||||
const post = async (path: string, data: unknown): Promise<unknown> => {
|
||||
const res = await ctx.post(path, { data });
|
||||
if (res.status() === 409) return null;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`[mobile-routes-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: unknown }).data;
|
||||
};
|
||||
const daysAgo = (days: number, hour = 8) => {
|
||||
const at = new Date(Date.now() - days * 86_400_000);
|
||||
at.setUTCHours(hour, 0, 0, 0);
|
||||
return at.toISOString();
|
||||
};
|
||||
|
||||
for (let day = 1; day <= 6; day += 1) {
|
||||
await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: 80 + day / 10,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120 + day,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 78 + (day % 3),
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "PULSE",
|
||||
value: 60 + day,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 92 + day,
|
||||
glucoseContext: "FASTING",
|
||||
measuredAt: daysAgo(day, 6),
|
||||
});
|
||||
await post("/api/mood-entries", {
|
||||
mood: day % 2 ? "GUT" : "OKAY",
|
||||
moodLoggedAt: daysAgo(day, 20),
|
||||
});
|
||||
}
|
||||
|
||||
// Two readings of one marker, so the list row has a trend line and a
|
||||
// range bar, plus a qualitative one with neither.
|
||||
for (const [days, value] of [
|
||||
[40, 78],
|
||||
[5, 84],
|
||||
] as const) {
|
||||
await post("/api/labs", {
|
||||
analyte: MOBILE_ROUTES_ANALYTE,
|
||||
panel: "Nierenwerte und Elektrolyte",
|
||||
value,
|
||||
unit: "mL/min/1.73m²",
|
||||
referenceLow: 60,
|
||||
referenceHigh: 120,
|
||||
takenAt: daysAgo(days),
|
||||
});
|
||||
}
|
||||
await post("/api/labs", {
|
||||
analyte: "Hepatitis-B-Oberflächenantigen",
|
||||
valueText: "negativ",
|
||||
takenAt: daysAgo(5),
|
||||
});
|
||||
|
||||
await post("/api/medications", {
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
});
|
||||
|
||||
const practitioner = (await post("/api/practitioners", {
|
||||
name: "Gemeinschaftspraxis für Allgemeinmedizin",
|
||||
specialty: "Allgemeinmedizin und Innere Medizin",
|
||||
})) as { id: string } | null;
|
||||
await post("/api/encounters", {
|
||||
occurredAt: daysAgo(20),
|
||||
practitionerId: practitioner?.id ?? null,
|
||||
reason: "Routinekontrolle Blutdruck und Blutbild",
|
||||
});
|
||||
await post("/api/vaccinations", {
|
||||
occurredAt: daysAgo(300),
|
||||
antigenSlug: "tetanus",
|
||||
doseNumber: 1,
|
||||
lotNumber: "AB1234-XY",
|
||||
practitionerId: practitioner?.id ?? null,
|
||||
});
|
||||
|
||||
await post("/api/illness/episodes", {
|
||||
label: "Erkältung mit Husten",
|
||||
type: "INFECTION",
|
||||
onsetAt: daysAgo(12),
|
||||
resolvedAt: daysAgo(6),
|
||||
});
|
||||
|
||||
const metric = (await post("/api/custom-metrics", {
|
||||
name: "Augeninnendruck rechts",
|
||||
unit: "mmHg",
|
||||
targetLow: 10,
|
||||
targetHigh: 21,
|
||||
decimals: 0,
|
||||
})) as { id: string } | null;
|
||||
const metricId =
|
||||
metric?.id ??
|
||||
(
|
||||
(await (await ctx.get("/api/custom-metrics")).json()) as {
|
||||
data: { customMetrics: Array<{ id: string }> };
|
||||
}
|
||||
).data.customMetrics[0]?.id;
|
||||
await post(`/api/custom-metrics/${metricId}/entries`, {
|
||||
value: 16,
|
||||
measuredAt: daysAgo(3),
|
||||
});
|
||||
|
||||
await post("/api/workouts/batch", {
|
||||
workouts: [
|
||||
{
|
||||
sportType: "running",
|
||||
startedAt: daysAgo(2, 6),
|
||||
endedAt: new Date(
|
||||
new Date(daysAgo(2, 6)).getTime() + 45 * 60_000,
|
||||
).toISOString(),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "e2e-mobile-routes-run",
|
||||
activeEnergyKcal: 420,
|
||||
distanceM: 6500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const pdf =
|
||||
"%PDF-1.4\n%e2e-mobile-routes\n" +
|
||||
"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n" +
|
||||
"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\n%%EOF\n";
|
||||
const doc = await ctx.post("/api/documents/inbound", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: "laborbefund-hausarzt-mit-langem-dateinamen.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(pdf),
|
||||
},
|
||||
title: "Laborbefund Hausarzt mit einem sehr langen Titel",
|
||||
kind: "LAB_RESULT",
|
||||
},
|
||||
});
|
||||
if (!doc.ok()) {
|
||||
throw new Error(
|
||||
`[mobile-routes-fixture] /api/documents/inbound answered ${doc.status()}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Structural guard: background and insight readers must not materialise a
|
||||
* dense measurement stream.
|
||||
* Structural guard: no reader materialises a dense measurement stream.
|
||||
*
|
||||
* #1023: a single account syncing heart rate from a watch took the worker
|
||||
* down within half an hour. The status-card read was bounded in time
|
||||
@@ -10,17 +9,34 @@
|
||||
* SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks
|
||||
* the number of days, not the sampling rate.
|
||||
*
|
||||
* What this guard pins, in the directories that run on the worker or feed an
|
||||
* insight: every `measurement.findMany` without a `take` reads a type that is
|
||||
* named as a literal and is not one a device streams. A read whose type is a
|
||||
* variable could be handed a dense type by the next caller, so it must be
|
||||
* listed below with the reason it is safe. The list is frozen both ways: a new
|
||||
* unlisted read fails, and an entry whose read is gone fails too.
|
||||
* The same class kept turning up outside the directories this guard first
|
||||
* walked: the targets page read a year of glucose and a month of heart rate,
|
||||
* the cycle insights read a year of every outcome channel, the per-kind series
|
||||
* read heart-rate variability and blood oxygen raw for ten years, all from
|
||||
* `app/api` or a helper beside it. So the guard now covers the request
|
||||
* handlers, import, export, the targets and the doctor report as well, and
|
||||
* the raw SQL reads of `measurements` anywhere in the tree.
|
||||
*
|
||||
* It is a tripwire, not a proof: the matcher reads source text, and a read
|
||||
* written some other way (a raw SELECT of rows, a helper in another
|
||||
* directory) passes it. The floor below fails the guard if the matcher stops
|
||||
* finding reads at all.
|
||||
* Two checks.
|
||||
*
|
||||
* 1. `measurement.findMany` without a `take`, in the scoped directories,
|
||||
* must be bounded by its `where`: a type named as a literal (or a list of
|
||||
* literals) none of which a device streams, or a key list (`id`,
|
||||
* `externalId` or `measuredAt` `in: [...]`, or an exact `measuredAt`).
|
||||
* Anything else is listed below with the reason it stays bounded.
|
||||
*
|
||||
* 2. A raw `$queryRaw` / `$queryRawUnsafe` whose statement reads from
|
||||
* `measurements` must fold: `GROUP BY`, `DISTINCT`, `LIMIT`, or a select
|
||||
* list of aggregates only. A statement that returns rows is listed below
|
||||
* with its reason. A statement held in a variable is invisible to the
|
||||
* matcher; that is the check's known blind spot.
|
||||
*
|
||||
* Both lists are frozen both ways: a new unlisted read fails, and an entry
|
||||
* whose read is gone fails too. The floors below fail the guard if a matcher
|
||||
* stops finding reads at all, and the "plants" cases run each matcher over a
|
||||
* planted offender, which is the mutation proof kept in the suite: the
|
||||
* planted findMany of `BLOOD_GLUCOSE` over a year and the planted raw SELECT of
|
||||
* every pulse row are both caught. It is a tripwire, not a proof.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -42,6 +58,12 @@ const SCOPED_DIRS = [
|
||||
"lib/analytics/",
|
||||
"lib/dashboard/",
|
||||
"lib/daily/",
|
||||
// Widened in v1.39.7: the request handlers and the helpers they lean on.
|
||||
"app/api/",
|
||||
"lib/import",
|
||||
"lib/export",
|
||||
"lib/targets/",
|
||||
"lib/doctor-report",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
@@ -55,8 +77,8 @@ const DENSE_TYPES: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Untaken reads whose type is not a sparse literal, keyed
|
||||
* `<file>::<type expression>`, with why each stays bounded.
|
||||
* Untaken reads whose `where` neither names sparse literal types nor a key
|
||||
* list, keyed `<file>::<type expression>`, with why each stays bounded.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"lib/insights/comprehensive-generate.ts::(shorthand)":
|
||||
@@ -69,8 +91,6 @@ const ALLOWED: Record<string, string> = {
|
||||
"A constant for six-minute-walk distance, written at most daily.",
|
||||
"lib/insights/derived/wellness-scores.ts::measurementType":
|
||||
"Daily computed / provider scores (recovery, strain, readiness), one row per day.",
|
||||
"lib/jobs/reaction-line.ts::{ in: [...types]":
|
||||
"Exact-instant match (`measuredAt: row.occurredAt`), not a window.",
|
||||
"lib/jobs/step-consolidation-repair.ts::STEP_TYPE":
|
||||
"Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.",
|
||||
'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never':
|
||||
@@ -79,6 +99,34 @@ const ALLOWED: Record<string, string> = {
|
||||
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
|
||||
"lib/analytics/score/reader.ts::{ in: types":
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
|
||||
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
|
||||
"app/api/measurements/series/route.ts::(shorthand)":
|
||||
"The raw branch of the per-kind series. Every dense kind reaches it only inside 90 days and under 10 000 rows (counted first); the sparse kinds are a few readings a day.",
|
||||
"app/api/measurements/batch/route.ts::p.row.type as MeasurementType":
|
||||
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
|
||||
"app/api/measurements/route.ts::(shorthand)":
|
||||
"Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.",
|
||||
"lib/targets/build-response.ts::{ in: recentTypes":
|
||||
"Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.",
|
||||
"lib/doctor-report/collect.ts::{ notIn: rawExcluded":
|
||||
"The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw statements that read rows of `measurements` without folding them, keyed
|
||||
* `<file>::<ordinal among this file's unfolded statements>`, with why each
|
||||
* stays bounded.
|
||||
*/
|
||||
const ALLOWED_SQL: Record<string, string> = {
|
||||
"lib/analytics/bp-in-target-fast-path.ts::1":
|
||||
"Canonical-source blood pressure, one type over 365 days: a cuff, a few readings a day. The in-target pairing needs the individual readings.",
|
||||
"lib/insights/comprehensive-aggregator.ts::1":
|
||||
"Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.",
|
||||
"lib/measurements/reconcile-external-measurement.ts::1":
|
||||
"Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.",
|
||||
"lib/rollups/measurement-coverage.ts::1":
|
||||
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
|
||||
};
|
||||
|
||||
interface UntakenRead {
|
||||
@@ -87,63 +135,199 @@ interface UntakenRead {
|
||||
typeExpr: string;
|
||||
}
|
||||
|
||||
function scopedFiles(): string[] {
|
||||
function lineOf(src: string, index: number): number {
|
||||
return src.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
/** Index just past the bracket that closes the one at `open`. */
|
||||
function closeOf(src: string, open: number): number {
|
||||
const pair: Record<string, string> = { "(": ")", "{": "}", "[": "]" };
|
||||
const want = pair[src[open]];
|
||||
let depth = 1;
|
||||
let i = open + 1;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === src[open]) depth += 1;
|
||||
else if (src[i] === want) depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** The `where: { … }` object of a call, or null when `where` is a variable. */
|
||||
function whereObject(call: string): string | null {
|
||||
const m = /\bwhere\s*:\s*\{/.exec(call);
|
||||
if (!m) return null;
|
||||
const open = m.index + m[0].length - 1;
|
||||
return call.slice(open, closeOf(call, open));
|
||||
}
|
||||
|
||||
/** The type expression a `where` object constrains on. */
|
||||
function typeExprOf(call: string): string {
|
||||
const where = whereObject(call);
|
||||
if (where === null) {
|
||||
return /\bwhere\s*[,}]/.test(call) ? "(where variable)" : "(none)";
|
||||
}
|
||||
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(where);
|
||||
if (typed) return typed[1].trim();
|
||||
return /\btype\s*[,}\n]/.test(where) ? "(shorthand)" : "(none)";
|
||||
}
|
||||
|
||||
/** A `where` that pins rows by key rather than by a window. */
|
||||
function keyBounded(call: string): boolean {
|
||||
const where = whereObject(call) ?? "";
|
||||
return (
|
||||
/\b(?:id|externalId|measuredAt)\s*:\s*\{\s*in\s*:/.test(where) ||
|
||||
/\bmeasuredAt\s*:\s*(?![\s{])/.test(where)
|
||||
);
|
||||
}
|
||||
|
||||
function sparseLiteral(typeExpr: string): boolean {
|
||||
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
|
||||
if (lit) return !DENSE_TYPES.has(lit[1]);
|
||||
const list = /^\{\s*in\s*:\s*\[([^\]]*)/.exec(typeExpr);
|
||||
if (!list) return false;
|
||||
const items = list[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
items.length > 0 &&
|
||||
items.every((item) => {
|
||||
const m = /^"([A-Z_0-9]+)"$/.exec(item);
|
||||
return m !== null && !DENSE_TYPES.has(m[1]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Every untaken `measurement.findMany` in `src`, with its type expression. */
|
||||
function untakenFindManyReads(
|
||||
file: string,
|
||||
src: string,
|
||||
): { all: number; untaken: UntakenRead[] } {
|
||||
let all = 0;
|
||||
const untaken: UntakenRead[] = [];
|
||||
// Whitespace-tolerant: a call split across lines still matches.
|
||||
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
|
||||
for (let m = re.exec(src); m; m = re.exec(src)) {
|
||||
all += 1;
|
||||
const call = src.slice(m.index, closeOf(src, m.index + m[0].length - 1));
|
||||
if (/\btake\b/.test(call)) continue;
|
||||
if (keyBounded(call)) continue;
|
||||
// A type expression can itself contain a full bracket (`{ in: [...] }`);
|
||||
// the regex above stops at the first `,` / `}` / newline, which is the
|
||||
// stable key the allowlist uses.
|
||||
untaken.push({
|
||||
file,
|
||||
line: lineOf(src, m.index),
|
||||
typeExpr: typeExprOf(call),
|
||||
});
|
||||
}
|
||||
return { all, untaken };
|
||||
}
|
||||
|
||||
/** The statement text of the raw call starting at `at`, or null if opaque. */
|
||||
function statementAt(src: string, at: number): string | null {
|
||||
let i = at;
|
||||
// Skip a type argument: `$queryRaw<Array<{ … }>>`.
|
||||
if (src[i] === "<") {
|
||||
let depth = 1;
|
||||
i += 1;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === "<") depth += 1;
|
||||
else if (src[i] === ">") depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
while (/\s/.test(src[i] ?? "")) i += 1;
|
||||
if (src[i] === "`") {
|
||||
// Tagged template: up to the matching backtick, skipping `${ … }`.
|
||||
let j = i + 1;
|
||||
while (j < src.length && src[j] !== "`") {
|
||||
if (src[j] === "$" && src[j + 1] === "{") j = closeOf(src, j + 1);
|
||||
else j += 1;
|
||||
}
|
||||
return src.slice(i, j + 1);
|
||||
}
|
||||
if (src[i] === "(") return src.slice(i, closeOf(src, i));
|
||||
return null;
|
||||
}
|
||||
|
||||
const READS_MEASUREMENTS =
|
||||
/\bFROM\s+"?measurements"?(?![_\w])|canonicalMeasurementsFrom\s*\(/i;
|
||||
const FOLDS =
|
||||
/GROUP\s+BY|\bDISTINCT\b|\bLIMIT\b|^[\s`(]*(?:Prisma\.sql`)?\s*SELECT\s+(?:COUNT|SUM|AVG|MIN|MAX|STDDEV_POP)\s*\(/i;
|
||||
|
||||
interface RawRead {
|
||||
file: string;
|
||||
line: number;
|
||||
folds: boolean;
|
||||
}
|
||||
|
||||
/** Every raw statement in `src` that reads from `measurements`. */
|
||||
function rawMeasurementReads(file: string, src: string): RawRead[] {
|
||||
const out: RawRead[] = [];
|
||||
const re = /\.\$queryRaw(?:Unsafe)?\b/g;
|
||||
for (let m = re.exec(src); m; m = re.exec(src)) {
|
||||
const statement = statementAt(src, m.index + m[0].length);
|
||||
if (statement === null || !READS_MEASUREMENTS.test(statement)) continue;
|
||||
out.push({
|
||||
file,
|
||||
line: lineOf(src, m.index),
|
||||
folds: FOLDS.test(statement),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d)))
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function untakenReads(): { all: number; untaken: UntakenRead[] } {
|
||||
function scan() {
|
||||
let all = 0;
|
||||
const untaken: UntakenRead[] = [];
|
||||
for (const file of scopedFiles()) {
|
||||
const raw: RawRead[] = [];
|
||||
for (const file of sourceFiles()) {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
// Whitespace-tolerant: a call split across lines still matches.
|
||||
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
|
||||
for (let m = re.exec(src); m; m = re.exec(src)) {
|
||||
all += 1;
|
||||
let i = m.index + m[0].length;
|
||||
let depth = 1;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === "(") depth += 1;
|
||||
else if (src[i] === ")") depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
const call = src.slice(m.index, i);
|
||||
if (/\btake\b/.test(call)) continue;
|
||||
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call);
|
||||
const typeExpr = typed
|
||||
? typed[1].trim()
|
||||
: /\btype\s*,/.test(call)
|
||||
? "(shorthand)"
|
||||
: "(none)";
|
||||
untaken.push({
|
||||
file,
|
||||
line: src.slice(0, m.index).split("\n").length,
|
||||
typeExpr,
|
||||
});
|
||||
}
|
||||
raw.push(...rawMeasurementReads(file, src));
|
||||
if (!SCOPED_DIRS.some((d) => file.startsWith(d))) continue;
|
||||
const reads = untakenFindManyReads(file, src);
|
||||
all += reads.all;
|
||||
untaken.push(...reads.untaken);
|
||||
}
|
||||
return { all, untaken };
|
||||
return { all, untaken, raw };
|
||||
}
|
||||
|
||||
function sparseLiteral(typeExpr: string): boolean {
|
||||
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
|
||||
return lit !== null && !DENSE_TYPES.has(lit[1]);
|
||||
/** `<file>::<n>` for the n-th unfolded statement of each file. */
|
||||
function unfoldedKeys(raw: readonly RawRead[]): Map<string, RawRead> {
|
||||
const keys = new Map<string, RawRead>();
|
||||
const perFile = new Map<string, number>();
|
||||
for (const read of raw) {
|
||||
if (read.folds) continue;
|
||||
const n = (perFile.get(read.file) ?? 0) + 1;
|
||||
perFile.set(read.file, n);
|
||||
keys.set(`${read.file}::${n}`, read);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
describe("dense measurement reads stay bounded (#1023)", () => {
|
||||
const { all, untaken } = untakenReads();
|
||||
const { all, untaken, raw } = scan();
|
||||
|
||||
it("finds the reads it is meant to police", () => {
|
||||
// Guard against a matcher that silently matches nothing.
|
||||
expect(all).toBeGreaterThanOrEqual(40);
|
||||
expect(untaken.length).toBeGreaterThanOrEqual(15);
|
||||
// Pinned below the counts on 2026-10-01 (82 / 45 / 37 / 33).
|
||||
expect(all).toBeGreaterThanOrEqual(75);
|
||||
expect(untaken.length).toBeGreaterThanOrEqual(40);
|
||||
expect(raw.length).toBeGreaterThanOrEqual(33);
|
||||
expect(raw.filter((r) => r.folds).length).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("every untaken read names a sparse literal type or is listed with its reason", () => {
|
||||
it("every untaken read names sparse literal types, a key list, or is listed with its reason", () => {
|
||||
const offenders = untaken
|
||||
.filter((r) => !sparseLiteral(r.typeExpr))
|
||||
.filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED))
|
||||
@@ -156,20 +340,78 @@ describe("dense measurement reads stay bounded (#1023)", () => {
|
||||
expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it("every raw read of measurements folds or is listed with its reason", () => {
|
||||
const keys = unfoldedKeys(raw);
|
||||
const offenders = [...keys]
|
||||
.filter(([key]) => !(key in ALLOWED_SQL))
|
||||
.map(([key, read]) => `${key} (line ${read.line})`);
|
||||
expect(offenders).toEqual([]);
|
||||
expect(Object.keys(ALLOWED_SQL).filter((k) => !keys.has(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the readers fixed for #1023 fold in SQL rather than reading rows", () => {
|
||||
for (const file of [
|
||||
"lib/insights/graded-series.ts",
|
||||
"lib/rollups/tiered-context.ts",
|
||||
"lib/jobs/coach-plan-review.ts",
|
||||
"lib/measurements/daily-series-read.ts",
|
||||
"lib/insights/derived/baseline.ts",
|
||||
"lib/targets/glucose-read.ts",
|
||||
]) {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
|
||||
}
|
||||
const baseline = readFileSync(
|
||||
join(SRC, "lib/insights/derived/baseline.ts"),
|
||||
"utf8",
|
||||
});
|
||||
|
||||
it("plants: an unbounded findMany of a dense type is caught", () => {
|
||||
const planted = `
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId, type: "BLOOD_GLUCOSE", measuredAt: { gte: oneYearAgo } },
|
||||
select: { value: true, measuredAt: true },
|
||||
});`;
|
||||
const { all: n, untaken: found } = untakenFindManyReads(
|
||||
"app/api/planted/route.ts",
|
||||
planted,
|
||||
);
|
||||
expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
|
||||
expect(n).toBe(1);
|
||||
expect(found.map((r) => r.typeExpr)).toEqual(['"BLOOD_GLUCOSE"']);
|
||||
expect(sparseLiteral(found[0].typeExpr)).toBe(false);
|
||||
// The same read with a key list or a take is bounded.
|
||||
expect(
|
||||
untakenFindManyReads(
|
||||
"x.ts",
|
||||
planted.replace("measuredAt: { gte", "id: { in: ids }, x: { gte"),
|
||||
).untaken,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
untakenFindManyReads(
|
||||
"x.ts",
|
||||
planted.replace("select:", "take: 10, select:"),
|
||||
).untaken,
|
||||
).toEqual([]);
|
||||
// A list of sparse literals passes; one dense member does not.
|
||||
expect(sparseLiteral('{ in: ["WEIGHT", "BODY_FAT"]')).toBe(true);
|
||||
expect(sparseLiteral('{ in: ["WEIGHT", "PULSE"]')).toBe(false);
|
||||
});
|
||||
|
||||
it("plants: a raw SELECT of rows from measurements is caught", () => {
|
||||
const planted =
|
||||
"const rows = await prisma.$queryRaw<Array<{ v: number }>>`\n" +
|
||||
' SELECT m."value" FROM measurements m\n' +
|
||||
' WHERE m."user_id" = ${userId} AND m."type" = \'PULSE\'\n' +
|
||||
"`;";
|
||||
const reads = rawMeasurementReads("lib/planted.ts", planted);
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0].folds).toBe(false);
|
||||
const folded = rawMeasurementReads(
|
||||
"lib/planted.ts",
|
||||
planted.replace("WHERE", "WHERE true GROUP BY 1 HAVING true AND"),
|
||||
);
|
||||
expect(folded[0].folds).toBe(true);
|
||||
const aggregate = rawMeasurementReads(
|
||||
"lib/planted.ts",
|
||||
planted.replace('SELECT m."value"', 'SELECT COUNT(*), AVG(m."value")'),
|
||||
);
|
||||
expect(aggregate[0].folds).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Structural guard: every pg-boss queue declares how long its job can run, and
|
||||
* a long one is safe against pg-boss's expiry.
|
||||
*
|
||||
* pg-boss gives a job fifteen minutes unless the send says otherwise, then
|
||||
* fails it, fires its abort signal and retries it while the handler keeps
|
||||
* going. The full-history imports ran beside themselves that way (#1066), and
|
||||
* so could every other job that legitimately needs longer: the nightly status
|
||||
* passes, the pre-generation, the record scan, the key rotation, the backups.
|
||||
*
|
||||
* `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or
|
||||
* `long`. This guard holds the tree to it:
|
||||
*
|
||||
* - every queue bound with `createAndWork` is in the table, and every table
|
||||
* entry is bound;
|
||||
* - every `boss.send` and every schedule tuple names a queue in the table;
|
||||
* - a `long` queue has an expiry between the default and pg-boss's 24-hour
|
||||
* ceiling, every send and schedule for it carries `expireInSeconds` (or an
|
||||
* identifier the table names as its carrier), it stops on the job's budget
|
||||
* where the table says (`jobBudget` / `jobDeadline`, or a declared
|
||||
* one-shot), and it is exclusive per identity where the table says
|
||||
* (`lockedPass` at the binding, or the named marker);
|
||||
* - a `budgeted` queue stops on the budget and relies on the default expiry;
|
||||
* - a `short` queue's sends set no expiry of their own, which would mean the
|
||||
* job is longer than the table claims.
|
||||
*
|
||||
* Floors fail the guard if a matcher stops matching, and the "plants" cases
|
||||
* run the checks over a planted long queue that lacks each protection in turn
|
||||
* (the mutation proof kept in the suite). Removing `lockedPass` from the
|
||||
* pr-detection binding, or the expiry from its cron tuple, fails the real
|
||||
* checks below.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
import {
|
||||
DEFAULT_JOB_EXPIRE_SECONDS,
|
||||
QUEUE_RUNTIME,
|
||||
type QueueRuntime,
|
||||
type SourceSite,
|
||||
} from "@/lib/jobs/queue-runtime";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const REGISTRARS = [
|
||||
"lib/jobs/reminder/register-integration-sync.ts",
|
||||
"lib/jobs/reminder/register-maintenance.ts",
|
||||
"lib/jobs/reminder/register-reminders.ts",
|
||||
"lib/jobs/reminder/register-rollup.ts",
|
||||
"lib/jobs/reminder/register-status.ts",
|
||||
];
|
||||
/** pg-boss refuses an expiry of 24 hours or more. */
|
||||
const MAX_EXPIRE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
const source = new Map(
|
||||
files.map((f) => [f, readFileSync(join(SRC, f), "utf8")]),
|
||||
);
|
||||
|
||||
/** Strip comments so documentation that quotes code never matches. */
|
||||
function code(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** Index just past the bracket that closes the one at `open`. */
|
||||
function closeOf(text: string, open: number): number {
|
||||
const pair: Record<string, string> = { "(": ")", "[": "]", "{": "}" };
|
||||
const want = pair[text[open]];
|
||||
let depth = 1;
|
||||
let i = open + 1;
|
||||
while (depth > 0 && i < text.length) {
|
||||
if (text[i] === text[open]) depth += 1;
|
||||
else if (text[i] === want) depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
type Definitions = Map<string, Array<{ file: string; name: string }>>;
|
||||
|
||||
/** `const NAME = "queue-name"` definitions, per file. */
|
||||
function definitionsOf(texts: ReadonlyMap<string, string>): Definitions {
|
||||
const definitions: Definitions = new Map();
|
||||
for (const [file, text] of texts) {
|
||||
for (const m of text.matchAll(
|
||||
/\bconst\s+([A-Z][A-Z0-9_]*)\s*(?::\s*string\s*)?=\s*"([a-z0-9-]+)"/g,
|
||||
)) {
|
||||
const list = definitions.get(m[1]) ?? [];
|
||||
list.push({ file, name: m[2] });
|
||||
definitions.set(m[1], list);
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
/** Resolve a queue identifier as seen from `file`, preferring its own. */
|
||||
function resolve(
|
||||
definitions: Definitions,
|
||||
file: string,
|
||||
ident: string,
|
||||
): string | null {
|
||||
const defs = definitions.get(ident) ?? [];
|
||||
const own = defs.find((d) => d.file === file);
|
||||
if (own) return own.name;
|
||||
const names = new Set(defs.map((d) => d.name));
|
||||
return names.size === 1 ? [...names][0] : null;
|
||||
}
|
||||
|
||||
interface Site {
|
||||
file: string;
|
||||
queue: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */
|
||||
function bindings(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
definitions: Definitions,
|
||||
): Site[] {
|
||||
const out: Site[] = [];
|
||||
for (const file of REGISTRARS) {
|
||||
const text = code(texts.get(file) ?? "");
|
||||
for (const m of text.matchAll(
|
||||
/createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g,
|
||||
)) {
|
||||
const open = text.indexOf("(", m.index);
|
||||
const queue = resolve(definitions, file, m[1]);
|
||||
if (queue) {
|
||||
out.push({ file, queue, text: text.slice(open, closeOf(text, open)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */
|
||||
function sends(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
definitions: Definitions,
|
||||
): Site[] {
|
||||
const out: Site[] = [];
|
||||
for (const [file, raw] of texts) {
|
||||
const text = code(raw);
|
||||
for (const m of text.matchAll(
|
||||
/\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*([A-Z][A-Z0-9_]*)/g,
|
||||
)) {
|
||||
const open = text.indexOf("(", text.indexOf("send", m.index));
|
||||
const queue = resolve(definitions, file, m[1]);
|
||||
if (queue) {
|
||||
out.push({ file, queue, text: text.slice(open, closeOf(text, open)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */
|
||||
function schedules(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
definitions: Definitions,
|
||||
): Site[] {
|
||||
const out: Site[] = [];
|
||||
for (const file of REGISTRARS) {
|
||||
const text = code(texts.get(file) ?? "");
|
||||
const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text);
|
||||
if (!m) continue;
|
||||
const open = m.index + m[0].length - 1;
|
||||
const body = text.slice(open + 1, closeOf(text, open) - 1);
|
||||
for (let i = 0; i < body.length; i += 1) {
|
||||
if (body[i] !== "[") continue;
|
||||
const end = closeOf(body, i);
|
||||
const tuple = body.slice(i, end);
|
||||
const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1];
|
||||
const queue = ident ? resolve(definitions, file, ident) : null;
|
||||
if (queue) out.push({ file, queue, text: tuple });
|
||||
i = end - 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text of `fn` (a function or a const) in `file`. */
|
||||
function bodyOf(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
site: { file: string; fn: string },
|
||||
): string {
|
||||
const text = code(texts.get(site.file) ?? "");
|
||||
const m = new RegExp(
|
||||
`(?:function\\s+${site.fn}\\b|const\\s+${site.fn}\\b)`,
|
||||
).exec(text);
|
||||
if (!m) return "";
|
||||
const end = text.indexOf("\n}", m.index);
|
||||
return text.slice(m.index, end < 0 ? undefined : end + 2);
|
||||
}
|
||||
|
||||
function siteText(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
site: SourceSite,
|
||||
binding: Site | undefined,
|
||||
): string {
|
||||
return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site);
|
||||
}
|
||||
|
||||
const STOPS = /\bjob(?:Budget|Deadline)\s*\(/;
|
||||
|
||||
/** Every finding for one queue's declaration against the tree. */
|
||||
function check(
|
||||
queue: string,
|
||||
entry: QueueRuntime,
|
||||
texts: ReadonlyMap<string, string>,
|
||||
all: { bindings: Site[]; sends: Site[]; schedules: Site[] },
|
||||
): string[] {
|
||||
const problems: string[] = [];
|
||||
const binding = all.bindings.find((b) => b.queue === queue);
|
||||
const carriers = [...all.sends, ...all.schedules].filter(
|
||||
(s) => s.queue === queue,
|
||||
);
|
||||
if (entry.runtime === "long") {
|
||||
if (
|
||||
!(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) ||
|
||||
!(entry.expireInSeconds < MAX_EXPIRE_SECONDS)
|
||||
) {
|
||||
problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`);
|
||||
}
|
||||
for (const site of carriers) {
|
||||
const carried =
|
||||
/\bexpireInSeconds\b/.test(site.text) ||
|
||||
entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text));
|
||||
if (!carried) {
|
||||
problems.push(
|
||||
`${queue}: ${site.file} sends or schedules it without an expiry`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof entry.stop === "object" && "oneShot" in entry.stop) {
|
||||
if (entry.stop.oneShot.length < 20) {
|
||||
problems.push(`${queue}: a one-shot needs its reason`);
|
||||
}
|
||||
} else if (!STOPS.test(siteText(texts, entry.stop, binding))) {
|
||||
problems.push(`${queue}: does not stop on the job's budget`);
|
||||
}
|
||||
if (entry.exclusive === "lockedPass") {
|
||||
if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) {
|
||||
problems.push(`${queue}: binding holds no lockedPass`);
|
||||
}
|
||||
} else if (
|
||||
!siteText(texts, entry.exclusive.at, binding).includes(
|
||||
entry.exclusive.marker,
|
||||
)
|
||||
) {
|
||||
problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`);
|
||||
}
|
||||
} else {
|
||||
if (entry.runtime === "budgeted") {
|
||||
if (!STOPS.test(siteText(texts, entry.budget, binding))) {
|
||||
problems.push(`${queue}: does not stop on the job's budget`);
|
||||
}
|
||||
}
|
||||
for (const site of carriers) {
|
||||
if (/\bexpireInSeconds\b/.test(site.text)) {
|
||||
problems.push(
|
||||
`${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function scan(texts: ReadonlyMap<string, string>) {
|
||||
const definitions = definitionsOf(texts);
|
||||
return {
|
||||
bindings: bindings(texts, definitions),
|
||||
sends: sends(texts, definitions),
|
||||
schedules: schedules(texts, definitions),
|
||||
};
|
||||
}
|
||||
|
||||
describe("every queue declares its runtime", () => {
|
||||
const all = scan(source);
|
||||
const bound = new Set(all.bindings.map((b) => b.queue));
|
||||
|
||||
it("finds the bindings, sends and schedules it is meant to police", () => {
|
||||
// Pinned below the counts on 2026-10-01 (112 / 64 / 74).
|
||||
expect(all.bindings.length).toBeGreaterThanOrEqual(105);
|
||||
expect(all.sends.length).toBeGreaterThanOrEqual(50);
|
||||
expect(all.schedules.length).toBeGreaterThanOrEqual(70);
|
||||
expect(
|
||||
Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length,
|
||||
).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("every bound queue is in the table, and every entry is bound", () => {
|
||||
expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]);
|
||||
expect(
|
||||
Object.keys(QUEUE_RUNTIME)
|
||||
.filter((q) => !bound.has(q))
|
||||
.sort(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every send and schedule names a queue in the table", () => {
|
||||
const unknown = [...all.sends, ...all.schedules]
|
||||
.filter((s) => !(s.queue in QUEUE_RUNTIME))
|
||||
.map((s) => `${s.file} → ${s.queue}`);
|
||||
expect(unknown).toEqual([]);
|
||||
});
|
||||
|
||||
it("every queue meets its declaration", () => {
|
||||
const problems = Object.entries(QUEUE_RUNTIME).flatMap(([queue, entry]) =>
|
||||
check(queue, entry, source, all),
|
||||
);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("plants: a long queue missing any protection is caught", () => {
|
||||
const registrar = REGISTRARS[1];
|
||||
const planted = (binding: string, schedule: string) =>
|
||||
new Map([
|
||||
...source,
|
||||
[
|
||||
registrar,
|
||||
`const PLANT_QUEUE = "planted-long";\n` +
|
||||
`const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` +
|
||||
`async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`,
|
||||
],
|
||||
]);
|
||||
const entry: QueueRuntime = {
|
||||
runtime: "long",
|
||||
expireInSeconds: 3600,
|
||||
expiryVia: [],
|
||||
stop: "binding",
|
||||
exclusive: "lockedPass",
|
||||
why: "planted",
|
||||
};
|
||||
const findings = (binding: string, schedule: string) => {
|
||||
const texts = planted(binding, schedule);
|
||||
return check("planted-long", entry, texts, scan(texts));
|
||||
};
|
||||
const safe = [
|
||||
"lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))",
|
||||
"[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]",
|
||||
] as const;
|
||||
expect(findings(...safe)).toEqual([]);
|
||||
expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([
|
||||
`planted-long: ${registrar} sends or schedules it without an expiry`,
|
||||
]);
|
||||
expect(
|
||||
findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]),
|
||||
).toEqual(["planted-long: does not stop on the job's budget"]);
|
||||
expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([
|
||||
"planted-long: binding holds no lockedPass",
|
||||
]);
|
||||
expect(
|
||||
check(
|
||||
"planted-long",
|
||||
{ ...entry, expireInSeconds: 900 },
|
||||
...((): [ReadonlyMap<string, string>, ReturnType<typeof scan>] => {
|
||||
const texts = planted(...safe);
|
||||
return [texts, scan(texts)];
|
||||
})(),
|
||||
),
|
||||
).toEqual(["planted-long: expiry 900s out of range"]);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -57,6 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({
|
||||
discoverPhaseCorrelations(args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/day-aggregates", () => ({
|
||||
readSourceDayAggregates: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type SymptomDay,
|
||||
} from "@/lib/cycle/symptom-phase";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
@@ -130,27 +131,19 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
// The outcome metrics the phase contrast compares — soft-delete-scoped,
|
||||
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
|
||||
// for a caller outside `measurements`.
|
||||
// The outcome metrics the phase contrast compares, soft-delete-scoped.
|
||||
// A year of steps, heart-rate variability and sensor glucose is far too
|
||||
// many readings to hold as objects (#1023), and the crosstab uses only
|
||||
// each day's sum and count after picking one source and device per day.
|
||||
// So the fold runs in SQL, one row per type, local day, source and
|
||||
// device, the read the coach's cycle snapshot already makes. Skipped
|
||||
// whole for a caller outside `measurements`.
|
||||
seesMeasurements
|
||||
? prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
? readSourceDayAggregates({
|
||||
userId: user.id,
|
||||
types: PHASE_CROSSTAB_METRIC_TYPES,
|
||||
since: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
timeZone: tz,
|
||||
})
|
||||
: [],
|
||||
prisma.user.findUnique({
|
||||
@@ -200,10 +193,13 @@ export const GET = apiHandler(async () => {
|
||||
today,
|
||||
);
|
||||
|
||||
// Each row stands for its day's readings: `value` is their sum and
|
||||
// `count` their number, so `metricDayMap` gets the raw readings' day mean.
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
value: m.sum,
|
||||
count: m.n,
|
||||
measuredAt: m.firstAt,
|
||||
source: m.source,
|
||||
deviceType: m.deviceType,
|
||||
}));
|
||||
|
||||
@@ -19,8 +19,12 @@ import { unzipSync } from "fflate";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// `count` sizes the report's pulse window (#1023): a sparse account.
|
||||
measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) },
|
||||
// `groupBy` sizes the report's dense windows (#1023): a sparse account.
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(async () => 0),
|
||||
groupBy: vi.fn(async () => []),
|
||||
},
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
|
||||
@@ -30,6 +30,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The glucose and heart-rate streams fold in SQL; this file pins the route's
|
||||
// composition over Prisma mocks, so both readers are mocked at the module.
|
||||
vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/targets/glucose-read")>()),
|
||||
readGlucoseTargetSummaries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
|
||||
readRestingPulseProxy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
@@ -84,6 +94,11 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
foldGlucoseTargetRows,
|
||||
readGlucoseTargetSummaries,
|
||||
} from "@/lib/targets/glucose-read";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-targets-1",
|
||||
@@ -153,6 +168,8 @@ beforeEach(() => {
|
||||
// v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON`
|
||||
// (Prisma's `distinct` dedups client-side after pulling every row).
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]);
|
||||
vi.mocked(readRestingPulseProxy).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
@@ -338,16 +355,26 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => {
|
||||
// The glucose section used to scan the user's entire BLOOD_GLUCOSE
|
||||
// history on every cold build (no `measuredAt` filter at all) just
|
||||
// to read the latest value per context. The read now carries the
|
||||
// same 365-day floor as the latest-ever-per-type query.
|
||||
// to read the latest value per context. It now folds in SQL, newest
|
||||
// per context over a year and per day over thirty days, and no glucose
|
||||
// or heart-rate reading is read as a row.
|
||||
await callGet(makeReq());
|
||||
|
||||
const calls = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => c[0]);
|
||||
const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE");
|
||||
expect(glucoseCall).toBeDefined();
|
||||
expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
|
||||
expect(
|
||||
calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
calls.filter((c) => c?.where?.type?.in?.includes?.("PULSE")),
|
||||
).toEqual([]);
|
||||
const [args] = vi.mocked(readGlucoseTargetSummaries).mock.calls[0];
|
||||
const day = 86_400_000;
|
||||
expect(Date.now() - args.since.getTime()).toBeGreaterThan(364 * day);
|
||||
expect(Date.now() - args.since.getTime()).toBeLessThan(366 * day);
|
||||
expect(Date.now() - args.recentSince.getTime()).toBeGreaterThan(29 * day);
|
||||
expect(Date.now() - args.recentSince.getTime()).toBeLessThan(31 * day);
|
||||
});
|
||||
|
||||
it("pins the complete ordered public response for representative populated data", async () => {
|
||||
@@ -408,10 +435,20 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
).mockImplementation(
|
||||
async (args: { where?: { type?: string | { in: string[] } } }) => {
|
||||
if (args.where?.type === "SLEEP_DURATION") return sleepRows;
|
||||
if (args.where?.type === "BLOOD_GLUCOSE") return glucoseRows;
|
||||
return genericRows;
|
||||
},
|
||||
);
|
||||
vi.mocked(readGlucoseTargetSummaries).mockImplementation(
|
||||
async ({ recentSince, timeZone }) =>
|
||||
foldGlucoseTargetRows(
|
||||
glucoseRows.map((row) => ({
|
||||
...row,
|
||||
glucoseContext: row.glucoseContext as "FASTING",
|
||||
})),
|
||||
recentSince,
|
||||
timeZone,
|
||||
),
|
||||
);
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ type: "WEIGHT", value: 70 },
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 125 },
|
||||
|
||||
@@ -543,9 +543,18 @@ async function sleepListResponse(
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
|
||||
// Bound the read window: honour an explicit from/to, otherwise cap to
|
||||
// the last year so the per-stage scan stays small.
|
||||
const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000);
|
||||
// Bound the read window to a year ending at `to` (or now). An explicit
|
||||
// `from` narrows it but never widens it: an early `from` used to walk every
|
||||
// per-stage row back to that date, which on a multi-year, multi-source
|
||||
// account is the unbounded read this cap exists to stop. Older nights are
|
||||
// reached by moving `to` back, and `meta.windowFrom` says where a cut
|
||||
// window started so a client can tell a cut from an empty history.
|
||||
const windowEnd = to ?? new Date();
|
||||
const floor = new Date(
|
||||
windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000,
|
||||
);
|
||||
const since = from && from > floor ? from : floor;
|
||||
const windowCut = from !== undefined && from < floor;
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -690,7 +699,13 @@ async function sleepListResponse(
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: { total: totalNights, limit, offset, groupBy: "night" },
|
||||
meta: {
|
||||
total: totalNights,
|
||||
limit,
|
||||
offset,
|
||||
groupBy: "night",
|
||||
...(windowCut ? { windowFrom: since.toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,23 @@ const SLEEP_SERIES_MAX_DAYS = 365;
|
||||
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
|
||||
*/
|
||||
const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
|
||||
/**
|
||||
* The kinds a device can write at sampling rate. Heart-rate variability and
|
||||
* blood oxygen joined pulse and glucose once watches and rings began writing
|
||||
* them every few minutes overnight: their 3650-day "Alle" range read every
|
||||
* raw row, which is the read this rule exists to stop.
|
||||
*/
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set([
|
||||
"glucose",
|
||||
"pulse",
|
||||
"heartRateVariability",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, a pulse or glucose series with more rows
|
||||
* than this is bucketed per local hour in SQL instead of sent raw.
|
||||
* #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV,
|
||||
* blood oxygen) with more rows than this is bucketed per local hour in SQL
|
||||
* instead of sent raw.
|
||||
*
|
||||
* A watch that records heart rate once a minute puts 43 000 rows in the
|
||||
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
|
||||
@@ -381,7 +393,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
})) > DENSE_SERIES_RAW_ROW_CAP)
|
||||
) {
|
||||
// Day buckets past the raw window; hour buckets for a pulse or CGM
|
||||
// Day buckets past the raw window; hour buckets for a dense-kind
|
||||
// stream too dense to send raw inside it.
|
||||
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
@@ -489,17 +501,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// pulse — `value` is the bucket's average; valueMin/valueMax carry the
|
||||
// bucket's low/high band (folding each hourly import bucket's own
|
||||
// `value` is the bucket's average. For pulse, valueMin/valueMax carry
|
||||
// the bucket's low/high band (folding each hourly import bucket's own
|
||||
// spread via the COALESCE above), same band semantics as the per-hour
|
||||
// shape.
|
||||
// shape. The other dense kinds keep the bare point shape their raw rows
|
||||
// have, so a client reads a bucket the way it reads a reading.
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: round2(r.mean),
|
||||
secondary: null,
|
||||
valueMin: r.min_value,
|
||||
valueMax: r.max_value,
|
||||
...(kind === "pulse"
|
||||
? { valueMin: r.min_value, valueMax: r.max_value }
|
||||
: {}),
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
|
||||
@@ -192,7 +192,10 @@ export default function LabsPage() {
|
||||
title={t("labs.addResult")}
|
||||
description={t("labs.addDescription")}
|
||||
footer={
|
||||
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
|
||||
<div
|
||||
ref={setAddFooterEl}
|
||||
className="flex w-full flex-wrap justify-end gap-2"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LabForm
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function NotificationsPage() {
|
||||
title={t("notifications.title")}
|
||||
description={t("notifications.subtitle")}
|
||||
actions={headerActions}
|
||||
stackActionsOnPhone
|
||||
/>
|
||||
<EmptyState
|
||||
className="max-w-2xl"
|
||||
@@ -227,6 +228,7 @@ export default function NotificationsPage() {
|
||||
title={t("notifications.title")}
|
||||
description={t("notifications.subtitle")}
|
||||
actions={headerActions}
|
||||
stackActionsOnPhone
|
||||
/>
|
||||
|
||||
{/* Desktop: table layout */}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function AppLogPreviewSection() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
@@ -133,13 +133,12 @@ export function AppLogPreviewSection() {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.app-logs.processDetail")}
|
||||
</p>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("admin.section.app-logs.processDetail")}</p>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
type="search"
|
||||
|
||||
@@ -163,7 +163,10 @@ export function LabBiomarkerChart({
|
||||
const min = Math.min(...candidates);
|
||||
const max = Math.max(...candidates);
|
||||
const pad = (max - min || Math.abs(max) || 1) * 0.1;
|
||||
return [min - pad, max + pad];
|
||||
// A reference window that starts at 0 (LDL "0–130") must not drag the
|
||||
// axis below zero: a concentration cannot be negative, and a "-13" tick
|
||||
// reads as a value. The pad only stays below zero when the data already is.
|
||||
return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad];
|
||||
}, [points, lowerBound, upperBound, sourceBand]);
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
|
||||
@@ -368,6 +368,10 @@ export function LabForm({
|
||||
variant="outline"
|
||||
data-action="save-and-add-another"
|
||||
disabled={submitting}
|
||||
// Three buttons do not fit one phone-width row: the secondary save
|
||||
// takes a row of its own above Cancel + Save instead of pushing
|
||||
// Cancel off the left edge of the sheet.
|
||||
className="max-md:order-first max-md:w-full"
|
||||
>
|
||||
{pendingAction === "saveAndAddAnother" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
});
|
||||
}, [readings, sortBy, sortDir]);
|
||||
|
||||
// One action pair per reading, shared by the phone list and the table.
|
||||
const renderActions = (r: LabResultDto) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("labs.form.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("labs.form.takenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="range"
|
||||
label={t("labs.referenceLabel")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="note"
|
||||
label={t("labs.hasNote")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
<span className="sr-only">{t("labs.editReading")}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ordered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
<div className="space-y-2">
|
||||
<span>{formatLabReading(r)}</span>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
referenceHigh={r.referenceHigh}
|
||||
unit={r.unit}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
<SourceRangeNote
|
||||
reading={r}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.hasNote ? t("labs.hasNote") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Phones get one stacked row per reading. The table needs ~510 px for
|
||||
its five columns, so on a phone it panned sideways inside its
|
||||
container and parked the edit / delete buttons off-screen, while the
|
||||
range bar's end labels were clipped by the same container. */}
|
||||
<ul
|
||||
data-slot="lab-history-mobile-list"
|
||||
className="divide-border divide-y lg:hidden"
|
||||
>
|
||||
{ordered.map((r) => (
|
||||
<li key={r.id} className="flex items-start gap-3 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="font-semibold tabular-nums">
|
||||
{formatLabReading(r)}
|
||||
</div>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
referenceHigh={r.referenceHigh}
|
||||
unit={r.unit}
|
||||
/>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
{r.hasNote ? (
|
||||
<span className="text-xs">{t("labs.hasNote")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<SourceRangeNote
|
||||
reading={r}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</div>
|
||||
{renderActions(r)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div data-slot="lab-history-table" className="max-lg:hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("labs.form.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("labs.form.takenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="range"
|
||||
label={t("labs.referenceLabel")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="note"
|
||||
label={t("labs.hasNote")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
<span className="sr-only">{t("labs.editReading")}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ordered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
<div className="space-y-2">
|
||||
<span>{formatLabReading(r)}</span>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
referenceHigh={r.referenceHigh}
|
||||
unit={r.unit}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
<SourceRangeNote
|
||||
reading={r}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.hasNote ? t("labs.hasNote") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
{renderActions(r)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={editingId !== null}
|
||||
|
||||
@@ -80,7 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
|
||||
|
||||
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
|
||||
return (
|
||||
<div className="w-48">
|
||||
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
|
||||
<LabReferenceRangeBar
|
||||
value={reading.value}
|
||||
referenceLow={reading.referenceLow}
|
||||
@@ -91,6 +91,35 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact list is a five-column table from `lg` up: name and reading, range
|
||||
* badge, range bar, trend, chevron. Below that the same five cells fold into
|
||||
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
|
||||
* trend + gaps) are wider than a phone, and the only flexible column — the
|
||||
* name — was the one that gave way: it collapsed to nothing and the row
|
||||
* panned sideways off the card.
|
||||
*
|
||||
* Phone layout, per row:
|
||||
*
|
||||
* name / reading · date badge ›
|
||||
* range bar trend
|
||||
*
|
||||
* The placement classes are all `max-lg:` so the desktop table keeps exactly
|
||||
* the subgrid it had.
|
||||
*/
|
||||
const ROW_CLASS =
|
||||
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
|
||||
|
||||
const MOBILE_CELL = {
|
||||
reading: "max-lg:col-start-1 max-lg:row-start-1",
|
||||
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
|
||||
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
|
||||
trend:
|
||||
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
|
||||
end: "max-lg:col-start-3 max-lg:row-start-1",
|
||||
notLinked: "max-lg:col-span-full max-lg:row-start-3",
|
||||
} as const;
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
@@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
|
||||
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] lg:gap-x-3">
|
||||
{groups.map((group) => {
|
||||
const inner = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
<span
|
||||
data-slot="lab-list-analyte"
|
||||
className="truncate font-medium"
|
||||
>
|
||||
{group.analyte}
|
||||
</span>
|
||||
</div>
|
||||
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
|
||||
className={cn(
|
||||
ROW_CLASS,
|
||||
"hover:bg-muted/40 transition-colors",
|
||||
)}
|
||||
>
|
||||
{inner}
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
className="w-full"
|
||||
className={cn("lg:w-full", MOBILE_CELL.badge)}
|
||||
/>
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className="w-[72px]">
|
||||
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0",
|
||||
MOBILE_CELL.end,
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
// Un-linked group (not backfilled to a catalog biomarker):
|
||||
// render inert but say so, so it doesn't read as a broken
|
||||
// link next to its clickable neighbours.
|
||||
<div
|
||||
key={group.key}
|
||||
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
|
||||
>
|
||||
<div key={group.key} className={ROW_CLASS}>
|
||||
{inner}
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
className="w-full"
|
||||
className={cn("lg:w-full", MOBILE_CELL.badge)}
|
||||
/>
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className="w-[72px]">
|
||||
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 text-xs italic">
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 text-xs italic",
|
||||
MOBILE_CELL.notLinked,
|
||||
)}
|
||||
>
|
||||
{t("labs.notLinkedYet")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -129,9 +129,17 @@ export function OcrRowEditor({
|
||||
{fieldError("analyte", "labs.ocr.errorAnalyte")}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{row.biomarkerMatch === "existing" ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
// The chip repeats the analyte, so a long panel name ran past
|
||||
// the row on a phone; it truncates inside the row instead.
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground max-w-full"
|
||||
title={t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
>
|
||||
<Link2 aria-hidden />
|
||||
{t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
|
||||
@@ -89,7 +89,12 @@ export function SettingsCardHeader({
|
||||
{titleAccessory}
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{status}</div>
|
||||
// `max-w-full` caps the slot at the row once it wraps under the
|
||||
// title, so a status line longer than a phone row wraps inside
|
||||
// the card instead of widening the page.
|
||||
<div className="flex max-w-full shrink-0 items-center gap-2">
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{/* One sentence, one text run. This slot deliberately carries no
|
||||
|
||||
@@ -294,7 +294,7 @@ function AddProviderControl({
|
||||
const [picked, setPicked] = useState<string>(addable[0] ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
aria-label={t("settings.ai.providerChain.addProvider")}
|
||||
value={picked}
|
||||
|
||||
@@ -869,7 +869,7 @@ function SortableWidgetRow({
|
||||
style={style}
|
||||
data-slot="widget-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 sm:flex-nowrap ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -898,7 +898,12 @@ function SortableWidgetRow({
|
||||
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
|
||||
{labels.widgetLabel}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
{/* On a phone the two switches and two arrows (~250 px) left the name
|
||||
five characters ("Gewic…"). Below `sm` the controls wrap onto a
|
||||
second line, right-aligned under the column heads, and the name
|
||||
keeps the first line to itself. */}
|
||||
<span className="basis-full sm:hidden" aria-hidden="true" />
|
||||
<div className="flex w-12 justify-center max-sm:ml-auto">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => onToggleTile(widget.id, v)}
|
||||
|
||||
@@ -234,8 +234,6 @@ export function LowStockCard({
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.lowStock.toggleAria")}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("notifications.lowStock.statusOn")
|
||||
@@ -244,6 +242,7 @@ export function LowStockCard({
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
|
||||
{enabled && (
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="low-stock-days" className="text-sm font-medium">
|
||||
|
||||
@@ -193,8 +193,6 @@ export function MoodReminderCard({
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.moodReminder.toggleAria")}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("notifications.moodReminder.statusOn")
|
||||
@@ -203,6 +201,7 @@ export function MoodReminderCard({
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
|
||||
{enabled && (
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
|
||||
|
||||
@@ -112,8 +112,11 @@ export function ReportScopePicker({
|
||||
{/* The one-click way to a complete report. An inline action row
|
||||
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
|
||||
It is offered, never applied — and it stays offered so a person can
|
||||
come back to the standard set after experimenting. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
come back to the standard set after experimenting. On a phone the
|
||||
action's own label is wider than half the row, so beside it the
|
||||
sentence shrank to a one-word column and the button ran past the
|
||||
card edge: there the action drops under the sentence instead. */}
|
||||
<div className="flex items-start justify-between gap-3 max-sm:flex-col">
|
||||
<p
|
||||
id={hintId}
|
||||
className="text-muted-foreground min-w-0 flex-1 text-xs"
|
||||
|
||||
@@ -26,6 +26,15 @@ import { cn } from "@/lib/utils";
|
||||
* shells hand-rolled the whole block to get the second copy, which is how
|
||||
* they drifted apart from every module page in the first place.
|
||||
*/
|
||||
/**
|
||||
* A one-word German title ("Krankheitstagebuch") is wider than the column a
|
||||
* phone leaves beside the actions, and without a break opportunity it ran
|
||||
* underneath them. `hyphens-auto` breaks it by the page's `lang`; the
|
||||
* `wrap-break-word` floor covers a word the hyphenation dictionary does not.
|
||||
*/
|
||||
const HEADING_CLASS =
|
||||
"min-w-0 text-2xl font-bold tracking-tight hyphens-auto wrap-break-word";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
titleId,
|
||||
@@ -34,6 +43,7 @@ export function PageHeader({
|
||||
backLink,
|
||||
topSlot,
|
||||
actions,
|
||||
stackActionsOnPhone = false,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
@@ -44,37 +54,65 @@ export function PageHeader({
|
||||
/** Optional block above the title (e.g. a hub back-link the shell owns). */
|
||||
topSlot?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* Below `sm`, move the actions onto their own row under the description.
|
||||
* For text-labelled actions (two outline links, say): beside the title they
|
||||
* left the H1 a column narrower than its own word. Icon actions fit beside
|
||||
* the title and leave this off.
|
||||
*/
|
||||
stackActionsOnPhone?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const heading =
|
||||
headingAs === "div" ? (
|
||||
<div
|
||||
id={titleId}
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
<div id={titleId} role="heading" aria-level={1} className={HEADING_CLASS}>
|
||||
{title}
|
||||
</div>
|
||||
) : (
|
||||
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
|
||||
<h1 id={titleId} className={HEADING_CLASS}>
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
|
||||
// Title, description and actions share one grid. On a phone the
|
||||
// description spans the full row under the title + actions: a header with
|
||||
// three or four icon actions otherwise left the sentence a ~130 px column
|
||||
// that wrapped five or six times. From `sm` up the actions span both rows,
|
||||
// so the description sits beside them exactly as before.
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{backLink ? <BackLink {...backLink} /> : null}
|
||||
{topSlot}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
{heading}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
data-slot="page-header-row"
|
||||
className={cn(
|
||||
"grid items-start gap-x-3 gap-y-1.5",
|
||||
actions ? "grid-cols-[minmax(0,1fr)_auto]" : "grid-cols-1",
|
||||
actions && stackActionsOnPhone && "max-sm:grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{heading}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{actions}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-start-2 row-start-1 flex shrink-0 items-center gap-2",
|
||||
description && "sm:row-span-2",
|
||||
stackActionsOnPhone &&
|
||||
"max-sm:col-span-full max-sm:col-start-1 max-sm:row-start-3 max-sm:flex-wrap",
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm",
|
||||
actions && "col-span-full sm:col-span-1",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +139,26 @@ export function resolveRestingPulseSeries(input: {
|
||||
pulseSamples: ReadonlyArray<PulseSample>;
|
||||
/** Day key for the proxy buckets: the user's own day. */
|
||||
dayKeyOf: (d: Date) => string;
|
||||
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
|
||||
return mergeRestingWithProxy({
|
||||
restingSamples: input.restingSamples,
|
||||
proxySamples: deriveRestingProxyFromPulse(
|
||||
input.pulseSamples,
|
||||
input.dayKeyOf,
|
||||
),
|
||||
dayKeyOf: input.dayKeyOf,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link resolveRestingPulseSeries} for a caller that already holds the
|
||||
* proxy, one point per day, as `readRestingPulseProxy` folds it in Postgres.
|
||||
* The per-day merge and the label are the same.
|
||||
*/
|
||||
export function mergeRestingWithProxy(input: {
|
||||
restingSamples: ReadonlyArray<PulseSample>;
|
||||
proxySamples: ReadonlyArray<PulseSample>;
|
||||
dayKeyOf: (d: Date) => string;
|
||||
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
|
||||
const dayKeyOf = input.dayKeyOf;
|
||||
const byTime = (a: PulseSample, b: PulseSample) =>
|
||||
@@ -148,14 +168,13 @@ export function resolveRestingPulseSeries(input: {
|
||||
// Days already covered by a real resting row — the proxy must not
|
||||
// second-guess them.
|
||||
const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt)));
|
||||
const proxy = deriveRestingProxyFromPulse(
|
||||
input.pulseSamples,
|
||||
dayKeyOf,
|
||||
).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt)));
|
||||
const proxy = input.proxySamples.filter(
|
||||
(p) => !restingDays.has(dayKeyOf(p.measuredAt)),
|
||||
);
|
||||
|
||||
if (resting.length === 0) {
|
||||
return proxy.length > 0
|
||||
? { series: proxy, which: "proxy" }
|
||||
? { series: [...proxy], which: "proxy" }
|
||||
: { series: [], which: "none" };
|
||||
}
|
||||
if (proxy.length === 0) return { series: resting, which: "resting" };
|
||||
|
||||
@@ -390,19 +390,29 @@ export interface RotationResult {
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/** Re-encrypt one column's stale rows to the active key. */
|
||||
/** Thrown out of a column walk when the caller's time budget is spent. */
|
||||
class RotationStopped extends Error {}
|
||||
|
||||
/**
|
||||
* Re-encrypt one column's stale rows to the active key. `shouldStop` is the
|
||||
* caller's time budget, checked between rows; a stopped column reports
|
||||
* `stopped` and the rows it did not reach stay on their old key for the next
|
||||
* run, which skips every row already rotated.
|
||||
*/
|
||||
export async function rotateColumn(
|
||||
client: CorpusClient,
|
||||
col: EncryptedColumn,
|
||||
): Promise<RotationResult> {
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<RotationResult & { stopped: boolean }> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const result: RotationResult = {
|
||||
const result: RotationResult & { stopped: boolean } = {
|
||||
model: col.model,
|
||||
field: col.field,
|
||||
scanned: 0,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
dropped: 0,
|
||||
stopped: false,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -432,6 +442,10 @@ export async function rotateColumn(
|
||||
// (`convertSingleValueBackup`). A row converted, replaced or deleted since
|
||||
// it was listed is gone, not an error.
|
||||
for await (const id of singleValueIds(delegate)) {
|
||||
if (shouldStop()) {
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
result.scanned += 1;
|
||||
try {
|
||||
const outcome = await convertSingleValueBackup(
|
||||
@@ -452,31 +466,37 @@ export async function rotateColumn(
|
||||
// Idempotent — rows already on the active key are skipped, so an
|
||||
// interrupted run resumes cleanly on the next invocation. `scanned` counts
|
||||
// the rows that hold ciphertext, whatever the column.
|
||||
await walkColumn(delegate, col, async ({ id, value, codec }) => {
|
||||
result.scanned += 1;
|
||||
if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return;
|
||||
try {
|
||||
// The plaintext is never inspected, only re-sealed, so every envelope
|
||||
// a stored value can legitimately carry survives rotation untouched.
|
||||
const next =
|
||||
codec !== null
|
||||
? reencryptBlob(value as Uint8Array, codec, col.aad)
|
||||
: fromCiphertext(
|
||||
encrypt(decrypt(toCiphertext(value, col.kind)!)),
|
||||
col.kind,
|
||||
);
|
||||
await delegate.update({
|
||||
where: { [pkField(col)]: id },
|
||||
data: { [col.field]: next },
|
||||
});
|
||||
result.rotated += 1;
|
||||
} catch (err) {
|
||||
// Deleted between the read and the write (a backup replacing its
|
||||
// pieces, an account going): nothing is left to rotate.
|
||||
if (isRowGone(err)) return;
|
||||
await onUnreadable(id);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await walkColumn(delegate, col, async ({ id, value, codec }) => {
|
||||
if (shouldStop()) throw new RotationStopped();
|
||||
result.scanned += 1;
|
||||
if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return;
|
||||
try {
|
||||
// The plaintext is never inspected, only re-sealed, so every envelope
|
||||
// a stored value can legitimately carry survives rotation untouched.
|
||||
const next =
|
||||
codec !== null
|
||||
? reencryptBlob(value as Uint8Array, codec, col.aad)
|
||||
: fromCiphertext(
|
||||
encrypt(decrypt(toCiphertext(value, col.kind)!)),
|
||||
col.kind,
|
||||
);
|
||||
await delegate.update({
|
||||
where: { [pkField(col)]: id },
|
||||
data: { [col.field]: next },
|
||||
});
|
||||
result.rotated += 1;
|
||||
} catch (err) {
|
||||
// Deleted between the read and the write (a backup replacing its
|
||||
// pieces, an account going): nothing is left to rotate.
|
||||
if (isRowGone(err)) return;
|
||||
await onUnreadable(id);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof RotationStopped)) throw err;
|
||||
result.stopped = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -488,16 +508,31 @@ export interface CorpusRotation {
|
||||
totalErrors: number;
|
||||
/** Unreadable rows deleted from `disposable` columns. */
|
||||
totalDropped: number;
|
||||
/**
|
||||
* The time budget ran out before every column was walked. Running again
|
||||
* finishes the job: rows already on the active key are skipped.
|
||||
*/
|
||||
stoppedEarly: boolean;
|
||||
}
|
||||
|
||||
/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */
|
||||
/**
|
||||
* Re-encrypt the whole corpus to the active key. Idempotent + active-key-only.
|
||||
* `shouldStop` is the caller's time budget (`jobBudget` in the worker).
|
||||
*/
|
||||
export async function rotateCorpus(
|
||||
client: CorpusClient,
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<CorpusRotation> {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const results: RotationResult[] = [];
|
||||
let stoppedEarly = false;
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
results.push(await rotateColumn(client, col));
|
||||
const { stopped, ...result } = await rotateColumn(client, col, shouldStop);
|
||||
results.push(result);
|
||||
if (stopped) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let totalScanned = 0;
|
||||
let totalRotated = 0;
|
||||
@@ -516,5 +551,6 @@ export async function rotateCorpus(
|
||||
totalRotated,
|
||||
totalErrors,
|
||||
totalDropped,
|
||||
stoppedEarly,
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,7 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
|
||||
return Promise.resolve(rows[name] ?? []);
|
||||
},
|
||||
count: () => Promise.resolve(0),
|
||||
groupBy: () => Promise.resolve([]),
|
||||
findUnique: () => {
|
||||
calls.push(name);
|
||||
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
|
||||
|
||||
@@ -77,8 +77,21 @@ import {
|
||||
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, pulse takes the per-day path anyway once the
|
||||
* window holds more readings than this.
|
||||
* The types a device writes at sampling rate whose report sections are
|
||||
* statistics and a series and nothing else: the figures the day path keeps
|
||||
* exactly (count, mean, minimum, maximum, latest) plus one point per local
|
||||
* day. Glucose is dense too but has its own rule below, because its clinical
|
||||
* panel needs the per-reading counts the bucket query carries.
|
||||
*/
|
||||
const DENSE_STAT_TYPES = [
|
||||
"PULSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"OXYGEN_SATURATION",
|
||||
] as const satisfies readonly MeasurementType[];
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, a dense statistics type takes the per-day
|
||||
* path anyway once the window holds more of its readings than this.
|
||||
*
|
||||
* A watch that records heart rate once a minute puts 130 000 readings in a
|
||||
* 90-day report. Read raw, every one of them became an object, went through
|
||||
@@ -170,22 +183,38 @@ export async function collectDoctorReportData(
|
||||
const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`);
|
||||
|
||||
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
|
||||
const densePulse =
|
||||
!excluded.includes("PULSE") &&
|
||||
(aggregateDenseTypes ||
|
||||
(await prisma.measurement.count({
|
||||
where: {
|
||||
userId,
|
||||
type: "PULSE",
|
||||
measuredAt: { gte: start, lte: end },
|
||||
deletedAt: null,
|
||||
},
|
||||
})) > DENSE_REPORT_RAW_ROW_CAP);
|
||||
const includedDenseStatTypes = DENSE_STAT_TYPES.filter(
|
||||
(type) => !excluded.includes(type),
|
||||
);
|
||||
// Inside the raw window, one grouped count decides each type on its own:
|
||||
// a watch's minute-by-minute heart rate folds while a few blood-oxygen
|
||||
// readings a night beside it stay raw.
|
||||
const denseStatCounts =
|
||||
aggregateDenseTypes || includedDenseStatTypes.length === 0
|
||||
? null
|
||||
: new Map(
|
||||
(
|
||||
await prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId,
|
||||
type: { in: [...includedDenseStatTypes] },
|
||||
measuredAt: { gte: start, lte: end },
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
})
|
||||
).map((row) => [row.type, row._count._all]),
|
||||
);
|
||||
const denseTypes: MeasurementType[] = includedDenseStatTypes.filter(
|
||||
(type) =>
|
||||
denseStatCounts === null ||
|
||||
(denseStatCounts.get(type) ?? 0) > DENSE_REPORT_RAW_ROW_CAP,
|
||||
);
|
||||
const denseGlucose =
|
||||
aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
|
||||
const rawExcluded = [...excluded];
|
||||
if (densePulse) rawExcluded.push("PULSE");
|
||||
if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE");
|
||||
if (denseGlucose) denseTypes.push("BLOOD_GLUCOSE");
|
||||
const rawExcluded = [...excluded, ...denseTypes];
|
||||
|
||||
const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf));
|
||||
const wantsIntakeEvents =
|
||||
@@ -318,8 +347,7 @@ export async function collectDoctorReportData(
|
||||
start,
|
||||
end,
|
||||
reportTz,
|
||||
densePulse,
|
||||
denseGlucose,
|
||||
denseTypes,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* The per-local-day pre-aggregation for the two sample-dense measurement
|
||||
* types.
|
||||
* The per-local-day pre-aggregation for the sample-dense measurement types.
|
||||
*
|
||||
* On a long window a CGM or a per-sample heart-rate account produces six
|
||||
* figures of rows, so pulse and glucose collapse to one row per local
|
||||
* day/source/device/context in SQL and the canonical-source picker then works
|
||||
* over that bounded intermediate set. Every other type keeps the raw-row path.
|
||||
* figures of rows, so the dense types (glucose, pulse, heart-rate variability,
|
||||
* blood oxygen) collapse to one row per local day/source/device/context in
|
||||
* SQL and the canonical-source picker then works over that bounded
|
||||
* intermediate set. Every other type keeps the raw-row path.
|
||||
*
|
||||
* Extracted from the aggregator with the selection rework: the query is a job
|
||||
* of its own, it is the one raw-SQL surface on this path, and keeping it in a
|
||||
* thousand-line function is how a parameter stops being reviewed.
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
@@ -46,8 +47,8 @@ export interface DenseMeasurementBucket {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the dense buckets for whichever of the two types the caller wants. Both
|
||||
* flags false is not a call: the caller skips it entirely.
|
||||
* Load the dense buckets for the types the caller names. An empty list is not
|
||||
* a call: nothing is read.
|
||||
*
|
||||
* Every value is bound as a tagged-template parameter — no splice, no
|
||||
* interpolation of anything that came off the wire.
|
||||
@@ -57,11 +58,13 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
reportTz: string;
|
||||
densePulse: boolean;
|
||||
denseGlucose: boolean;
|
||||
denseTypes: readonly MeasurementType[];
|
||||
}): Promise<DenseMeasurementBucket[]> {
|
||||
const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
|
||||
if (!densePulse && !denseGlucose) return [];
|
||||
const { userId, start, end, reportTz, denseTypes } = params;
|
||||
if (denseTypes.length === 0) return [];
|
||||
const typeList = Prisma.join(
|
||||
denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`),
|
||||
);
|
||||
return prisma.$queryRaw<DenseMeasurementBucket[]>`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
@@ -81,11 +84,7 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
AND m."measured_at" >= ${start}
|
||||
AND m."measured_at" <= ${end}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND (
|
||||
(${densePulse} AND m."type" = 'PULSE'::"measurement_type")
|
||||
OR
|
||||
(${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
|
||||
)
|
||||
AND m."type" IN (${typeList})
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
|
||||
@@ -207,6 +207,31 @@ describe("findPregenerateCandidates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runInsightPregenerate — the job's time budget", () => {
|
||||
it("stops between candidates once the budget is spent and says so", async () => {
|
||||
capabilities = {
|
||||
briefing: unavailable("user_disabled"),
|
||||
statusText: unavailable("user_disabled"),
|
||||
};
|
||||
const { prisma } = makePrisma([
|
||||
{ id: "u1", locale: "de" },
|
||||
{ id: "u2", locale: "de" },
|
||||
{ id: "u3", locale: "de" },
|
||||
]);
|
||||
let checks = 0;
|
||||
const result = await runInsightPregenerate(prisma as never, {
|
||||
generate: vi.fn(),
|
||||
statusGenerators: [],
|
||||
warmGenericMetrics: vi.fn().mockResolvedValue(0),
|
||||
// Spent after the first candidate.
|
||||
shouldStop: () => checks++ >= 1,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ total: 3, skipped: 1, stoppedEarly: true });
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runInsightPregenerate — per-user capabilities", () => {
|
||||
it("skips a user with neither capability before any budget write or generation", async () => {
|
||||
capabilities = {
|
||||
@@ -1337,7 +1362,7 @@ describe("queue registration", () => {
|
||||
|
||||
it("schedules the cron in the schedules table (with retry policy)", () => {
|
||||
expect(workerSrc).toMatch(
|
||||
/\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*insightRetryOptions\s*\]/,
|
||||
/\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*INSIGHT_PREGENERATE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* `lockedPass`: each job runs under the lock `job:<queue>:<identity>`, and a
|
||||
* delivery that finds the lock held does no work and succeeds.
|
||||
*
|
||||
* The lock itself (a session advisory lock on a connection of its own) is
|
||||
* exercised against Postgres in
|
||||
* `tests/integration/integration-backfill-lane-expiry.test.ts`; this pins the
|
||||
* wrapper around it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
const held = vi.hoisted(() => new Set<string>());
|
||||
const keys = vi.hoisted(() => [] as string[]);
|
||||
|
||||
vi.mock("@/lib/jobs/job-lock", () => ({
|
||||
withJobLock: async <T>(key: string, run: () => Promise<T>) => {
|
||||
keys.push(key);
|
||||
if (held.has(key)) return { ran: false };
|
||||
held.add(key);
|
||||
try {
|
||||
return { ran: true, result: await run() };
|
||||
} finally {
|
||||
held.delete(key);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { jobDone, jobFailed } from "@/lib/jobs/job-outcome";
|
||||
import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass";
|
||||
|
||||
type Payload = { userId?: string };
|
||||
const job = (data: Payload) => ({ id: "j", data }) as Job<Payload>;
|
||||
|
||||
beforeEach(() => {
|
||||
held.clear();
|
||||
keys.length = 0;
|
||||
});
|
||||
|
||||
describe("lockedPass", () => {
|
||||
it("runs each job under its queue and identity", async () => {
|
||||
const handler = vi.fn(async () => jobDone({ generated: 1 }));
|
||||
const bound = lockedPass(
|
||||
"q",
|
||||
(j: Job<Payload>) =>
|
||||
j.data.userId ? `user:${j.data.userId}` : WHOLE_PASS,
|
||||
handler,
|
||||
);
|
||||
const outcome = await bound([job({ userId: "a" })]);
|
||||
expect(outcome).toEqual(jobDone({ generated: 1 }));
|
||||
await bound([job({})]);
|
||||
expect(keys).toEqual(["job:q:user:a", "job:q:pass"]);
|
||||
});
|
||||
|
||||
it("does nothing beside a run that still holds the lock", async () => {
|
||||
held.add("job:q:pass");
|
||||
const handler = vi.fn(async () => jobDone());
|
||||
const outcome = await lockedPass("q", () => WHOLE_PASS, handler)([job({})]);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual(jobDone({ already_running: 1 }));
|
||||
});
|
||||
|
||||
it("passes a failed outcome through", async () => {
|
||||
const failed = jobFailed("stopped early", undefined, {
|
||||
stopped_early: true,
|
||||
});
|
||||
const outcome = await lockedPass(
|
||||
"q",
|
||||
() => WHOLE_PASS,
|
||||
async () => failed,
|
||||
)([job({})]);
|
||||
expect(outcome).toBe(failed);
|
||||
});
|
||||
});
|
||||
@@ -250,7 +250,7 @@ describe("queue registration", () => {
|
||||
|
||||
it("schedules the cron in the schedules table (with retry policy)", () => {
|
||||
expect(workerSrc).toMatch(
|
||||
/\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*insightRetryOptions\s*\]/,
|
||||
/\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*PERIOD_NARRATIVE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -190,9 +190,9 @@ describe("mood-status queue registration (dead-queue guard)", () => {
|
||||
expect(match![1]).toMatch(/\bMOOD_STATUS_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("schedules the 02:30 cron with the insight retry policy", () => {
|
||||
it("schedules the 02:30 cron with the nightly pass options (retries and expiry)", () => {
|
||||
expect(workerSrc).toMatch(
|
||||
/\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*insightRetryOptions\s*\]/,
|
||||
/\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*nightlyInsightPassOptions\s*\]/,
|
||||
);
|
||||
expect(workerSrc).toMatch(/MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/);
|
||||
});
|
||||
|
||||
@@ -811,7 +811,12 @@ export async function enqueueBackupRestoreSweepAtBoot(
|
||||
await boss.send(
|
||||
BACKUP_RESTORE_QUEUE,
|
||||
{ sweep: true } satisfies BackupRestorePayload,
|
||||
{ startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15 },
|
||||
{
|
||||
startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15,
|
||||
// The sweep is short, but it rides a queue whose every send carries
|
||||
// the restore's expiry, so no job on it is cut at the default.
|
||||
expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// The admission check fails an abandoned job the next time the account
|
||||
|
||||
@@ -64,6 +64,14 @@ export const CONTENT_INDEX_BACKFILL_CONCURRENCY = 1;
|
||||
/** Discovery page size (id-only; blobs are loaded one at a time). */
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* The job's expiry: three hours. One job transcribes up to 200 documents with
|
||||
* a provider call each, which outlasted pg-boss's fifteen-minute default and
|
||||
* was retried beside itself. The run stops at three quarters of this
|
||||
* (`jobBudget`) and holds a lock per account (`lockedPass`).
|
||||
*/
|
||||
export const CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS = 3 * 60 * 60;
|
||||
|
||||
/** Max documents one job indexes before yielding (bounds spend + runtime). */
|
||||
const MAX_DOCS_PER_RUN = 200;
|
||||
|
||||
@@ -97,6 +105,11 @@ export interface ContentIndexBackfillSummary {
|
||||
failed: number;
|
||||
reason:
|
||||
"ok" | "no-provider" | "no-consent" | "budget-reached" | "unavailable";
|
||||
/**
|
||||
* The job's time budget ran out before the walk did. The documents not
|
||||
* reached stay un-indexed for the next run.
|
||||
*/
|
||||
stoppedEarly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +171,11 @@ async function retokeniseStaleRows(
|
||||
*/
|
||||
export async function runContentIndexBackfillForUser(
|
||||
userId: string,
|
||||
/**
|
||||
* The job's time budget (`jobBudget`), checked before each document: up to
|
||||
* 200 transcriptions, one provider call each, can outlast the job.
|
||||
*/
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<ContentIndexBackfillSummary> {
|
||||
// Stale-tokeniser rows first: local work that owes nothing to the
|
||||
// provider, consent, or budget gates below, so a user without a usable
|
||||
@@ -222,6 +240,7 @@ export async function runContentIndexBackfillForUser(
|
||||
let failed = stale.failed;
|
||||
let reason: ContentIndexBackfillSummary["reason"] = "ok";
|
||||
let cursor: string | null = null;
|
||||
let stoppedEarly = false;
|
||||
|
||||
outer: for (;;) {
|
||||
if (indexed >= MAX_DOCS_PER_RUN) break;
|
||||
@@ -244,6 +263,10 @@ export async function runContentIndexBackfillForUser(
|
||||
|
||||
for (const { id } of rows) {
|
||||
if (indexed >= MAX_DOCS_PER_RUN) break outer;
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break outer;
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
@@ -325,9 +348,23 @@ export async function runContentIndexBackfillForUser(
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.contentIndex.backfill" },
|
||||
meta: { indexed, retokenised: stale.retokenised, skipped, failed, reason },
|
||||
meta: {
|
||||
indexed,
|
||||
retokenised: stale.retokenised,
|
||||
skipped,
|
||||
failed,
|
||||
reason,
|
||||
stopped_early: stoppedEarly,
|
||||
},
|
||||
});
|
||||
return { indexed, retokenised: stale.retokenised, skipped, failed, reason };
|
||||
return {
|
||||
indexed,
|
||||
retokenised: stale.retokenised,
|
||||
skipped,
|
||||
failed,
|
||||
reason,
|
||||
...(stoppedEarly ? { stoppedEarly } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,6 +381,7 @@ export async function enqueueContentIndexBackfill(
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(CONTENT_INDEX_BACKFILL_QUEUE, payload, {
|
||||
expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS,
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { type Job } from "pg-boss";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
rotateCorpus,
|
||||
@@ -37,27 +38,40 @@ export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1;
|
||||
/** Fixed key so duplicate admin triggers coalesce into one queued run. */
|
||||
export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate";
|
||||
|
||||
/**
|
||||
* The job's expiry: twelve hours. The pass re-encrypts every encrypted column
|
||||
* on the instance and outlasted pg-boss's fifteen-minute default, which
|
||||
* retried it beside itself. The pass stops at three quarters of this
|
||||
* (`jobBudget`) and holds a lock (`lockedPass`); the admin route's send
|
||||
* carries it.
|
||||
*/
|
||||
export const ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS = 12 * 60 * 60;
|
||||
|
||||
export interface EncryptionKeyRotatePayload {
|
||||
/** The admin who triggered the run (for the audit trail). */
|
||||
requestedByUserId?: string;
|
||||
enqueuedAt?: string;
|
||||
}
|
||||
|
||||
export async function runEncryptionKeyRotation(): Promise<{
|
||||
export async function runEncryptionKeyRotation(
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<{
|
||||
activeKeyId: string;
|
||||
totalScanned: number;
|
||||
totalRotated: number;
|
||||
totalErrors: number;
|
||||
totalDropped: number;
|
||||
stoppedEarly: boolean;
|
||||
}> {
|
||||
const prisma = getWorkerPrisma();
|
||||
const out = await rotateCorpus(prisma as unknown as CorpusClient);
|
||||
const out = await rotateCorpus(prisma as unknown as CorpusClient, shouldStop);
|
||||
return {
|
||||
activeKeyId: out.activeKeyId,
|
||||
totalScanned: out.totalScanned,
|
||||
totalRotated: out.totalRotated,
|
||||
totalErrors: out.totalErrors,
|
||||
totalDropped: out.totalDropped,
|
||||
stoppedEarly: out.stoppedEarly,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +81,10 @@ export async function handleEncryptionKeyRotate(
|
||||
return withBackgroundEvent("job.encryption_key_rotate", async (evt) => {
|
||||
const requestedBy = jobs[0]?.data?.requestedByUserId ?? null;
|
||||
try {
|
||||
const result = await runEncryptionKeyRotation();
|
||||
// A whole-corpus pass: it stops between rows once the job's budget is
|
||||
// spent, and says so in the audit row, so the operator runs it again
|
||||
// to finish (rows already rotated are skipped).
|
||||
const result = await runEncryptionKeyRotation(jobBudget(jobs));
|
||||
evt.addMeta("rotate_active_key_id", result.activeKeyId);
|
||||
evt.addMeta("rotate_scanned", result.totalScanned);
|
||||
evt.addMeta("rotate_rotated", result.totalRotated);
|
||||
|
||||
@@ -19,6 +19,15 @@ import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate";
|
||||
|
||||
/**
|
||||
* The job's expiry: three hours. The scheduled pass walks up to 200 accounts
|
||||
* with a comprehensive generation each, which can outlast pg-boss's fifteen
|
||||
* minute default; past it pg-boss retried the job beside the pass still
|
||||
* running. The pass now stops itself at three quarters of this (`jobBudget`)
|
||||
* and holds a lock (`lockedPass`), and every send and the schedule carry it.
|
||||
*/
|
||||
export const INSIGHT_PREGENERATE_EXPIRE_SECONDS = 3 * 60 * 60;
|
||||
|
||||
export interface InsightPregeneratePayload {
|
||||
/** Set on the nightly scheduled tick (informational; the handler keys
|
||||
* its batch behaviour off the ABSENCE of `userId`/`force`). */
|
||||
@@ -73,6 +82,7 @@ export async function enqueueForceWarm(payload: {
|
||||
} satisfies InsightPregeneratePayload,
|
||||
{
|
||||
singletonKey: `force:${payload.userId}`,
|
||||
expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS,
|
||||
// De-dupe within a 6-minute window. v1.16.8 — widened from 120 s:
|
||||
// the client's revalidation poll runs up to ~250 s after a stale
|
||||
// page-open, so a 2-minute singleton let the tail of one poll
|
||||
@@ -148,6 +158,7 @@ export async function enqueuePregenerateFailureRetry(payload: {
|
||||
// warm can never swallow the scheduled retry (and vice versa);
|
||||
// one retry per user per window.
|
||||
singletonKey: `retry:${payload.userId}`,
|
||||
expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS,
|
||||
singletonSeconds: 3600,
|
||||
// A worker crash mid-warm re-runs the job; a warm that completes
|
||||
// with a failed comprehensive does NOT throw, so this cannot loop.
|
||||
|
||||
@@ -277,6 +277,8 @@ export interface PregenerateRunResult {
|
||||
skipped: number;
|
||||
failed: number;
|
||||
budgetBlocked: number;
|
||||
/** The job's time budget ran out before every candidate was reached. */
|
||||
stoppedEarly: boolean;
|
||||
/**
|
||||
* Count of per-metric assessment caches written warm across the whole
|
||||
* run. The comprehensive generator evicts the seven `*-status`
|
||||
@@ -575,6 +577,12 @@ export async function runInsightPregenerate(
|
||||
userId: string;
|
||||
locale: SupportedLocale;
|
||||
}) => Promise<void>;
|
||||
/**
|
||||
* The job's time budget (`jobBudget`). Checked between candidates: a pass
|
||||
* of 200 provider calls can outlast its job, and the candidates it does
|
||||
* not reach are the stalest again tomorrow, so they lead the next pass.
|
||||
*/
|
||||
shouldStop?: () => boolean;
|
||||
} = {},
|
||||
): Promise<PregenerateRunResult> {
|
||||
const now = options.now ?? new Date();
|
||||
@@ -606,6 +614,7 @@ export async function runInsightPregenerate(
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
budgetBlocked: 0,
|
||||
stoppedEarly: false,
|
||||
assessmentsWarmed: 0,
|
||||
metricAssessmentsWarmed: 0,
|
||||
};
|
||||
@@ -619,6 +628,10 @@ export async function runInsightPregenerate(
|
||||
result.total = candidates.length;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (options.shouldStop?.()) {
|
||||
result.stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
// v1.18.11 P3 — one feature-cache scope per candidate, so the
|
||||
// comprehensive briefing's bounded feature read is computed once and any
|
||||
// sibling consumer in this candidate's warm reuses it. Scoped per user so
|
||||
@@ -819,6 +832,7 @@ export async function runInsightPregenerate(
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
budget_blocked: result.budgetBlocked,
|
||||
stopped_early: result.stoppedEarly,
|
||||
assessments_warmed: result.assessmentsWarmed,
|
||||
metric_assessments_warmed: result.metricAssessmentsWarmed,
|
||||
},
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* started beside it in the same worker, so the lane's "one at a time" did not
|
||||
* hold for the one case it exists for, an import too long for its expiry.
|
||||
*
|
||||
* So each run holds a session-level advisory lock keyed on the job's identity
|
||||
* (kind, provider where there is one, account) for as long as it runs. A
|
||||
* So each run holds a session-level advisory lock (`withJobLock`) keyed on the
|
||||
* job's identity (kind, provider where there is one, account) for as long as
|
||||
* it runs. A
|
||||
* delivery that finds the lock taken does no work: the run that holds it is
|
||||
* still importing, and it either stamps its completion marker or leaves the
|
||||
* account for the next boot's discovery. The lock lives on a connection of its
|
||||
@@ -24,47 +25,26 @@
|
||||
* One extra connection per running import; the lane admits one import per
|
||||
* process at a time, so that is one connection.
|
||||
*/
|
||||
import { Client } from "pg";
|
||||
import { withJobLock, type GuardedRun } from "./job-lock";
|
||||
|
||||
/** The lock key for one admission job's identity. */
|
||||
function integrationBackfillLockKey(identity: string): string {
|
||||
return `integration-backfill:${identity}`;
|
||||
}
|
||||
|
||||
/** What a guarded run came to. */
|
||||
type GuardedRun<T> = { ran: true; result: T } | { ran: false };
|
||||
|
||||
/**
|
||||
* Run `run` while holding the import lock for `identity`. Resolves
|
||||
* `{ ran: false }` at once, without calling `run`, when another run holds it.
|
||||
* Rejects exactly as `run` does otherwise.
|
||||
*/
|
||||
export async function withIntegrationBackfillLock<T>(
|
||||
export function withIntegrationBackfillLock<T>(
|
||||
identity: string,
|
||||
run: () => Promise<T>,
|
||||
connectionString: string | undefined = process.env.DATABASE_URL,
|
||||
): Promise<GuardedRun<T>> {
|
||||
const client = new Client({ connectionString, keepAlive: true });
|
||||
// An idle client whose server goes away emits `error`; unhandled, that
|
||||
// takes the whole worker down. The run itself notices the database is
|
||||
// gone through its own queries.
|
||||
client.on("error", () => {});
|
||||
await client.connect();
|
||||
try {
|
||||
const key = integrationBackfillLockKey(identity);
|
||||
const { rows } = await client.query<{ held: boolean }>(
|
||||
"SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS held",
|
||||
[key],
|
||||
);
|
||||
if (!rows[0]?.held) return { ran: false };
|
||||
try {
|
||||
return { ran: true, result: await run() };
|
||||
} finally {
|
||||
await client
|
||||
.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key])
|
||||
.catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
return withJobLock(
|
||||
integrationBackfillLockKey(identity),
|
||||
run,
|
||||
connectionString,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* A session-level advisory lock held for the length of one run, across
|
||||
* processes.
|
||||
*
|
||||
* pg-boss holds a job's "one at a time" only while the job is inside its
|
||||
* expiry. Once the expiry passes it fails the job, fires its abort signal and
|
||||
* retries it, and it does not stop the handler: measured against pg-boss 12.34
|
||||
* (`tests/integration/integration-backfill-lane-expiry.test.ts`), the retry of
|
||||
* the same job started beside the run that was still going. No queue policy
|
||||
* helps, because the policies constrain job rows and the expired row is no
|
||||
* longer active. Only something the running handler holds can, so each long
|
||||
* run holds an advisory lock keyed on its identity, and a delivery that finds
|
||||
* the lock taken does no work.
|
||||
*
|
||||
* The lock lives on a connection of its own, outside the Prisma pool, because
|
||||
* a session lock has to stay on one connection for the whole run and a pooled
|
||||
* one is handed back between statements. Ending the connection releases the
|
||||
* lock, and so does the process dying, so a crash never leaves a key locked.
|
||||
* One extra connection per running locked job.
|
||||
*/
|
||||
import { Client } from "pg";
|
||||
|
||||
/** What a guarded run came to. */
|
||||
export type GuardedRun<T> = { ran: true; result: T } | { ran: false };
|
||||
|
||||
/**
|
||||
* Run `run` while holding the advisory lock for `key`. Resolves
|
||||
* `{ ran: false }` at once, without calling `run`, when another run holds it.
|
||||
* Rejects exactly as `run` does otherwise.
|
||||
*/
|
||||
export async function withJobLock<T>(
|
||||
key: string,
|
||||
run: () => Promise<T>,
|
||||
connectionString: string | undefined = process.env.DATABASE_URL,
|
||||
): Promise<GuardedRun<T>> {
|
||||
const client = new Client({ connectionString, keepAlive: true });
|
||||
// An idle client whose server goes away emits `error`; unhandled, that
|
||||
// takes the whole worker down. The run itself notices the database is
|
||||
// gone through its own queries.
|
||||
client.on("error", () => {});
|
||||
await client.connect();
|
||||
try {
|
||||
const { rows } = await client.query<{ held: boolean }>(
|
||||
"SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS held",
|
||||
[key],
|
||||
);
|
||||
if (!rows[0]?.held) return { ran: false };
|
||||
try {
|
||||
return { ran: true, result: await run() };
|
||||
} finally {
|
||||
await client
|
||||
.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key])
|
||||
.catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The binding for a queue whose job can outlast pg-boss's default expiry.
|
||||
*
|
||||
* Three things together make a long job safe, and `queue-runtime.ts` names
|
||||
* every queue that needs them:
|
||||
*
|
||||
* 1. An explicit `expireInSeconds` on every send and schedule, so pg-boss
|
||||
* does not declare the job dead at fifteen minutes while it is working.
|
||||
* 2. A time budget (`jobBudget` / `jobDeadline` in `job-budget.ts`) the
|
||||
* handler checks between units of work, so the pass stops on its own
|
||||
* before that expiry and the next run picks up what is left.
|
||||
* 3. A lock the running handler holds (`withJobLock`), so the retry pg-boss
|
||||
* starts once the expiry has passed does nothing beside a run that is
|
||||
* still going. Queue policies cannot do this: they constrain job rows,
|
||||
* and an expired job's row is no longer active.
|
||||
*
|
||||
* `lockedPass` is the third. It wraps a handler so each job runs under the
|
||||
* lock `<queue>:<identity>`, where the identity is what two runs must not
|
||||
* share: the account for a per-user job, a constant for a pass over everyone.
|
||||
* A delivery that finds the lock held reports `already_running` and succeeds,
|
||||
* because the run that holds it is doing the work.
|
||||
*/
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
import { withJobLock } from "@/lib/jobs/job-lock";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** The identity of a pass that walks every account: one run at a time. */
|
||||
export const WHOLE_PASS = "pass";
|
||||
|
||||
export function lockedPass<J extends Job<unknown>>(
|
||||
queue: string,
|
||||
identityOf: (job: J) => string,
|
||||
handler: (jobs: J[]) => Promise<JobOutcome>,
|
||||
): (jobs: J[]) => Promise<JobOutcome> {
|
||||
return async (jobs) => {
|
||||
let outcome: JobOutcome = jobDone({ jobs: 0 });
|
||||
for (const job of jobs) {
|
||||
const run = await withJobLock(`job:${queue}:${identityOf(job)}`, () =>
|
||||
handler([job]),
|
||||
);
|
||||
if (!run.ran) {
|
||||
annotate({
|
||||
action: { name: "job.long_pass.already_running" },
|
||||
meta: { queue },
|
||||
});
|
||||
outcome = jobDone({ already_running: 1 });
|
||||
continue;
|
||||
}
|
||||
// The first failed job fails the batch; pg-boss hands one job per
|
||||
// batch here, so this is that job's own outcome.
|
||||
if (!run.result.ok) return run.result;
|
||||
outcome = run.result;
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import {
|
||||
@@ -825,13 +826,25 @@ async function processNextChunk(
|
||||
);
|
||||
}
|
||||
|
||||
export async function processMedicationIntakeImportJob(
|
||||
/**
|
||||
* Work the import chunk by chunk until it is finished. `shouldStop` is the
|
||||
* job's time budget: between chunks, once it is spent, the run returns
|
||||
* `"stopped"` and leaves the job row as it is. Every chunk commits its own
|
||||
* progress under the row lock, so the retry that follows picks up at the next
|
||||
* chunk, and a second delivery running beside this one waits on that lock and
|
||||
* continues from the progress this one wrote.
|
||||
*/
|
||||
async function workMedicationIntakeImport(
|
||||
jobId: string,
|
||||
): Promise<MedicationImportResult | null> {
|
||||
shouldStop: () => boolean,
|
||||
): Promise<MedicationImportResult | null | "stopped"> {
|
||||
const client = getWorkerPrisma();
|
||||
for (;;) {
|
||||
const outcome = await processNextChunk(client, jobId);
|
||||
if (!outcome.terminal) continue;
|
||||
if (!outcome.terminal) {
|
||||
if (shouldStop()) return "stopped";
|
||||
continue;
|
||||
}
|
||||
if (outcome.finalized && outcome.userId && outcome.result) {
|
||||
if (outcome.result.imported > 0) {
|
||||
invalidateUserMedications(outcome.userId, { evict: true });
|
||||
@@ -843,6 +856,14 @@ export async function processMedicationIntakeImportJob(
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole import in one go, with no time budget. */
|
||||
export async function processMedicationIntakeImportJob(
|
||||
jobId: string,
|
||||
): Promise<MedicationImportResult | null> {
|
||||
const result = await workMedicationIntakeImport(jobId, () => false);
|
||||
return result === "stopped" ? null : result;
|
||||
}
|
||||
|
||||
async function recordWorkerFailure(
|
||||
job: Job<MedicationIntakeImportQueuePayload>,
|
||||
error: unknown,
|
||||
@@ -879,7 +900,19 @@ export async function handleMedicationIntakeImport(
|
||||
job: Job<MedicationIntakeImportQueuePayload>,
|
||||
): Promise<JobOutcome> {
|
||||
try {
|
||||
const result = await processMedicationIntakeImportJob(job.data.jobId);
|
||||
const result = await workMedicationIntakeImport(
|
||||
job.data.jobId,
|
||||
jobBudget([job]),
|
||||
);
|
||||
// Out of time before the last chunk: fail the delivery so pg-boss
|
||||
// retries it, and the retry resumes from the committed progress.
|
||||
if (result === "stopped") {
|
||||
return jobFailed(
|
||||
"stopped before the last chunk; the retry resumes",
|
||||
undefined,
|
||||
{ stopped_early: true },
|
||||
);
|
||||
}
|
||||
// A null result is a delivery that found the row already terminal or gone.
|
||||
// There was nothing left to import, which is an absence of work rather
|
||||
// than a failure to do it.
|
||||
|
||||
@@ -42,6 +42,15 @@ const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* The off-host pass's expiry: four hours. It uploads every account and
|
||||
* already stopped between accounts at three quarters of the default fifteen
|
||||
* minutes, which on an instance of any size meant a retry per night; one
|
||||
* large account past the default was retried beside itself. Four hours with
|
||||
* the same budget and a lock (`lockedPass`) leaves room for the night's pass.
|
||||
*/
|
||||
export const OFFHOST_BACKUP_EXPIRE_SECONDS = 4 * 60 * 60;
|
||||
|
||||
export interface OffhostBackupConfig {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
|
||||
@@ -23,6 +23,15 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm";
|
||||
|
||||
/**
|
||||
* The job's expiry: three hours. On a boundary night the scheduled pass
|
||||
* writes up to two narratives for each of 200 accounts, which can outlast
|
||||
* pg-boss's fifteen-minute default. The pass stops itself at three quarters
|
||||
* of this (`jobBudget`) and holds a lock (`lockedPass`); every send and the
|
||||
* schedule carry it.
|
||||
*/
|
||||
export const PERIOD_NARRATIVE_EXPIRE_SECONDS = 3 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Nightly at 05:05 Europe/Berlin — inside the existing maintenance window,
|
||||
* after the comprehensive insight pre-generation (04:30) and the computed
|
||||
@@ -79,6 +88,7 @@ export async function enqueueNarrativeWarm(payload: {
|
||||
{
|
||||
singletonKey: `warm:${payload.userId}:${payload.period}:${payload.locale}`,
|
||||
singletonSeconds: 120,
|
||||
expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS,
|
||||
// Transient provider / pool failures retry with backoff instead
|
||||
// of leaving the narrative cold until the next boundary night.
|
||||
retryLimit: 3,
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface NarrativeWarmRunResult {
|
||||
insufficient: number;
|
||||
failed: number;
|
||||
budgetBlocked: number;
|
||||
/** The job's time budget ran out before every candidate was reached. */
|
||||
stoppedEarly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +154,12 @@ export async function runPeriodNarrativeWarm(
|
||||
cap?: number;
|
||||
/** Injected for the test — defaults to the real generator. */
|
||||
generate?: typeof generatePeriodNarrative;
|
||||
/**
|
||||
* The job's time budget (`jobBudget`), checked between candidates. The
|
||||
* candidates are ordered least recently narrated first, so the ones a
|
||||
* stopped pass did not reach lead the next one.
|
||||
*/
|
||||
shouldStop?: () => boolean;
|
||||
} = {},
|
||||
): Promise<NarrativeWarmRunResult> {
|
||||
const now = options.now ?? new Date();
|
||||
@@ -167,6 +175,7 @@ export async function runPeriodNarrativeWarm(
|
||||
insufficient: 0,
|
||||
failed: 0,
|
||||
budgetBlocked: 0,
|
||||
stoppedEarly: false,
|
||||
};
|
||||
|
||||
const flags = await getAssistantFlags();
|
||||
@@ -180,6 +189,10 @@ export async function runPeriodNarrativeWarm(
|
||||
result.total = candidates.length;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (options.shouldStop?.()) {
|
||||
result.stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
// The nightly pass exists for the model-written narrative. Unavailable,
|
||||
// it skips the user before the context build and the budget write; the
|
||||
// deterministic narrative is written on the next read instead (the
|
||||
@@ -233,6 +246,7 @@ export async function runPeriodNarrativeWarm(
|
||||
insufficient: result.insufficient,
|
||||
failed: result.failed,
|
||||
budget_blocked: result.budgetBlocked,
|
||||
stopped_early: result.stoppedEarly,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
|
||||
export const PR_DETECTION_QUEUE = "pr-detection";
|
||||
|
||||
/**
|
||||
* The job's expiry: twenty-five minutes. The fallback cron walks every
|
||||
* account's whole history every thirty minutes, which on a large instance
|
||||
* outlasts pg-boss's fifteen-minute default; past it pg-boss retried the pass
|
||||
* beside itself. The pass stops at three quarters of this (`jobBudget`) and
|
||||
* holds a lock (`lockedPass`), so it ends before the next tick and a retry or
|
||||
* a late tick never runs beside it. The ingest sends carry the same expiry.
|
||||
*/
|
||||
export const PR_DETECTION_EXPIRE_SECONDS = 25 * 60;
|
||||
|
||||
/** Concurrency budget for the worker process — five jobs in flight
|
||||
* is enough to drain a multi-user backfill spike without crowding
|
||||
* the reminder check or the daily insights workload that runs on the
|
||||
@@ -62,6 +72,7 @@ export async function enqueuePrDetection(
|
||||
triggeredAt: new Date().toISOString(),
|
||||
};
|
||||
await boss.send(PR_DETECTION_QUEUE, payload, {
|
||||
expireInSeconds: PR_DETECTION_EXPIRE_SECONDS,
|
||||
// Stagger retries gently — the detector is purely read-heavy on
|
||||
// the user's own data, but a stampede of failed jobs against a
|
||||
// briefly-flaky DB would compound the problem.
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* How long one job of each queue can run, and what keeps a long one safe.
|
||||
*
|
||||
* pg-boss gives every job an expiry, fifteen minutes unless the send says
|
||||
* otherwise. Past it pg-boss fails the job, fires its abort signal and retries
|
||||
* it, and it does not stop the handler, so a job that legitimately needs longer
|
||||
* ends up running twice beside itself. No queue policy prevents that: the
|
||||
* policies constrain job rows, and an expired row is no longer active. Every
|
||||
* queue bound with `createAndWork` is therefore declared here as one of:
|
||||
*
|
||||
* - `short`: a job cannot come near fifteen minutes (one account's bounded
|
||||
* window, one document, a forwarder, a bounded delete). It relies on the
|
||||
* default expiry, and the reason says why that is enough.
|
||||
* - `budgeted`: a pass over a long history that stops itself at three
|
||||
* quarters of the default expiry (`jobBudget`) and hands the rest to its
|
||||
* next run, so it ends before fifteen minutes by construction.
|
||||
* - `long`: a job that may run for hours. It carries an explicit
|
||||
* `expireInSeconds` on every send and schedule, stops itself before that
|
||||
* expiry (`jobBudget` / `jobDeadline`), and holds something per identity
|
||||
* that keeps a second run from working beside it: the lock `lockedPass`
|
||||
* takes, a lock of its own, or a row it claims. The one exception is a
|
||||
* one-shot job with `retryLimit: 0`, which pg-boss never retries and which
|
||||
* cannot stop half-way without losing its input.
|
||||
*
|
||||
* `src/__tests__/job-queue-runtime-guard.test.ts` holds every binding, send
|
||||
* and schedule in the tree to this table.
|
||||
*/
|
||||
import { INTEGRATION_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared";
|
||||
import { PR_DETECTION_EXPIRE_SECONDS } from "@/lib/jobs/pr-detection";
|
||||
import { ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS } from "@/lib/jobs/encryption-key-rotate";
|
||||
import { CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/document-content-index-backfill";
|
||||
import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy";
|
||||
import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup";
|
||||
import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore";
|
||||
import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker";
|
||||
import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import";
|
||||
|
||||
/** pg-boss's default job expiry, in seconds. */
|
||||
export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60;
|
||||
|
||||
/** Where a check lives: `binding` is the `createAndWork` call itself. */
|
||||
export type SourceSite = "binding" | { file: string; fn: string };
|
||||
|
||||
export type QueueRuntime =
|
||||
| { runtime: "short"; why: string }
|
||||
| { runtime: "budgeted"; budget: SourceSite; why: string }
|
||||
| {
|
||||
runtime: "long";
|
||||
expireInSeconds: number;
|
||||
/**
|
||||
* Identifiers that carry the expiry into a send or a schedule. A site
|
||||
* that names none of them must set `expireInSeconds` itself.
|
||||
*/
|
||||
expiryVia: readonly string[];
|
||||
/** Where the handler stops on the job's budget, or `oneShot`. */
|
||||
stop: SourceSite | { oneShot: string };
|
||||
/**
|
||||
* What keeps a second run from working beside this one: `lockedPass`
|
||||
* at the binding, or a marker in the named function.
|
||||
*/
|
||||
exclusive: "lockedPass" | { marker: string; at: SourceSite };
|
||||
why: string;
|
||||
};
|
||||
|
||||
const short = (why: string): QueueRuntime => ({ runtime: "short", why });
|
||||
|
||||
const nightlyStatusPass = (fn: string): QueueRuntime => ({
|
||||
runtime: "long",
|
||||
expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS,
|
||||
expiryVia: ["nightlyInsightPassOptions"],
|
||||
stop: { file: "lib/jobs/reminder/insights-handlers.ts", fn },
|
||||
exclusive: "lockedPass",
|
||||
why: "Nightly status pass: one provider call per candidate account, serially, over the whole instance.",
|
||||
});
|
||||
|
||||
const cohortSync = (provider: string): QueueRuntime =>
|
||||
short(
|
||||
`${provider} sync: a recent window per connection (incremental since the last sync); a webhook job is one connection.`,
|
||||
);
|
||||
|
||||
const oauthStateCleanup = short("One DELETE of expired OAuth state rows.");
|
||||
|
||||
const forwarder = short(
|
||||
"Forwards each job to integration-backfill-admission; the import itself runs there.",
|
||||
);
|
||||
|
||||
export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
// ── Integration sync ───────────────────────────────────────────────
|
||||
"integration-backfill-admission": {
|
||||
runtime: "long",
|
||||
expireInSeconds: INTEGRATION_BACKFILL_EXPIRE_SECONDS,
|
||||
expiryVia: ["integrationBackfillAdmissionSendOptions"],
|
||||
stop: {
|
||||
file: "lib/jobs/integration-backfill-drain.ts",
|
||||
fn: "drainIntegrationBackfillAdmission",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "withIntegrationBackfillLock",
|
||||
at: {
|
||||
file: "lib/jobs/integration-backfill-drain.ts",
|
||||
fn: "drainIntegrationBackfillAdmission",
|
||||
},
|
||||
},
|
||||
why: "Full-history provider imports: thousands of pages on a dense account.",
|
||||
},
|
||||
"withings-fallback-sync": cohortSync("Withings"),
|
||||
"withings-activity-sync": cohortSync("Withings activity"),
|
||||
"withings-sleep-sync": cohortSync("Withings sleep"),
|
||||
"withings-ecg-sync": cohortSync("Withings ECG"),
|
||||
"withings-oauth-state-cleanup": oauthStateCleanup,
|
||||
"whoop-recovery-sync": cohortSync("WHOOP recovery"),
|
||||
"whoop-sleep-sync": cohortSync("WHOOP sleep"),
|
||||
"whoop-workout-sync": cohortSync("WHOOP workout"),
|
||||
"whoop-cycle-sync": cohortSync("WHOOP cycle"),
|
||||
"whoop-backfill": forwarder,
|
||||
"whoop-oauth-state-cleanup": oauthStateCleanup,
|
||||
"oidc-native-handoff-cleanup": short("One DELETE of expired handoff rows."),
|
||||
"fitbit-sync": cohortSync("Fitbit"),
|
||||
"fitbit-backfill": forwarder,
|
||||
"fitbit-sleep-repair": forwarder,
|
||||
"fitbit-oauth-state-cleanup": oauthStateCleanup,
|
||||
"google-health-sync": cohortSync("Google Health"),
|
||||
"google-health-backfill": forwarder,
|
||||
"google-health-sleep-repair": forwarder,
|
||||
"google-health-oauth-state-cleanup": oauthStateCleanup,
|
||||
"sleep-timeline-backfill": forwarder,
|
||||
"lab-biomarker-backfill": forwarder,
|
||||
"nightscout-sync": cohortSync("Nightscout"),
|
||||
"polar-sync": cohortSync("Polar"),
|
||||
"oura-sync": cohortSync("Oura"),
|
||||
"strava-sync": cohortSync("Strava"),
|
||||
"strava-backfill": forwarder,
|
||||
|
||||
// ── Status and insights ────────────────────────────────────────────
|
||||
"insights-general-status": nightlyStatusPass("handleGeneralStatusGenerate"),
|
||||
"insights-blood-pressure-status": nightlyStatusPass(
|
||||
"handleBloodPressureStatusGenerate",
|
||||
),
|
||||
"insights-weight-status": nightlyStatusPass("handleWeightStatusGenerate"),
|
||||
"insights-pulse-status": nightlyStatusPass("handlePulseStatusGenerate"),
|
||||
"insights-bmi-status": nightlyStatusPass("handleBmiStatusGenerate"),
|
||||
"insights-mood-status": nightlyStatusPass("handleMoodStatusGenerate"),
|
||||
"insights-medication-compliance-status": nightlyStatusPass(
|
||||
"handleMedicationComplianceStatusGenerate",
|
||||
),
|
||||
"insight-pregenerate": {
|
||||
runtime: "long",
|
||||
expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS,
|
||||
expiryVia: ["INSIGHT_PREGENERATE_EXPIRE_SECONDS"],
|
||||
stop: {
|
||||
file: "lib/jobs/reminder/insights-handlers.ts",
|
||||
fn: "handleInsightPregenerateJob",
|
||||
},
|
||||
exclusive: "lockedPass",
|
||||
why: "Up to 200 accounts, a comprehensive generation each (a provider call bounded by the account's timeout, up to ten minutes) plus the status warm.",
|
||||
},
|
||||
"morning-digest-refresh": short(
|
||||
"One account, one comprehensive generation, bounded by the provider timeout.",
|
||||
),
|
||||
"insight-status-generate": short(
|
||||
"One account, one metric, one provider call.",
|
||||
),
|
||||
"data-arrival": short("One arrival's reactions."),
|
||||
"reaction-line-generate": short("One provider call for one reaction line."),
|
||||
"workout-insight-generate": short("One workout's insight."),
|
||||
"recovery-score-compute": short(
|
||||
"Arithmetic over a recent window, capped at 500 accounts; no provider call.",
|
||||
),
|
||||
"stress-score-compute": short(
|
||||
"Arithmetic over a recent window, capped at 500 accounts; no provider call.",
|
||||
),
|
||||
"strain-score-compute": short(
|
||||
"Arithmetic over a recent window, capped at 500 accounts; no provider call.",
|
||||
),
|
||||
"period-narrative-warm": {
|
||||
runtime: "long",
|
||||
expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS,
|
||||
expiryVia: ["PERIOD_NARRATIVE_EXPIRE_SECONDS"],
|
||||
stop: "binding",
|
||||
exclusive: "lockedPass",
|
||||
why: "On a boundary night up to 200 accounts with a provider call per period each; a single-account warm is short.",
|
||||
},
|
||||
"coach-memory-refresh": short("One conversation's summary and facts."),
|
||||
"coach-nudge": short(
|
||||
"Light deterministic checks per account; the provider part is capped at 25 calls and 90 seconds a tick.",
|
||||
),
|
||||
"coach-reminder-sweep": short("Batches of 200 to 500 rows, no provider."),
|
||||
"coach-plan-review": short("A batch of 100 plans, folded in SQL."),
|
||||
"medication-low-stock": short("A light inventory check per account."),
|
||||
"daily-briefing": short("A light cohort scan every fifteen minutes."),
|
||||
|
||||
// ── Rollups ────────────────────────────────────────────────────────
|
||||
"rollup-recompute": short("One bucket."),
|
||||
"rollup-full-backfill": short(
|
||||
"One account's rollup tiers: four aggregate statements, each held to the 60-second statement timeout.",
|
||||
),
|
||||
"mood-rollup-recompute": short("One bucket."),
|
||||
"mood-rollup-full-backfill": short(
|
||||
"One account's mood entries, a few a day.",
|
||||
),
|
||||
"medication-compliance-full-backfill": short(
|
||||
"One account's 90-day compliance window.",
|
||||
),
|
||||
"step-consolidation": short(
|
||||
"One account's legacy step rows, one transaction per day.",
|
||||
),
|
||||
"step-consolidation-repair": short(
|
||||
"One account's tombstoned step rows inside the retention window.",
|
||||
),
|
||||
"cumulative-pr-rederive": short(
|
||||
"One account's record detection; measured at 20 seconds for 1.25 million rows.",
|
||||
),
|
||||
"mean-consolidation": {
|
||||
runtime: "budgeted",
|
||||
budget: "binding",
|
||||
why: "One account's whole history, a day at a time; stops at three quarters of the default expiry and resumes.",
|
||||
},
|
||||
"dense-intraday-retention": {
|
||||
runtime: "budgeted",
|
||||
budget: "binding",
|
||||
why: "A dense account's history, a day at a time; stops at three quarters of the default expiry and resumes.",
|
||||
},
|
||||
"dense-intraday-hourly-rebuild": {
|
||||
runtime: "budgeted",
|
||||
budget: "binding",
|
||||
why: "A dense account's hourly rows, a day at a time; stops at three quarters of the default expiry and resumes.",
|
||||
},
|
||||
"drain-per-sample-cumulative": {
|
||||
runtime: "budgeted",
|
||||
budget: "binding",
|
||||
why: "Every account's per-sample drains; stops at three quarters of the default expiry and hands the rest to a continuation job.",
|
||||
},
|
||||
|
||||
// ── Reminders ──────────────────────────────────────────────────────
|
||||
"medication-reminder-check": short("The due slots of the current minute."),
|
||||
"mood-reminder-check": short("Accounts whose local reminder hour is now."),
|
||||
"cycle-reminder-check": short("Accounts whose local reminder hour is now."),
|
||||
"measurement-reminder-check": short(
|
||||
"Accounts whose local reminder hour is now.",
|
||||
),
|
||||
"reminder-satisfy": short("One account's open reminders."),
|
||||
|
||||
// ── Maintenance ────────────────────────────────────────────────────
|
||||
"data-backup": {
|
||||
runtime: "long",
|
||||
expireInSeconds: DATA_BACKUP_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["DATA_BACKUP_SEND_OPTIONS"],
|
||||
stop: {
|
||||
file: "lib/jobs/reminder/backup-handlers.ts",
|
||||
fn: "handleDataBackup",
|
||||
},
|
||||
exclusive: "lockedPass",
|
||||
why: "Serialises every account's whole record.",
|
||||
},
|
||||
"data-backup-offhost": {
|
||||
runtime: "long",
|
||||
expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS,
|
||||
expiryVia: ["OFFHOST_BACKUP_EXPIRE_SECONDS"],
|
||||
stop: {
|
||||
file: "lib/jobs/reminder/backup-handlers.ts",
|
||||
fn: "handleOffhostBackup",
|
||||
},
|
||||
exclusive: "lockedPass",
|
||||
why: "Uploads every account's record off-host.",
|
||||
},
|
||||
"data-restore-drill": short("Fetches and stream-parses one off-host object."),
|
||||
"offhost-backup-purge": short("At most 200 delete requests per run."),
|
||||
"backup-restore": {
|
||||
runtime: "long",
|
||||
expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS,
|
||||
expiryVia: ["BACKUP_RESTORE_SEND_OPTIONS", "BACKUP_RESTORE_EXPIRE_SECONDS"],
|
||||
stop: { file: "lib/jobs/backup-restore.ts", fn: "handleBackupRestore" },
|
||||
exclusive: {
|
||||
marker: "restore_claimed",
|
||||
at: { file: "lib/jobs/backup-restore.ts", fn: "runBackupRestoreJob" },
|
||||
},
|
||||
why: "Restores a whole account in one transaction.",
|
||||
},
|
||||
"host-metric-sample": short("One host sample."),
|
||||
"feedback-aggregator": short("One aggregate over the feedback table."),
|
||||
"geo-backfill": short("At most 500 audit rows per pass."),
|
||||
"geolite2-fetch": short("One database download."),
|
||||
"tls-pin-monitor": short("One TLS handshake."),
|
||||
"mood-reminder-cleanup": short("One retention DELETE."),
|
||||
"push-attempt-cleanup": short("One retention DELETE."),
|
||||
"arrival-reaction-cleanup": short("One retention DELETE."),
|
||||
"rate-limit-cleanup": short("One retention DELETE."),
|
||||
"idempotency-cleanup": short("One retention DELETE."),
|
||||
"step-up-elevation-cleanup": short("One retention DELETE."),
|
||||
"coach-message-cleanup": short("One retention DELETE."),
|
||||
"mcp-token-cleanup": short("One retention DELETE."),
|
||||
"audit-log-cleanup": short("At most 40 batches of 5 000 rows per leg."),
|
||||
"measurement-tombstone-cleanup": short(
|
||||
"At most 40 batches of 5 000 rows per leg.",
|
||||
),
|
||||
"document-tombstone-purge": short("One deleteMany."),
|
||||
"document-summary-reaper": short("One updateMany."),
|
||||
"cycle-prediction-refresh": short(
|
||||
"Arithmetic per eligible account, no provider call.",
|
||||
),
|
||||
"mood-prognosis-refresh": short(
|
||||
"Arithmetic per eligible account, no provider call.",
|
||||
),
|
||||
"achievement-unlock-sweep": short(
|
||||
"A bounded achievement read per account, no provider call.",
|
||||
),
|
||||
"pr-detection": {
|
||||
runtime: "long",
|
||||
expireInSeconds: PR_DETECTION_EXPIRE_SECONDS,
|
||||
expiryVia: ["PR_DETECTION_EXPIRE_SECONDS"],
|
||||
stop: {
|
||||
file: "lib/jobs/reminder/ops-handlers.ts",
|
||||
fn: "handlePrDetection",
|
||||
},
|
||||
exclusive: "lockedPass",
|
||||
why: "The fallback cron scans every account's all-time history every thirty minutes; an ingest job is one account.",
|
||||
},
|
||||
"medication-inventory-expire": short("One updateMany."),
|
||||
"intake-auto-skip": short("One updateMany."),
|
||||
"apple-health-import-v2": {
|
||||
runtime: "long",
|
||||
expireInSeconds: APPLE_HEALTH_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["APPLE_HEALTH_IMPORT_SEND_OPTIONS"],
|
||||
stop: {
|
||||
oneShot:
|
||||
"The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "retryLimit: 0",
|
||||
at: {
|
||||
file: "lib/jobs/apple-health-import-worker.ts",
|
||||
fn: "APPLE_HEALTH_IMPORT_SEND_OPTIONS",
|
||||
},
|
||||
},
|
||||
why: "Parses an export that can be several gigabytes.",
|
||||
},
|
||||
"apple-health-import": short(
|
||||
"The legacy queue: moves each job onto apple-health-import-v2.",
|
||||
),
|
||||
"apple-health-import-reconcile": short("One updateMany."),
|
||||
"medication-intake-import": {
|
||||
runtime: "long",
|
||||
expireInSeconds: MEDICATION_INTAKE_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["MEDICATION_INTAKE_IMPORT_SEND_OPTIONS"],
|
||||
stop: {
|
||||
file: "lib/jobs/medication-intake-import.ts",
|
||||
fn: "handleMedicationIntakeImport",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "FOR UPDATE",
|
||||
at: {
|
||||
file: "lib/jobs/medication-intake-import.ts",
|
||||
fn: "processNextChunk",
|
||||
},
|
||||
},
|
||||
why: "Imports a dose-history file chunk by chunk; its length follows the file.",
|
||||
},
|
||||
"intake-slot-dedup": short("One account's duplicate dose slots."),
|
||||
"note-encryption-backfill": short(
|
||||
"Pages of 200 rows of one account's plaintext notes, light per row.",
|
||||
),
|
||||
"med-notes-encryption-backfill": short(
|
||||
"Pages of 200 rows of one account's plaintext notes, light per row.",
|
||||
),
|
||||
"free-text-encryption-backfill": short(
|
||||
"Pages of 200 rows of one account's plaintext fields, light per row.",
|
||||
),
|
||||
"encryption-key-rotate": {
|
||||
runtime: "long",
|
||||
expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
|
||||
expiryVia: ["ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS"],
|
||||
stop: {
|
||||
file: "lib/jobs/encryption-key-rotate.ts",
|
||||
fn: "handleEncryptionKeyRotate",
|
||||
},
|
||||
exclusive: "lockedPass",
|
||||
why: "Re-encrypts every encrypted column on the instance.",
|
||||
},
|
||||
"document-content-index-backfill": {
|
||||
runtime: "long",
|
||||
expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS,
|
||||
expiryVia: ["CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS"],
|
||||
stop: "binding",
|
||||
exclusive: "lockedPass",
|
||||
why: "Up to 200 documents of one account, a provider transcription each.",
|
||||
},
|
||||
"document-index": short("One document."),
|
||||
"document-thumbnail": short("One document."),
|
||||
"document-summary": short("One document."),
|
||||
"document-summary-catchup": short("Enqueues at most 200 documents."),
|
||||
"document-thumbnail-backfill": short("Enqueues at most 1 000 documents."),
|
||||
"environment-fetch": short(
|
||||
"A fan-out of sends, or one account's bounded archive range.",
|
||||
),
|
||||
};
|
||||
@@ -70,6 +70,7 @@ function buildPrismaMock(
|
||||
) {
|
||||
return {
|
||||
user: { findMany: vi.fn().mockResolvedValue(users) },
|
||||
dataBackup: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,3 +237,44 @@ describe("handleDataBackup outcome", () => {
|
||||
expect(outcome).toMatchObject({ ok: true, did: { backed: 0, total: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDataBackup on a large instance", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.store.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("backs up the accounts with the oldest copies first and stops on the budget", async () => {
|
||||
const prisma = buildPrismaMock([
|
||||
{ id: "fresh", username: "fresh" },
|
||||
{ id: "never", username: "never" },
|
||||
{ id: "stale", username: "stale" },
|
||||
]);
|
||||
prisma.dataBackup.findMany.mockResolvedValue([
|
||||
{ userId: "fresh", createdAt: new Date("2026-09-30T00:00:00Z") },
|
||||
{ userId: "stale", createdAt: new Date("2026-09-01T00:00:00Z") },
|
||||
]);
|
||||
mocks.getWorkerPrisma.mockReturnValue(prisma);
|
||||
const controller = new AbortController();
|
||||
// Two accounts in, pg-boss gives up on the job.
|
||||
mocks.store.mockImplementation(async () => {
|
||||
if (mocks.store.mock.calls.length === 2) controller.abort();
|
||||
});
|
||||
|
||||
const outcome = await handleDataBackup([
|
||||
{
|
||||
expireInSeconds: 7200,
|
||||
signal: controller.signal,
|
||||
} as unknown as Parameters<typeof handleDataBackup>[0][number],
|
||||
]);
|
||||
|
||||
const order = mocks.store.mock.calls.map(
|
||||
([input]) => (input as { userId: string }).userId,
|
||||
);
|
||||
expect(order).toEqual(["never", "stale"]);
|
||||
expect(outcome).toMatchObject({
|
||||
ok: true,
|
||||
did: { backed: 2, stopped_early: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,16 +121,39 @@ export async function handleOffhostBackup(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The weekly on-host copy of every account.
|
||||
*
|
||||
* The pass serialises each account's whole record, so on a large instance it
|
||||
* can outlast its job. It walks the accounts whose weekly copy is oldest
|
||||
* first and stops between accounts once the job's budget is spent
|
||||
* (`jobBudget`), reporting `stopped_early`. The accounts it did not reach have
|
||||
* the oldest copies, so they lead the next pass (the weekly cron or the
|
||||
* admin's "run now"), and every pass makes progress where it matters most.
|
||||
* The binding holds a lock (`lockedPass`), so a retry never runs beside it.
|
||||
*/
|
||||
export async function handleDataBackup(
|
||||
jobs: Job<DataBackupPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
const shouldStop = jobBudget(jobs);
|
||||
return withBackgroundEvent("job.data_backup", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
const [accounts, copies] = await Promise.all([
|
||||
prisma.user.findMany({ select: { id: true, username: true } }),
|
||||
prisma.dataBackup.findMany({
|
||||
where: { type: "WEEKLY_AUTO" },
|
||||
select: { userId: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
const copiedAt = new Map(
|
||||
copies.map((copy) => [copy.userId, copy.createdAt.getTime()]),
|
||||
);
|
||||
// No copy yet sorts first, then the oldest copy.
|
||||
const users = [...accounts].sort(
|
||||
(a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1),
|
||||
);
|
||||
let stoppedEarly = false;
|
||||
|
||||
let backed = 0;
|
||||
let usersFailed = 0;
|
||||
@@ -142,6 +165,10 @@ export async function handleDataBackup(
|
||||
// six weeks in.
|
||||
let lastError: unknown;
|
||||
for (const user of users) {
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
reportJobProgress({
|
||||
backup_user: user.id,
|
||||
backup_users_done: backed + usersFailed + skippedRestoring,
|
||||
@@ -207,6 +234,7 @@ export async function handleDataBackup(
|
||||
users_failed: usersFailed,
|
||||
records_oversized: oversized,
|
||||
users_skipped_restoring: skippedRestoring,
|
||||
stopped_early: stoppedEarly,
|
||||
};
|
||||
|
||||
// A pass that wrote nothing for anybody protected nobody, and saying
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
runInsightStatusGenerate,
|
||||
} from "@/lib/jobs/insight-status-generate";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
|
||||
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
|
||||
@@ -77,6 +78,12 @@ export interface MedicationComplianceStatusPayload {
|
||||
* The pass is what succeeds or fails. One user whose generation threw is that
|
||||
* user's cold card, reported as the `failed` count; failing the job would
|
||||
* re-run the whole cohort over it.
|
||||
*
|
||||
* The pass walks every candidate with a provider call each, so on a large
|
||||
* instance it can outlast its job. `shouldStop` is the job's time budget: the
|
||||
* pass stops between users once it is spent and reports `stopped_early`. The
|
||||
* users it did not reach keep yesterday's note until the next night or their
|
||||
* next visit; nothing is retried beside a pass that is still running.
|
||||
*/
|
||||
export async function runStatusCronGenerate(
|
||||
taskName: string,
|
||||
@@ -84,6 +91,7 @@ export async function runStatusCronGenerate(
|
||||
userId: string,
|
||||
options: { locale: string | null; force: boolean },
|
||||
) => Promise<unknown>,
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<JobOutcome> {
|
||||
return withBackgroundEvent(taskName, async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
@@ -96,8 +104,13 @@ export async function runStatusCronGenerate(
|
||||
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
|
||||
for (const user of users) {
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
// The capability before the generator builds a snapshot. The batch
|
||||
// entry below checks for itself.
|
||||
const capability = await aiCapabilityForJob(user.id, "statusText");
|
||||
@@ -124,9 +137,14 @@ export async function runStatusCronGenerate(
|
||||
|
||||
evt.setBackground({
|
||||
task_name: taskName,
|
||||
result: { generated, failed, total: users.length },
|
||||
result: { generated, failed, total: users.length, stoppedEarly },
|
||||
});
|
||||
return jobDone({
|
||||
total: users.length,
|
||||
generated,
|
||||
failed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
return jobDone({ total: users.length, generated, failed });
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
@@ -153,7 +171,10 @@ export async function runStatusCronGenerate(
|
||||
* inside the batch itself). Net effect: the nightly ladder pays one call per
|
||||
* user instead of seven, with no queue/registry churn.
|
||||
*/
|
||||
async function runStatusBatchCron(taskName: string): Promise<JobOutcome> {
|
||||
async function runStatusBatchCron(
|
||||
taskName: string,
|
||||
shouldStop: () => boolean,
|
||||
): Promise<JobOutcome> {
|
||||
return withBackgroundEvent(taskName, async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
@@ -166,7 +187,14 @@ async function runStatusBatchCron(taskName: string): Promise<JobOutcome> {
|
||||
let generated = 0;
|
||||
let served = 0;
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
for (const user of users) {
|
||||
// Same budget as the per-metric passes: stop between users, and
|
||||
// leave the rest to the later crons and the next night.
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const result = await generateStatusBatchForUser(user.id, {
|
||||
// The stored locale, then the operator default — the prompt names
|
||||
@@ -186,16 +214,34 @@ async function runStatusBatchCron(taskName: string): Promise<JobOutcome> {
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.status.batch.cron" },
|
||||
meta: { generated, served, failed, total: users.length },
|
||||
meta: {
|
||||
generated,
|
||||
served,
|
||||
failed,
|
||||
total: users.length,
|
||||
stopped_early: stoppedEarly,
|
||||
},
|
||||
});
|
||||
evt.setBackground({
|
||||
task_name: taskName,
|
||||
result: { generated, served, failed, total: users.length },
|
||||
result: {
|
||||
generated,
|
||||
served,
|
||||
failed,
|
||||
total: users.length,
|
||||
stoppedEarly,
|
||||
},
|
||||
});
|
||||
// A user whose batch call threw already fell back to the single-card
|
||||
// path inside the batch, and the six later per-metric crons cover what
|
||||
// is still cold. The batch pass ran, so it reports the count.
|
||||
return jobDone({ total: users.length, generated, served, failed });
|
||||
return jobDone({
|
||||
total: users.length,
|
||||
generated,
|
||||
served,
|
||||
failed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
@@ -208,61 +254,66 @@ async function runStatusBatchCron(taskName: string): Promise<JobOutcome> {
|
||||
export function handleGeneralStatusGenerate(
|
||||
jobs: Job<GeneralStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusBatchCron("job.insights.batch");
|
||||
return runStatusBatchCron("job.insights.batch", jobBudget(jobs));
|
||||
}
|
||||
|
||||
export function handleBloodPressureStatusGenerate(
|
||||
jobs: Job<BloodPressureStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.blood_pressure",
|
||||
generateBloodPressureStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWeightStatusGenerate(
|
||||
jobs: Job<WeightStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.weight",
|
||||
generateWeightStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handlePulseStatusGenerate(
|
||||
jobs: Job<PulseStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.pulse",
|
||||
generatePulseStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleBmiStatusGenerate(
|
||||
jobs: Job<BmiStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser);
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.bmi",
|
||||
generateBmiStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleMoodStatusGenerate(
|
||||
jobs: Job<MoodStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser);
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.mood",
|
||||
generateMoodStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleMedicationComplianceStatusGenerate(
|
||||
jobs: Job<MedicationComplianceStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.medication_compliance",
|
||||
generateMedicationComplianceStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +361,9 @@ export async function handleInsightPregenerateJob(
|
||||
return jobDone({ forced: forced.length, scheduled: 0 });
|
||||
}
|
||||
try {
|
||||
const summary = await runInsightPregenerate(getWorkerPrisma());
|
||||
const summary = await runInsightPregenerate(getWorkerPrisma(), {
|
||||
shouldStop: jobBudget(jobs),
|
||||
});
|
||||
evt.setBackground({
|
||||
task_name: "job.insight_pregenerate",
|
||||
result: { ...summary },
|
||||
@@ -325,6 +378,7 @@ export async function handleInsightPregenerateJob(
|
||||
skipped: summary.skipped,
|
||||
failed: summary.failed,
|
||||
budget_blocked: summary.budgetBlocked,
|
||||
stopped_early: summary.stoppedEarly,
|
||||
assessments_warmed: summary.assessmentsWarmed,
|
||||
metric_assessments_warmed: summary.metricAssessmentsWarmed,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Extracted from reminder-worker.ts, which owns the queue names, cron
|
||||
* schedules, and boss.work registrations.
|
||||
*/
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { type Job } from "pg-boss";
|
||||
import { runGeoBackfill } from "@/lib/jobs/geo-backfill";
|
||||
import { fetchGeoLite2Databases } from "@/lib/geo/geolite2-fetch";
|
||||
@@ -223,6 +224,11 @@ export async function handlePrDetection(
|
||||
let insertedAcrossJobs = 0;
|
||||
let tiesAcrossJobs = 0;
|
||||
let usersFailed = 0;
|
||||
let stoppedEarly = false;
|
||||
// The cron pass walks every account's all-time history and ticks every
|
||||
// thirty minutes. It stops between accounts once the job's budget is spent;
|
||||
// the ingest jobs and the next tick carry the rest.
|
||||
const shouldStop = jobBudget(jobs);
|
||||
for (const job of jobs) {
|
||||
await withBackgroundEvent("job.pr_detection", async (evt) => {
|
||||
const p = getWorkerPrisma();
|
||||
@@ -241,6 +247,10 @@ export async function handlePrDetection(
|
||||
let insertedTotal = 0;
|
||||
let tiesTotal = 0;
|
||||
for (const userId of userIds) {
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const result = await detectPersonalRecordsForUser(userId, {
|
||||
silent,
|
||||
@@ -274,5 +284,6 @@ export async function handlePrDetection(
|
||||
pr_detection_inserted: insertedAcrossJobs,
|
||||
pr_detection_ties: tiesAcrossJobs,
|
||||
pr_detection_users_failed: usersFailed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
}
|
||||
@@ -31,8 +31,12 @@ import {
|
||||
PR_DETECTION_QUEUE,
|
||||
PR_DETECTION_CONCURRENCY,
|
||||
PR_DETECTION_FALLBACK_CRON,
|
||||
PR_DETECTION_EXPIRE_SECONDS,
|
||||
type PrDetectionPayload,
|
||||
} from "@/lib/jobs/pr-detection";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass";
|
||||
import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
MEDICATION_INVENTORY_EXPIRE_CRON,
|
||||
@@ -531,7 +535,11 @@ const schedules: ScheduleEntry[] = [
|
||||
STEP_UP_ELEVATION_CLEANUP_CRON,
|
||||
cronIsTheRetry,
|
||||
],
|
||||
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
|
||||
[
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
OFFHOST_BACKUP_CRON,
|
||||
{ expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS },
|
||||
],
|
||||
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON],
|
||||
// Nightly backstop for the purge the deletion kicks straight away: a
|
||||
// request the bucket refused, or one written while the queue was down.
|
||||
@@ -557,7 +565,11 @@ const schedules: ScheduleEntry[] = [
|
||||
// cron payload deliberately omits a `userId` so the handler iterates
|
||||
// every user; per-user push-suppression cannot apply on the cron
|
||||
// path (the silent flag is set by the ingest hooks).
|
||||
[PR_DETECTION_QUEUE, PR_DETECTION_FALLBACK_CRON],
|
||||
[
|
||||
PR_DETECTION_QUEUE,
|
||||
PR_DETECTION_FALLBACK_CRON,
|
||||
{ expireInSeconds: PR_DETECTION_EXPIRE_SECONDS },
|
||||
],
|
||||
// v1.4.25 W19b — daily expire-stale pass for the per-pen inventory
|
||||
// entities. Flips IN_USE rows whose 30-day clock has blown to
|
||||
// EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx
|
||||
@@ -747,7 +759,7 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
DATA_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleDataBackup,
|
||||
lockedPass(DATA_BACKUP_QUEUE, () => WHOLE_PASS, handleDataBackup),
|
||||
);
|
||||
await createAndWork<RateLimitCleanupPayload>(
|
||||
boss,
|
||||
@@ -778,7 +790,7 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1, includeMetadata: true },
|
||||
handleOffhostBackup,
|
||||
lockedPass(OFFHOST_BACKUP_QUEUE, () => WHOLE_PASS, handleOffhostBackup),
|
||||
);
|
||||
await createAndWork(
|
||||
boss,
|
||||
@@ -935,7 +947,16 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
handlePrDetection,
|
||||
// An ingest job is one account; the cron pass is every account, one run
|
||||
// at a time.
|
||||
lockedPass(
|
||||
PR_DETECTION_QUEUE,
|
||||
(job) => {
|
||||
const userId = (job.data as PrDetectionPayload | undefined)?.userId;
|
||||
return userId ? `user:${userId}` : WHOLE_PASS;
|
||||
},
|
||||
handlePrDetection,
|
||||
),
|
||||
);
|
||||
await createAndWork<MedicationInventoryExpirePayload>(
|
||||
boss,
|
||||
@@ -1159,7 +1180,11 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY },
|
||||
handleEncryptionKeyRotate,
|
||||
lockedPass(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleEncryptionKeyRotate,
|
||||
),
|
||||
);
|
||||
|
||||
// v1.25 — medication-note encryption backfill worker. The boot enqueue
|
||||
@@ -1318,56 +1343,65 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
CONTENT_INDEX_BACKFILL_QUEUE,
|
||||
{ localConcurrency: CONTENT_INDEX_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
let users = 0;
|
||||
let skipped = 0;
|
||||
let documentsIndexed = 0;
|
||||
let documentsRetokenised = 0;
|
||||
let documentsSkipped = 0;
|
||||
let documentsFailed = 0;
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
if (!userId) {
|
||||
skipped++;
|
||||
continue;
|
||||
lockedPass(
|
||||
CONTENT_INDEX_BACKFILL_QUEUE,
|
||||
(job) => `user:${job.data.userId ?? ""}`,
|
||||
async (jobs) => {
|
||||
let users = 0;
|
||||
let stoppedEarly = false;
|
||||
const shouldStop = jobBudget(jobs);
|
||||
let skipped = 0;
|
||||
let documentsIndexed = 0;
|
||||
let documentsRetokenised = 0;
|
||||
let documentsSkipped = 0;
|
||||
let documentsFailed = 0;
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
if (!userId) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const {
|
||||
indexed,
|
||||
retokenised,
|
||||
skipped: docsSkipped,
|
||||
failed: docsFailed,
|
||||
reason,
|
||||
stoppedEarly: stopped,
|
||||
} = await runContentIndexBackfillForUser(userId, shouldStop);
|
||||
users++;
|
||||
stoppedEarly ||= stopped === true;
|
||||
documentsIndexed += indexed;
|
||||
documentsRetokenised += retokenised;
|
||||
documentsSkipped += docsSkipped;
|
||||
documentsFailed += docsFailed;
|
||||
workerLog(
|
||||
"info",
|
||||
`[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog(
|
||||
"error",
|
||||
`[document-content-index-backfill] user=${userId} failed`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const {
|
||||
indexed,
|
||||
retokenised,
|
||||
skipped: docsSkipped,
|
||||
failed: docsFailed,
|
||||
reason,
|
||||
} = await runContentIndexBackfillForUser(userId);
|
||||
users++;
|
||||
documentsIndexed += indexed;
|
||||
documentsRetokenised += retokenised;
|
||||
documentsSkipped += docsSkipped;
|
||||
documentsFailed += docsFailed;
|
||||
workerLog(
|
||||
"info",
|
||||
`[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog(
|
||||
"error",
|
||||
`[document-content-index-backfill] user=${userId} failed`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return jobDone({
|
||||
jobs: jobs.length,
|
||||
users,
|
||||
skipped,
|
||||
documents_indexed: documentsIndexed,
|
||||
documents_retokenised: documentsRetokenised,
|
||||
documents_skipped: documentsSkipped,
|
||||
documents_failed: documentsFailed,
|
||||
});
|
||||
},
|
||||
return jobDone({
|
||||
jobs: jobs.length,
|
||||
users,
|
||||
skipped,
|
||||
documents_indexed: documentsIndexed,
|
||||
documents_retokenised: documentsRetokenised,
|
||||
documents_skipped: documentsSkipped,
|
||||
documents_failed: documentsFailed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Document AI — automatic per-document content indexing. Enqueued on upload
|
||||
|
||||
@@ -102,11 +102,16 @@ import {
|
||||
} from "@/lib/jobs/insight-status-generate";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass";
|
||||
import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared";
|
||||
import { getWorkerPrisma, workerLog } from "./shared";
|
||||
import {
|
||||
createAndSchedule,
|
||||
createAndWork,
|
||||
insightRetryOptions,
|
||||
nightlyInsightPassOptions,
|
||||
type QueuePolicyTable,
|
||||
type ScheduleEntry,
|
||||
} from "./registrar-shared";
|
||||
@@ -232,25 +237,32 @@ const allQueues = [
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
[GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, insightRetryOptions],
|
||||
[GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
BLOOD_PRESSURE_STATUS_CRON,
|
||||
insightRetryOptions,
|
||||
nightlyInsightPassOptions,
|
||||
],
|
||||
[WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, insightRetryOptions],
|
||||
[PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, insightRetryOptions],
|
||||
[BMI_STATUS_QUEUE, BMI_STATUS_CRON, insightRetryOptions],
|
||||
[WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[BMI_STATUS_QUEUE, BMI_STATUS_CRON, nightlyInsightPassOptions],
|
||||
// v1.15.20 — mood status nightly, continuing the 02:xx ladder.
|
||||
[MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, insightRetryOptions],
|
||||
[MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
MEDICATION_COMPLIANCE_STATUS_CRON,
|
||||
insightRetryOptions,
|
||||
nightlyInsightPassOptions,
|
||||
],
|
||||
// v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight
|
||||
// pre-generation. Budget-gated per user inside the handler.
|
||||
[INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, insightRetryOptions],
|
||||
[
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
INSIGHT_PREGENERATE_CRON,
|
||||
{
|
||||
...insightRetryOptions,
|
||||
expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS,
|
||||
},
|
||||
],
|
||||
// v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin
|
||||
// Recovery-score compute + store, after the rollup-feeding consolidation
|
||||
// + drain so the signals it reads are already folded.
|
||||
@@ -266,7 +278,14 @@ const schedules: ScheduleEntry[] = [
|
||||
// v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The
|
||||
// handler only fans out on a week (Mon) / month (1st) boundary; every
|
||||
// other night is a cheap no-op. Budget-gated per user inside the runner.
|
||||
[PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, insightRetryOptions],
|
||||
[
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
PERIOD_NARRATIVE_CRON,
|
||||
{
|
||||
...insightRetryOptions,
|
||||
expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS,
|
||||
},
|
||||
],
|
||||
// v1.15.20 — daily 05:15 Europe/Berlin proactive Coach nudge, after
|
||||
// the 04:45–04:55 score crons so the recovery-score trigger reads
|
||||
// settled rows. Deterministic triggers only — no AI call on this path.
|
||||
@@ -369,43 +388,59 @@ export async function registerStatusQueues(
|
||||
boss,
|
||||
GENERAL_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleGeneralStatusGenerate,
|
||||
lockedPass(
|
||||
GENERAL_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleGeneralStatusGenerate,
|
||||
),
|
||||
);
|
||||
await createAndWork<BloodPressureStatusPayload>(
|
||||
boss,
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleBloodPressureStatusGenerate,
|
||||
lockedPass(
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleBloodPressureStatusGenerate,
|
||||
),
|
||||
);
|
||||
await createAndWork<WeightStatusPayload>(
|
||||
boss,
|
||||
WEIGHT_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWeightStatusGenerate,
|
||||
lockedPass(
|
||||
WEIGHT_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleWeightStatusGenerate,
|
||||
),
|
||||
);
|
||||
await createAndWork<PulseStatusPayload>(
|
||||
boss,
|
||||
PULSE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handlePulseStatusGenerate,
|
||||
lockedPass(PULSE_STATUS_QUEUE, () => WHOLE_PASS, handlePulseStatusGenerate),
|
||||
);
|
||||
await createAndWork<BmiStatusPayload>(
|
||||
boss,
|
||||
BMI_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleBmiStatusGenerate,
|
||||
lockedPass(BMI_STATUS_QUEUE, () => WHOLE_PASS, handleBmiStatusGenerate),
|
||||
);
|
||||
await createAndWork<MoodStatusPayload>(
|
||||
boss,
|
||||
MOOD_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodStatusGenerate,
|
||||
lockedPass(MOOD_STATUS_QUEUE, () => WHOLE_PASS, handleMoodStatusGenerate),
|
||||
);
|
||||
await createAndWork<MedicationComplianceStatusPayload>(
|
||||
boss,
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMedicationComplianceStatusGenerate,
|
||||
lockedPass(
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleMedicationComplianceStatusGenerate,
|
||||
),
|
||||
);
|
||||
// v1.7.0 — nightly comprehensive-insight pre-generation.
|
||||
//
|
||||
@@ -422,7 +457,16 @@ export async function registerStatusQueues(
|
||||
boss,
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
{ localConcurrency: 2 },
|
||||
handleInsightPregenerateJob,
|
||||
// The cohort walk is one pass at a time; a forced warm is one per
|
||||
// account, so it never waits behind the walk.
|
||||
lockedPass(
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
(job) =>
|
||||
job.data?.force && job.data.userId
|
||||
? `user:${job.data.userId}`
|
||||
: WHOLE_PASS,
|
||||
handleInsightPregenerateJob,
|
||||
),
|
||||
);
|
||||
// S4 — event-driven morning digest refresh. One forced comprehensive
|
||||
// regeneration per user per local morning, enqueued by the sleep-arrival
|
||||
@@ -749,44 +793,53 @@ export async function registerStatusQueues(
|
||||
boss,
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async (jobs): Promise<JobOutcome> => {
|
||||
// Two modes on one queue, so the outcome counts them separately: a
|
||||
// non-boundary night is a scheduled pass that generated nothing, which
|
||||
// is a success with zero counts, not an idle queue.
|
||||
let singleUser = 0;
|
||||
let scheduled = 0;
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.data?.userId) {
|
||||
await warmOneNarrative(job.data);
|
||||
singleUser++;
|
||||
} else {
|
||||
const summary = await runPeriodNarrativeWarm(getWorkerPrisma());
|
||||
scheduled++;
|
||||
generated += summary.generated;
|
||||
failed += summary.failed;
|
||||
workerLog(
|
||||
"info",
|
||||
`[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`,
|
||||
);
|
||||
lockedPass(
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
(job) => (job.data?.userId ? `user:${job.data.userId}` : WHOLE_PASS),
|
||||
async (jobs): Promise<JobOutcome> => {
|
||||
// Two modes on one queue, so the outcome counts them separately: a
|
||||
// non-boundary night is a scheduled pass that generated nothing, which
|
||||
// is a success with zero counts, not an idle queue.
|
||||
let singleUser = 0;
|
||||
let scheduled = 0;
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.data?.userId) {
|
||||
await warmOneNarrative(job.data);
|
||||
singleUser++;
|
||||
} else {
|
||||
const summary = await runPeriodNarrativeWarm(getWorkerPrisma(), {
|
||||
shouldStop: jobBudget(jobs),
|
||||
});
|
||||
scheduled++;
|
||||
generated += summary.generated;
|
||||
failed += summary.failed;
|
||||
stoppedEarly ||= summary.stoppedEarly;
|
||||
workerLog(
|
||||
"info",
|
||||
`[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, {
|
||||
mode: job.data?.userId ? "single-user" : "scheduled",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, {
|
||||
mode: job.data?.userId ? "single-user" : "scheduled",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return jobDone({
|
||||
single_user: singleUser,
|
||||
scheduled,
|
||||
generated,
|
||||
failed,
|
||||
});
|
||||
},
|
||||
return jobDone({
|
||||
single_user: singleUser,
|
||||
scheduled,
|
||||
generated,
|
||||
failed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
// v1.11.1 — combined Coach memory refresh: rolling conversation summary +
|
||||
// durable fact extraction for one long conversation. localConcurrency 1 so a
|
||||
|
||||
@@ -168,6 +168,22 @@ export const insightRetryOptions = {
|
||||
retryBackoff: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Send options for a nightly AI pass over every account (the status crons).
|
||||
*
|
||||
* The retries of `insightRetryOptions`, plus a two-hour expiry. Each pass
|
||||
* makes a provider call per account, and on a large instance that outlasted
|
||||
* pg-boss's fifteen-minute default: the job was declared dead and retried
|
||||
* beside the pass still running. The pass now stops itself at three quarters
|
||||
* of this (`jobBudget`) and holds a lock (`lockedPass`). The options ride on
|
||||
* the schedule because the cron is the only sender.
|
||||
*/
|
||||
export const NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS = 2 * 60 * 60;
|
||||
export const nightlyInsightPassOptions = {
|
||||
...insightRetryOptions,
|
||||
expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Retry policy for a cron-driven pass whose failure mode is deterministic.
|
||||
*
|
||||
|
||||
@@ -389,6 +389,12 @@ const listMeasurementsMeta = z
|
||||
.describe(
|
||||
"Rows discarded by the cross-source canonical picker on the day-collapse mode.",
|
||||
),
|
||||
windowFrom: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant the read started from. The window is a year ending at `to` (or now); page further back by moving `to`.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ListMeasurementsMeta" });
|
||||
|
||||
@@ -696,7 +702,7 @@ const seriesPointSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
"Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` / `glucose` within 90 days when the window holds more than 10 000 readings).",
|
||||
"Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:<ISO start>` for an hour bucket (the same kinds within 90 days when the window holds more than 10 000 readings).",
|
||||
),
|
||||
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
|
||||
value: z.number().describe("Primary value in the top-level `unit`."),
|
||||
@@ -751,7 +757,7 @@ const seriesResponse = z
|
||||
.meta({
|
||||
id: "MeasurementsSeriesResponse",
|
||||
description:
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
|
||||
});
|
||||
|
||||
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGlucoseTargets } from "../glucose-builder";
|
||||
import { foldGlucoseTargetRows } from "../glucose-read";
|
||||
import { buildMedicationTarget } from "../medication-builder";
|
||||
import { buildMoodTargets } from "../mood-builder";
|
||||
import { buildSleepTarget } from "../sleep-builder";
|
||||
@@ -8,11 +9,13 @@ import { buildVitalTargets } from "../vitals-builder";
|
||||
|
||||
const NOW = new Date("2026-07-21T12:00:00.000Z");
|
||||
const TZ = "UTC";
|
||||
const RECENT = new Date(NOW.getTime() - 30 * 86_400_000);
|
||||
|
||||
describe("target section builders", () => {
|
||||
it("keeps the vital target order and profile-gated cards on an empty profile", () => {
|
||||
const result = buildVitalTargets({
|
||||
recentMeasurements: [],
|
||||
restingPulseProxy: [],
|
||||
latestByType: {},
|
||||
average30ByType: {},
|
||||
heightCm: null,
|
||||
@@ -108,7 +111,7 @@ describe("target section builders", () => {
|
||||
},
|
||||
];
|
||||
const targets = buildGlucoseTargets({
|
||||
rows,
|
||||
summaries: foldGlucoseTargetRows(rows, RECENT, TZ),
|
||||
profile: {
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
@@ -140,10 +143,14 @@ describe("target section builders", () => {
|
||||
// metadata, so every row arrives with a null context. Iterating the four
|
||||
// named contexts alone returned no glucose target at all.
|
||||
const targets = buildGlucoseTargets({
|
||||
rows: [
|
||||
{ value: 112, measuredAt: NOW, glucoseContext: null },
|
||||
{ value: 104, measuredAt: NOW, glucoseContext: null },
|
||||
],
|
||||
summaries: foldGlucoseTargetRows(
|
||||
[
|
||||
{ value: 112, measuredAt: NOW, glucoseContext: null },
|
||||
{ value: 104, measuredAt: NOW, glucoseContext: null },
|
||||
],
|
||||
RECENT,
|
||||
TZ,
|
||||
),
|
||||
profile: {
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
@@ -173,11 +180,15 @@ describe("target section builders", () => {
|
||||
|
||||
it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => {
|
||||
const targets = buildGlucoseTargets({
|
||||
rows: [
|
||||
{ value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const },
|
||||
{ value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const },
|
||||
{ value: 111, measuredAt: NOW, glucoseContext: null },
|
||||
],
|
||||
summaries: foldGlucoseTargetRows(
|
||||
[
|
||||
{ value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const },
|
||||
{ value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const },
|
||||
{ value: 111, measuredAt: NOW, glucoseContext: null },
|
||||
],
|
||||
RECENT,
|
||||
TZ,
|
||||
),
|
||||
profile: {
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
@@ -235,6 +246,7 @@ describe("target section builders", () => {
|
||||
describe("buildVitalTargets — user weight target", () => {
|
||||
const base = {
|
||||
recentMeasurements: [],
|
||||
restingPulseProxy: [],
|
||||
latestByType: {},
|
||||
average30ByType: {},
|
||||
heightCm: 178,
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import { buildGlucoseTargets } from "./glucose-builder";
|
||||
import { readGlucoseTargetSummaries } from "./glucose-read";
|
||||
import { buildMedicationTarget } from "./medication-builder";
|
||||
import { buildMoodTargets } from "./mood-builder";
|
||||
import { buildSleepTarget } from "./sleep-builder";
|
||||
@@ -76,6 +78,10 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
"BODY_FAT",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
// Heart rate is the one vital a device streams. Its thirty days reach the
|
||||
// resting-pulse card as one proxy point per day, folded in SQL below; every
|
||||
// other vital is written a few times a day and is read as rows.
|
||||
const recentTypes = types.filter((type) => type !== "PULSE");
|
||||
const thirtyDaysAgo = new Date(queryNow.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const oneYearAgo = new Date(queryNow.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||
const moodSince = new Date(
|
||||
@@ -96,7 +102,8 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
medicationBundle,
|
||||
moodRollups,
|
||||
latestMoodEntry,
|
||||
glucoseRows,
|
||||
glucoseSummaries,
|
||||
restingPulseProxy,
|
||||
] = await Promise.all([
|
||||
limit(() =>
|
||||
prisma.user.findUnique({
|
||||
@@ -116,7 +123,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
type: { in: recentTypes },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -212,16 +219,22 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
),
|
||||
// A sensor reporting every five minutes writes 105 000 glucose readings
|
||||
// a year. The cards need the newest per meal context and the last thirty
|
||||
// days per local day, so both fold in SQL.
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
readGlucoseTargetSummaries({
|
||||
userId,
|
||||
since: oneYearAgo,
|
||||
recentSince: thirtyDaysAgo,
|
||||
timeZone: timezone,
|
||||
}),
|
||||
),
|
||||
limit(() =>
|
||||
readRestingPulseProxy({
|
||||
userId,
|
||||
since: thirtyDaysAgo,
|
||||
timeZone: timezone,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
@@ -252,6 +265,8 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
for (const type of types) {
|
||||
latestByType[type] =
|
||||
latestEverByType.find((row) => row.type === type)?.value ?? null;
|
||||
}
|
||||
for (const type of recentTypes) {
|
||||
const recent = recentMeasurements.filter(
|
||||
(measurement) => measurement.type === type,
|
||||
);
|
||||
@@ -267,6 +282,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
|
||||
const vitalSection = buildVitalTargets({
|
||||
recentMeasurements,
|
||||
restingPulseProxy,
|
||||
latestByType,
|
||||
average30ByType,
|
||||
heightCm,
|
||||
@@ -326,7 +342,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
|
||||
targets.push(
|
||||
...buildGlucoseTargets({
|
||||
rows: glucoseRows,
|
||||
summaries: glucoseSummaries,
|
||||
profile: {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
|
||||
@@ -43,12 +43,13 @@ function last7DayKeys(now: Date, timezone: string): string[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function computeStrip(
|
||||
/** A day's readings folded to their sum and count, keyed by local day. */
|
||||
export type DaySums = ReadonlyMap<string, { sum: number; count: number }>;
|
||||
|
||||
function sumByDay(
|
||||
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
classify: (mean: number) => DayBand | null,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
) {
|
||||
): Map<string, { sum: number; count: number }> {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const event of events) {
|
||||
const key = userDayKey(event.measuredAt, timezone);
|
||||
@@ -57,7 +58,15 @@ function computeStrip(
|
||||
bucket.count += 1;
|
||||
byDay.set(key, bucket);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
|
||||
function computeStrip(
|
||||
byDay: DaySums,
|
||||
classify: (mean: number) => DayBand | null,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
) {
|
||||
const consistency7d: Array<DayBand | null> = [];
|
||||
let daysInRange7d = 0;
|
||||
let daysLogged7d = 0;
|
||||
@@ -77,13 +86,40 @@ function computeStrip(
|
||||
|
||||
export function rollupConsistency({
|
||||
events,
|
||||
...rest
|
||||
}: RollupConsistencyInput): TargetConsistency {
|
||||
return rollupConsistencyFromDays({
|
||||
...rest,
|
||||
byDay: sumByDay(events, rest.timezone),
|
||||
readingCount: events.length,
|
||||
});
|
||||
}
|
||||
|
||||
interface RollupDaySumsInput extends ConsistencyClock {
|
||||
/** Each local day's readings as sum and count. */
|
||||
byDay: DaySums;
|
||||
/** Readings behind `byDay`, for the thin-data threshold. */
|
||||
readingCount: number;
|
||||
classify: (mean: number) => DayBand | null;
|
||||
totalReadingsThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same consistency as {@link rollupConsistency}, from readings already
|
||||
* folded per local day. Every figure here is a function of each day's mean
|
||||
* and of the reading count, so a reader that folds in SQL hands over one row
|
||||
* per day instead of every reading and gets the same answer.
|
||||
*/
|
||||
export function rollupConsistencyFromDays({
|
||||
byDay,
|
||||
readingCount,
|
||||
classify,
|
||||
timezone,
|
||||
now,
|
||||
totalReadingsThreshold = 3,
|
||||
}: RollupConsistencyInput): TargetConsistency {
|
||||
if (events.length < totalReadingsThreshold) {
|
||||
const partial = computeStrip(events, classify, timezone, now);
|
||||
}: RollupDaySumsInput): TargetConsistency {
|
||||
if (readingCount < totalReadingsThreshold) {
|
||||
const partial = computeStrip(byDay, classify, timezone, now);
|
||||
return {
|
||||
...EMPTY_CONSISTENCY,
|
||||
...partial,
|
||||
@@ -91,15 +127,7 @@ export function rollupConsistency({
|
||||
};
|
||||
}
|
||||
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const event of events) {
|
||||
const key = userDayKey(event.measuredAt, timezone);
|
||||
const bucket = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += event.value;
|
||||
bucket.count += 1;
|
||||
byDay.set(key, bucket);
|
||||
}
|
||||
const strip = computeStrip(events, classify, timezone, now);
|
||||
const strip = computeStrip(byDay, classify, timezone, now);
|
||||
|
||||
let daysInRange30d = 0;
|
||||
let daysLogged30d = 0;
|
||||
@@ -136,7 +164,7 @@ export function rollupConsistency({
|
||||
lastMetGoalAt,
|
||||
streakDays,
|
||||
insufficientData:
|
||||
events.length < totalReadingsThreshold || strip.daysLogged7d < 1,
|
||||
readingCount < totalReadingsThreshold || strip.daysLogged7d < 1,
|
||||
consistency7d: strip.consistency7d,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
type GlucoseContextBucket,
|
||||
} from "@/lib/glucose";
|
||||
import { resolveGlucoseTarget } from "./glucose-targets";
|
||||
import { makeRangeClassifier, rollupConsistency } from "./consistency";
|
||||
import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types";
|
||||
import { makeRangeClassifier, rollupConsistencyFromDays } from "./consistency";
|
||||
import type { TargetGlucoseContextSummary } from "./glucose-read";
|
||||
import type { TargetItem, TargetProfile } from "./types";
|
||||
|
||||
const LABEL_BY_CONTEXT: Record<GlucoseContextBucket, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
@@ -16,38 +17,53 @@ const LABEL_BY_CONTEXT: Record<GlucoseContextBucket, string> = {
|
||||
};
|
||||
|
||||
interface GlucoseTargetsInput {
|
||||
rows: TargetGlucoseRow[];
|
||||
/**
|
||||
* One summary per stored meal context: the newest reading of the year and
|
||||
* the last thirty days per local day (`readGlucoseTargetSummaries`).
|
||||
*/
|
||||
summaries: TargetGlucoseContextSummary[];
|
||||
profile: TargetProfile;
|
||||
timezone: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export function buildGlucoseTargets({
|
||||
rows,
|
||||
summaries,
|
||||
profile,
|
||||
timezone,
|
||||
now,
|
||||
}: GlucoseTargetsInput): TargetItem[] {
|
||||
const targets: TargetItem[] = [];
|
||||
const unit = resolveGlucoseUnit(profile.glucoseUnit);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// #943 — the untagged bucket rides the same loop. Iterating the four named
|
||||
// contexts alone left an account whose source writes no meal-time tag with
|
||||
// no glucose target at all, the same gap the dashboard tile had.
|
||||
for (const [context, contextRows] of groupByGlucoseContext(
|
||||
rows,
|
||||
(row) => row.glucoseContext,
|
||||
for (const [context, contextSummaries] of groupByGlucoseContext(
|
||||
summaries,
|
||||
(summary) => summary.glucoseContext,
|
||||
)) {
|
||||
const latest = contextRows[0].value;
|
||||
const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo);
|
||||
// A bucket is one stored context today; folding several keeps the newest
|
||||
// reading and adds the days up, should two ever share a bucket.
|
||||
const newest = contextSummaries.reduce((a, b) =>
|
||||
b.latestAt > a.latestAt ? b : a,
|
||||
);
|
||||
const latest = newest.latest;
|
||||
const recentByDay = new Map<string, { sum: number; count: number }>();
|
||||
let recentCount = 0;
|
||||
let recentSum = 0;
|
||||
for (const summary of contextSummaries) {
|
||||
recentCount += summary.recentCount;
|
||||
for (const [day, bucket] of summary.recentByDay) {
|
||||
const current = recentByDay.get(day) ?? { sum: 0, count: 0 };
|
||||
current.sum += bucket.sum;
|
||||
current.count += bucket.count;
|
||||
recentByDay.set(day, current);
|
||||
recentSum += bucket.sum;
|
||||
}
|
||||
}
|
||||
const average30 =
|
||||
recent.length > 0
|
||||
? Math.round(
|
||||
(recent.reduce((sum, row) => sum + row.value, 0) / recent.length) *
|
||||
10,
|
||||
) / 10
|
||||
: null;
|
||||
recentCount > 0 ? Math.round((recentSum / recentCount) * 10) / 10 : null;
|
||||
const resolved = resolveGlucoseTarget({
|
||||
context,
|
||||
hasDiabetes: profile.hasDiabetes,
|
||||
@@ -97,11 +113,9 @@ export function buildGlucoseTargets({
|
||||
: resolved.source === "ADA goal (diabetes)"
|
||||
? "ADA goal (diabetes)"
|
||||
: "ADA 2024 / DDG",
|
||||
...rollupConsistency({
|
||||
events: recent.map((row) => ({
|
||||
measuredAt: row.measuredAt,
|
||||
value: row.value,
|
||||
})),
|
||||
...rollupConsistencyFromDays({
|
||||
byDay: recentByDay,
|
||||
readingCount: recentCount,
|
||||
classify: makeRangeClassifier(
|
||||
range,
|
||||
effectiveRange
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The glucose rows the targets page needs, folded in Postgres.
|
||||
*
|
||||
* The glucose targets used to read every glucose reading of the last year and
|
||||
* keep three things from them per meal context: the newest value, and for the
|
||||
* last thirty days each local day's mean and the number of readings. A sensor
|
||||
* that reports every five minutes puts 105 000 readings in that year, and the
|
||||
* targets page is read on every visit, on the web and in the app. So the year
|
||||
* is reduced to one row per context (`DISTINCT ON`) and the thirty days to one
|
||||
* row per context and local day, and only those rows cross the wire.
|
||||
*
|
||||
* Output matches {@link foldGlucoseTargetRows} over the raw readings: the same
|
||||
* newest value per context (ties broken by id, newest first), and the same
|
||||
* per-day sums and counts, with days cut in the zone the caller passes by the
|
||||
* expression every other day fold uses.
|
||||
*/
|
||||
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isValidTimezone, userDayKey } from "@/lib/tz/format";
|
||||
import type { TargetGlucoseRow } from "./types";
|
||||
|
||||
/** One meal context's newest reading and its last thirty days, per day. */
|
||||
export interface TargetGlucoseContextSummary {
|
||||
glucoseContext: GlucoseContext | null;
|
||||
latest: number;
|
||||
latestAt: Date;
|
||||
/** Local day → the day's readings as sum and count, since `recentSince`. */
|
||||
recentByDay: Map<string, { sum: number; count: number }>;
|
||||
/** Readings behind `recentByDay`. */
|
||||
recentCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory fold over raw readings, newest first. Pure; the reference the
|
||||
* SQL reader is tested against.
|
||||
*/
|
||||
export function foldGlucoseTargetRows(
|
||||
rows: readonly TargetGlucoseRow[],
|
||||
recentSince: Date,
|
||||
timeZone: string,
|
||||
): TargetGlucoseContextSummary[] {
|
||||
const byContext = new Map<string, TargetGlucoseContextSummary>();
|
||||
for (const row of rows) {
|
||||
const key = row.glucoseContext ?? "";
|
||||
let summary = byContext.get(key);
|
||||
if (!summary) {
|
||||
summary = {
|
||||
glucoseContext: row.glucoseContext,
|
||||
latest: row.value,
|
||||
latestAt: row.measuredAt,
|
||||
recentByDay: new Map(),
|
||||
recentCount: 0,
|
||||
};
|
||||
byContext.set(key, summary);
|
||||
}
|
||||
if (row.measuredAt < recentSince) continue;
|
||||
const day = userDayKey(row.measuredAt, timeZone);
|
||||
const bucket = summary.recentByDay.get(day) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += row.value;
|
||||
bucket.count += 1;
|
||||
summary.recentByDay.set(day, bucket);
|
||||
summary.recentCount += 1;
|
||||
}
|
||||
return [...byContext.values()];
|
||||
}
|
||||
|
||||
export async function readGlucoseTargetSummaries(opts: {
|
||||
userId: string;
|
||||
/** Inclusive lower bound for the newest reading per context. */
|
||||
since: Date;
|
||||
/** Inclusive lower bound for the per-day fold. */
|
||||
recentSince: Date;
|
||||
/** IANA zone the day boundary is cut in. */
|
||||
timeZone: string;
|
||||
db?: Pick<PrismaClient, "$queryRaw">;
|
||||
}): Promise<TargetGlucoseContextSummary[]> {
|
||||
const db = opts.db ?? prisma;
|
||||
const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC";
|
||||
const [latestRows, dayRows] = await Promise.all([
|
||||
db.$queryRaw<
|
||||
Array<{
|
||||
glucose_context: GlucoseContext | null;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}>
|
||||
>(Prisma.sql`
|
||||
SELECT DISTINCT ON (m."glucose_context")
|
||||
m."glucose_context" AS glucose_context,
|
||||
m."value" AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${opts.userId}
|
||||
AND m."type" = 'BLOOD_GLUCOSE'::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${opts.since}
|
||||
ORDER BY m."glucose_context", m."measured_at" DESC, m."id" DESC
|
||||
`),
|
||||
db.$queryRaw<
|
||||
Array<{
|
||||
glucose_context: GlucoseContext | null;
|
||||
day: string;
|
||||
n: number;
|
||||
sum: number;
|
||||
}>
|
||||
>(Prisma.sql`
|
||||
WITH src AS (
|
||||
SELECT
|
||||
m."glucose_context" AS glucose_context,
|
||||
to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day,
|
||||
m."value" AS value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${opts.userId}
|
||||
AND m."type" = 'BLOOD_GLUCOSE'::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${opts.recentSince}
|
||||
)
|
||||
SELECT
|
||||
glucose_context,
|
||||
day,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(value)::double precision AS sum
|
||||
FROM src
|
||||
GROUP BY glucose_context, day
|
||||
`),
|
||||
]);
|
||||
|
||||
const byContext = new Map<string, TargetGlucoseContextSummary>();
|
||||
for (const row of latestRows) {
|
||||
byContext.set(row.glucose_context ?? "", {
|
||||
glucoseContext: row.glucose_context,
|
||||
latest: Number(row.value),
|
||||
latestAt: row.measured_at,
|
||||
recentByDay: new Map(),
|
||||
recentCount: 0,
|
||||
});
|
||||
}
|
||||
for (const row of dayRows) {
|
||||
// Every recent reading is also inside the year, so its context has a
|
||||
// newest row unless `recentSince` predates `since`, which no caller does.
|
||||
const summary = byContext.get(row.glucose_context ?? "");
|
||||
if (!summary) continue;
|
||||
const n = Number(row.n);
|
||||
summary.recentByDay.set(row.day, { sum: Number(row.sum), count: n });
|
||||
summary.recentCount += n;
|
||||
}
|
||||
return [...byContext.values()];
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import {
|
||||
mergeRestingWithProxy,
|
||||
type PulseSample,
|
||||
} from "@/lib/analytics/resting-pulse";
|
||||
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
|
||||
import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
@@ -38,7 +41,15 @@ export interface BloodPressureTargetRange {
|
||||
}
|
||||
|
||||
interface VitalTargetsInput {
|
||||
/** The last thirty days of every vital type except `PULSE`. */
|
||||
recentMeasurements: TargetMeasurement[];
|
||||
/**
|
||||
* The resting-pulse proxy over the same thirty days, one point per local
|
||||
* day, folded from raw `PULSE` in Postgres (`readRestingPulseProxy`). Raw
|
||||
* heart rate is never read here: a watch that records it once a minute puts
|
||||
* 43 000 readings in thirty days, and the card needs one figure per day.
|
||||
*/
|
||||
restingPulseProxy: PulseSample[];
|
||||
latestByType: TargetValueByType;
|
||||
average30ByType: TargetValueByType;
|
||||
heightCm: number | null;
|
||||
@@ -149,6 +160,7 @@ function buildBpPairsByDay(
|
||||
|
||||
export function buildVitalTargets({
|
||||
recentMeasurements,
|
||||
restingPulseProxy,
|
||||
latestByType,
|
||||
average30ByType,
|
||||
heightCm,
|
||||
@@ -285,19 +297,14 @@ export function buildVitalTargets({
|
||||
}
|
||||
|
||||
const pulseTarget = getPersonalizedPulseTarget(age, referenceSex);
|
||||
const restingResolved = resolveRestingPulseSeries({
|
||||
const restingResolved = mergeRestingWithProxy({
|
||||
restingSamples: recentMeasurements
|
||||
.filter((measurement) => measurement.type === "RESTING_HEART_RATE")
|
||||
.map((measurement) => ({
|
||||
measuredAt: measurement.measuredAt,
|
||||
value: measurement.value,
|
||||
})),
|
||||
pulseSamples: recentMeasurements
|
||||
.filter((measurement) => measurement.type === "PULSE")
|
||||
.map((measurement) => ({
|
||||
measuredAt: measurement.measuredAt,
|
||||
value: measurement.value,
|
||||
})),
|
||||
proxySamples: restingPulseProxy,
|
||||
dayKeyOf: (date) => userDayKey(date, timezone),
|
||||
});
|
||||
const restingEvents = restingResolved.series;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* `GET /api/cycle/insights` reads its outcome channels as per-source day
|
||||
* aggregates, not as a year of readings.
|
||||
*
|
||||
* The route compared a year of resting heart rate, heart-rate variability,
|
||||
* sleep, steps, weight, temperature and glucose across the cycle phases, and
|
||||
* read every reading of it. A sensor reporting glucose every five minutes is
|
||||
* over 100 000 readings in that year. The contrast only uses each local day's
|
||||
* sum and count after one source and device is picked per day, which is what
|
||||
* `readSourceDayAggregates` returns.
|
||||
*
|
||||
* Two properties on real Postgres: no outcome channel is read as rows, and the
|
||||
* response is the one the raw readings give. The second runs the route twice,
|
||||
* once over the SQL fold and once over every raw reading handed in as its own
|
||||
* one-reading aggregate (exactly the old input), and compares the bodies.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { SourceDayAggregateRow } from "@/lib/measurements/day-aggregates";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
/** When set, the fold is replaced by every raw reading as a one-row group. */
|
||||
const mode = vi.hoisted(() => ({ raw: false }));
|
||||
|
||||
vi.mock("@/lib/measurements/day-aggregates", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/measurements/day-aggregates")>();
|
||||
return {
|
||||
...actual,
|
||||
readSourceDayAggregates: async (
|
||||
opts: Parameters<typeof actual.readSourceDayAggregates>[0],
|
||||
): Promise<SourceDayAggregateRow[]> => {
|
||||
if (!mode.raw) return actual.readSourceDayAggregates(opts);
|
||||
const { getPrismaClient } = await import("./setup");
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: {
|
||||
userId: opts.userId,
|
||||
deletedAt: null,
|
||||
type: { in: [...opts.types] },
|
||||
measuredAt: { gte: opts.since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
type: r.type,
|
||||
day: userDayKey(r.measuredAt, opts.timeZone),
|
||||
source: r.source,
|
||||
deviceType: r.deviceType,
|
||||
n: 1,
|
||||
sum: r.value,
|
||||
firstAt: r.measuredAt,
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const OWNER = "cycle-dense-owner";
|
||||
const TZ = "Europe/Berlin";
|
||||
const CYCLE_LENGTH = 28;
|
||||
const OBSERVED_CYCLES = 5;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function isoDay(offsetDays: number): string {
|
||||
return new Date(Date.now() + offsetDays * DAY).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: OWNER, username: OWNER, gender: "FEMALE", timezone: TZ },
|
||||
});
|
||||
invalidateUserTimezone(OWNER);
|
||||
await prisma.cycleProfile.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
cycleTrackingEnabled: true,
|
||||
predictionEnabled: false,
|
||||
lutealPhaseLength: 14,
|
||||
},
|
||||
});
|
||||
for (let i = OBSERVED_CYCLES; i >= 1; i--) {
|
||||
await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
startDate: isoDay(-i * CYCLE_LENGTH),
|
||||
endDate: i > 1 ? isoDay(-(i - 1) * CYCLE_LENGTH - 1) : null,
|
||||
periodEndDate: isoDay(-i * CYCLE_LENGTH + 4),
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
const start = `${isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH)} 00:00:00`;
|
||||
// Sensor glucose every five minutes from two sources, higher in the second
|
||||
// half of each cycle; heart-rate variability every twenty minutes from a
|
||||
// watch, lower in the second half; steps every fifteen minutes from a watch
|
||||
// and a phone; weight once a day next to the Berlin midnight.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'cg' || s || '-' || g, $1, 'BLOOD_GLUCOSE'::measurement_type,
|
||||
(CASE WHEN (g / 288) % 28 >= 14 THEN 120 ELSE 95 END) + (g % 23) + s * 0.5, 'mg/dL',
|
||||
(CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source,
|
||||
$2::timestamp + (g * interval '5 minutes') + (s * interval '1 second'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 288 - 1) g, generate_series(0, 1) s`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'hv' || g, $1, 'HEART_RATE_VARIABILITY'::measurement_type,
|
||||
(CASE WHEN (g / 72) % 28 >= 14 THEN 38 ELSE 52 END) + (g % 11) * 0.7, 'ms',
|
||||
'APPLE_HEALTH'::measurement_source,
|
||||
$2::timestamp + (g * interval '20 minutes'), now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 72 - 1) g`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'st' || d || '-' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count',
|
||||
'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END,
|
||||
$2::timestamp + (g * interval '15 minutes') + (d * interval '2 seconds'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 96 - 1) g, generate_series(0, 1) d
|
||||
WHERE d = 0 OR g % 3 = 0`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'wt' || g, $1, 'WEIGHT'::measurement_type,
|
||||
(CASE WHEN g % 28 >= 14 THEN 71.2 ELSE 70.1 END) + (g % 5) * 0.1, 'kg',
|
||||
'WITHINGS'::measurement_source,
|
||||
$2::timestamp + (g * interval '1 day') + interval '22 hours 50 minutes', now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} - 1) g`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: OWNER, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}, 180_000);
|
||||
|
||||
async function readInsights(): Promise<{ status: number; body: unknown }> {
|
||||
const { GET } = await import("@/app/api/cycle/insights/route");
|
||||
const handler = GET as unknown as (request: Request) => Promise<Response>;
|
||||
const res = await handler(new Request("http://localhost/api/cycle/insights"));
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
function readsOutcomeRows(args: unknown): boolean {
|
||||
const type = (args as { where?: { type?: unknown } } | undefined)?.where
|
||||
?.type;
|
||||
const list = (type as { in?: unknown } | undefined)?.in;
|
||||
return (
|
||||
type === "BLOOD_GLUCOSE" ||
|
||||
type === "HEART_RATE_VARIABILITY" ||
|
||||
(Array.isArray(list) &&
|
||||
(list.includes("BLOOD_GLUCOSE") ||
|
||||
list.includes("HEART_RATE_VARIABILITY")))
|
||||
);
|
||||
}
|
||||
|
||||
function expectSameUpToRounding(
|
||||
actual: unknown,
|
||||
expected: unknown,
|
||||
path: string,
|
||||
): void {
|
||||
if (typeof expected === "number" && typeof actual === "number") {
|
||||
const scale = Math.max(Math.abs(expected), Math.abs(actual));
|
||||
expect(
|
||||
Math.abs(actual - expected) <= scale * 1e-9,
|
||||
`${path}: ${actual} vs ${expected}`,
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(expected)) {
|
||||
expect(Array.isArray(actual), path).toBe(true);
|
||||
expect((actual as unknown[]).length, path).toBe(expected.length);
|
||||
expected.forEach((e, i) =>
|
||||
expectSameUpToRounding((actual as unknown[])[i], e, `${path}[${i}]`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (expected !== null && typeof expected === "object") {
|
||||
expect(actual !== null && typeof actual === "object", path).toBe(true);
|
||||
const a = actual as Record<string, unknown>;
|
||||
const e = expected as Record<string, unknown>;
|
||||
expect(Object.keys(a).sort(), path).toEqual(Object.keys(e).sort());
|
||||
for (const key of Object.keys(e)) {
|
||||
expectSameUpToRounding(a[key], e[key], `${path}.${key}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(actual, path).toEqual(expected);
|
||||
}
|
||||
|
||||
describe("cycle insights on a dense account", () => {
|
||||
it("reads no outcome channel as rows", async () => {
|
||||
mode.raw = false;
|
||||
const spy = vi.spyOn(prisma.measurement, "findMany");
|
||||
try {
|
||||
const started = performance.now();
|
||||
const { status } = await readInsights();
|
||||
process.stderr.write(
|
||||
`[cycle-insights-dense] ${(performance.now() - started).toFixed(0)} ms\n`,
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(spy.mock.calls.filter(([a]) => readsOutcomeRows(a))).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("answers exactly what the raw readings give", async () => {
|
||||
mode.raw = false;
|
||||
const folded = await readInsights();
|
||||
mode.raw = true;
|
||||
const raw = await readInsights();
|
||||
mode.raw = false;
|
||||
expect(folded.status).toBe(200);
|
||||
const rows = (folded.body as { data: { rows: unknown[] } }).data.rows;
|
||||
// The fixture is built so the contrast has something to say.
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
// A day's sum is added in another order in SQL than in JavaScript, so a
|
||||
// p-value can differ in its last bits; every figure is otherwise equal.
|
||||
expectSameUpToRounding(folded.body, raw.body, "body");
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The per-kind series for heart-rate variability and blood oxygen follows the
|
||||
* same dense rule as pulse and glucose.
|
||||
*
|
||||
* Both used to read every raw row for any window up to 3650 days, and a ring
|
||||
* or a watch that writes them every minute overnight, or all day, makes that
|
||||
* the same unbounded read pulse had before #1023. Beyond 90 days they now fold
|
||||
* per local day; within 90 days a window of more than 10 000 readings folds
|
||||
* per local hour. A sparse account inside 90 days keeps its raw readings,
|
||||
* byte for byte, and a bucket has the shape of a reading of its kind (no
|
||||
* `valueMin` / `valueMax`, which only pulse carries).
|
||||
*
|
||||
* The doctor report applies the same rule to both: their sections are a
|
||||
* statistics row and a series, so a dense window takes the per-day path pulse
|
||||
* already takes, with exact count, minimum, maximum and latest reading.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { latestReading } from "@/lib/fhir/resources/common";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "Europe/Berlin";
|
||||
const DENSE = "dense-hrv-spo2-owner";
|
||||
const SPARSE = "sparse-hrv-spo2-owner";
|
||||
const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000);
|
||||
/** Twelve days of one reading a minute: over the 10 000-row cap. */
|
||||
const DENSE_ROWS = 12 * 24 * 60;
|
||||
|
||||
const KINDS = [
|
||||
{ kind: "heartRateVariability", type: "HEART_RATE_VARIABILITY", unit: "ms" },
|
||||
{ kind: "oxygenSaturation", type: "OXYGEN_SATURATION", unit: "%" },
|
||||
] as const;
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
for (const id of [DENSE, SPARSE]) {
|
||||
await prisma.user.create({ data: { id, username: id, timezone: TZ } });
|
||||
invalidateUserTimezone(id);
|
||||
}
|
||||
for (const { type, unit } of KINDS) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT $4 || lpad(g::text, 7, '0'), $1, $5::measurement_type,
|
||||
CASE WHEN $5 = 'OXYGEN_SATURATION' THEN 92 + (g % 8) ELSE 30 + (g % 40) * 0.5 END,
|
||||
$6, 'GOOGLE_HEALTH'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, $3::int - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
DENSE_ROWS,
|
||||
type === "OXYGEN_SATURATION" ? "ds" : "dh",
|
||||
type,
|
||||
unit,
|
||||
);
|
||||
// A few readings a night for a month and a half.
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: 45 * 3 }, (_, i) => ({
|
||||
id: `sp-${type}-${i}`,
|
||||
userId: SPARSE,
|
||||
type,
|
||||
value: type === "OXYGEN_SATURATION" ? 94 + (i % 5) : 35 + (i % 17),
|
||||
unit,
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(
|
||||
NOW.getTime() - Math.floor(i / 3) * 86_400_000 - (i % 3) * 7_200_000,
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
}, 120_000);
|
||||
|
||||
async function series(userId: string, kind: string, days: number) {
|
||||
const s = await prisma.session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", s.id);
|
||||
const { GET } = await import("@/app/api/measurements/series/route");
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/measurements/series?kind=${kind}&days=${days}`,
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return (
|
||||
(await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<Record<string, unknown> & { id: string; value: number }>;
|
||||
stats: { count: number; mean: number; min: number; max: number };
|
||||
};
|
||||
}
|
||||
).data;
|
||||
}
|
||||
|
||||
describe.each(KINDS)("$kind series", ({ kind, type, unit }) => {
|
||||
it("sends a dense stream inside 90 days as hour buckets", async () => {
|
||||
const data = await series(DENSE, kind, 30);
|
||||
expect(data.unit).toBe(unit);
|
||||
expect(data.points.length).toBeLessThanOrEqual(12 * 24 + 2);
|
||||
expect(data.points.length).toBeGreaterThanOrEqual(12 * 24 - 2);
|
||||
expect(data.points.every((p) => p.id.startsWith("hour:"))).toBe(true);
|
||||
// A bucket has the shape of a reading of its kind.
|
||||
expect(
|
||||
data.points.every((p) => !("valueMin" in p) && !("valueMax" in p)),
|
||||
).toBe(true);
|
||||
// Statistics stay over every reading.
|
||||
const agg = await prisma.measurement.aggregate({
|
||||
where: {
|
||||
userId: DENSE,
|
||||
type,
|
||||
measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
_count: true,
|
||||
_min: { value: true },
|
||||
_max: { value: true },
|
||||
});
|
||||
expect(data.stats.count).toBe(agg._count);
|
||||
expect(data.stats.min).toBe(agg._min.value);
|
||||
expect(data.stats.max).toBe(agg._max.value);
|
||||
});
|
||||
|
||||
it("folds the long ranges per local day", async () => {
|
||||
const data = await series(SPARSE, kind, 3650);
|
||||
expect(data.points.length).toBeGreaterThan(40);
|
||||
expect(data.points.length).toBeLessThanOrEqual(46);
|
||||
expect(data.points.every((p) => p.id.startsWith("day:"))).toBe(true);
|
||||
expect(data.stats.count).toBe(45 * 3);
|
||||
});
|
||||
|
||||
it("keeps a sparse series inside 90 days raw", async () => {
|
||||
const data = await series(SPARSE, kind, 30);
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: SPARSE,
|
||||
type,
|
||||
measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
});
|
||||
expect(data.points).toEqual(
|
||||
raw.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(KINDS)("doctor report $type", ({ type }) => {
|
||||
async function report(userId: string, days: number) {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - days * 86_400_000);
|
||||
return collectDoctorReportData(
|
||||
userId,
|
||||
{ start, end, days },
|
||||
selectionFromLeaves([type]),
|
||||
);
|
||||
}
|
||||
|
||||
it("folds a dense window to one point per day, statistics exact", async () => {
|
||||
const data = await report(DENSE, 30);
|
||||
const points = data.measurements[type] ?? [];
|
||||
expect(points.length).toBeLessThanOrEqual(14);
|
||||
expect(points.length).toBeGreaterThanOrEqual(12);
|
||||
const agg = await prisma.measurement.aggregate({
|
||||
where: { userId: DENSE, type },
|
||||
_count: true,
|
||||
_avg: { value: true },
|
||||
_min: { value: true },
|
||||
_max: { value: true },
|
||||
});
|
||||
expect(data.stats[type]?.count).toBe(agg._count);
|
||||
expect(data.stats[type]?.min).toBe(agg._min.value);
|
||||
expect(data.stats[type]?.max).toBe(agg._max.value);
|
||||
expect(data.stats[type]?.avg).toBeCloseTo(agg._avg.value ?? 0, 9);
|
||||
const newest = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId: DENSE, type },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
expect(latestReading(data, type)).toEqual({
|
||||
value: newest.value,
|
||||
measuredAt: newest.measuredAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a sparse window raw", async () => {
|
||||
const data = await report(SPARSE, 30);
|
||||
const count = await prisma.measurement.count({
|
||||
where: {
|
||||
userId: SPARSE,
|
||||
type,
|
||||
measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
});
|
||||
expect(data.measurements[type]).toHaveLength(count);
|
||||
expect(data.stats[type]?.count).toBe(count);
|
||||
});
|
||||
|
||||
it("folds a window longer than 90 days per day", async () => {
|
||||
const data = await report(SPARSE, 120);
|
||||
expect(data.stats[type]?.count).toBe(45 * 3);
|
||||
expect((data.measurements[type] ?? []).length).toBeLessThanOrEqual(46);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* The targets page on a dense account: a glucose sensor reporting every five
|
||||
* minutes and a watch recording heart rate once a minute.
|
||||
*
|
||||
* `buildTargetsResponse` read a year of raw glucose readings to keep the
|
||||
* newest one per meal context, and thirty days of raw heart rate to derive one
|
||||
* resting figure per day. Both now fold in SQL. These cases pin that neither
|
||||
* stream is read as rows any more, and that the cards come out exactly as the
|
||||
* in-memory fold over the raw readings makes them, on a dense account and on
|
||||
* a sparse one with readings next to the local midnight.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse";
|
||||
import { buildTargetsResponse } from "@/lib/targets/build-response";
|
||||
import { buildGlucoseTargets } from "@/lib/targets/glucose-builder";
|
||||
import {
|
||||
foldGlucoseTargetRows,
|
||||
readGlucoseTargetSummaries,
|
||||
} from "@/lib/targets/glucose-read";
|
||||
import { buildVitalTargets } from "@/lib/targets/vitals-builder";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "America/New_York";
|
||||
const DENSE = "dense-targets-owner";
|
||||
const SPARSE = "sparse-targets-owner";
|
||||
const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000);
|
||||
const DAY = 86_400_000;
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
for (const id of [DENSE, SPARSE]) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
timezone: TZ,
|
||||
dateOfBirth: new Date("1980-05-01T00:00:00Z"),
|
||||
gender: "FEMALE",
|
||||
heightCm: 170,
|
||||
},
|
||||
});
|
||||
invalidateUserTimezone(id);
|
||||
}
|
||||
// 200 days of a sensor every five minutes, untagged, with every 37th
|
||||
// reading tagged fasting so two contexts are present.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, glucose_context, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT 'dg' || lpad(g::text, 7, '0'), $1, 'BLOOD_GLUCOSE'::measurement_type,
|
||||
70 + (g % 110) + (g % 3) * 0.25, 'mg/dL', 'NIGHTSCOUT'::measurement_source,
|
||||
CASE WHEN g % 37 = 0 THEN 'FASTING'::glucose_context ELSE NULL END,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '5 minutes'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, 200 * 288 - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
);
|
||||
// Ten days of heart rate once a minute.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT 'dp' || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type,
|
||||
55 + (g % 50), 'bpm', 'GOOGLE_HEALTH'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, 10 * 24 * 60 - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
);
|
||||
|
||||
// Sparse: fingerstick readings in three contexts, a few a day, some older
|
||||
// than thirty days, some within minutes of the New York midnight; a cuff a
|
||||
// few times a day; a resting heart rate on alternate days.
|
||||
const contexts = ["FASTING", "POSTPRANDIAL", null] as const;
|
||||
const sparse: Array<{
|
||||
type: "BLOOD_GLUCOSE" | "PULSE" | "RESTING_HEART_RATE";
|
||||
value: number;
|
||||
at: Date;
|
||||
ctx?: (typeof contexts)[number];
|
||||
}> = [];
|
||||
for (let day = 0; day < 60; day++) {
|
||||
for (let i = 0; i < 1 + (day % 3); i++) {
|
||||
sparse.push({
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 85 + ((day * 13 + i * 29) % 90) + (i % 2 ? 0.5 : 0),
|
||||
at: new Date(NOW.getTime() - day * DAY - (i * 311 + 7) * 60_000),
|
||||
ctx: contexts[(day + i) % 3],
|
||||
});
|
||||
}
|
||||
if (day < 25) {
|
||||
for (let i = 0; i < 2 + (day % 4); i++) {
|
||||
sparse.push({
|
||||
type: "PULSE",
|
||||
value: 54 + ((day * 7 + i * 11) % 40),
|
||||
at: new Date(NOW.getTime() - day * DAY - (i * 173 + 3) * 60_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (day % 2 === 0 && day < 30) {
|
||||
sparse.push({
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 58 + (day % 6),
|
||||
at: new Date(NOW.getTime() - day * DAY - 4 * 3_600_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.measurement.createMany({
|
||||
data: sparse.map((r, i) => ({
|
||||
id: `st${String(i).padStart(5, "0")}`,
|
||||
userId: SPARSE,
|
||||
type: r.type,
|
||||
value: r.value,
|
||||
unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "bpm",
|
||||
source: "MANUAL" as const,
|
||||
glucoseContext: r.ctx ?? null,
|
||||
measuredAt: r.at,
|
||||
})),
|
||||
});
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
}, 180_000);
|
||||
|
||||
function readsDenseRows(args: unknown): boolean {
|
||||
const type = (args as { where?: { type?: unknown } } | undefined)?.where
|
||||
?.type;
|
||||
if (type === "BLOOD_GLUCOSE" || type === "PULSE") return true;
|
||||
const list = (type as { in?: unknown } | undefined)?.in;
|
||||
return (
|
||||
Array.isArray(list) &&
|
||||
(list.includes("PULSE") || list.includes("BLOOD_GLUCOSE"))
|
||||
);
|
||||
}
|
||||
|
||||
describe("targets on a dense account", () => {
|
||||
it("never reads the glucose or heart-rate stream as rows", async () => {
|
||||
const spy = vi.spyOn(prisma.measurement, "findMany");
|
||||
try {
|
||||
const started = performance.now();
|
||||
const res = await buildTargetsResponse({ id: DENSE, timezone: TZ });
|
||||
const elapsed = performance.now() - started;
|
||||
process.stderr.write(
|
||||
`[dense-targets] buildTargetsResponse ${elapsed.toFixed(0)} ms\n`,
|
||||
);
|
||||
expect(res.targets.length).toBeGreaterThan(0);
|
||||
const denseReads = spy.mock.calls.filter(([args]) =>
|
||||
readsDenseRows(args),
|
||||
);
|
||||
expect(denseReads).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("targets fold equals the raw-reading fold", () => {
|
||||
for (const userId of [SPARSE, DENSE]) {
|
||||
it(`glucose summaries match the in-memory fold (${userId})`, async () => {
|
||||
const since = new Date(NOW.getTime() - 365 * DAY);
|
||||
const recentSince = new Date(NOW.getTime() - 30 * DAY);
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const expected = foldGlucoseTargetRows(raw, recentSince, TZ);
|
||||
const actual = await readGlucoseTargetSummaries({
|
||||
userId,
|
||||
since,
|
||||
recentSince,
|
||||
timeZone: TZ,
|
||||
});
|
||||
const byCtx = <T extends { glucoseContext: string | null }>(xs: T[]) =>
|
||||
[...xs].sort((a, b) =>
|
||||
(a.glucoseContext ?? "").localeCompare(b.glucoseContext ?? ""),
|
||||
);
|
||||
expect(actual.length).toBeGreaterThanOrEqual(2);
|
||||
const exp = byCtx(expected);
|
||||
const act = byCtx(actual);
|
||||
expect(act.map((s) => s.glucoseContext)).toEqual(
|
||||
exp.map((s) => s.glucoseContext),
|
||||
);
|
||||
for (let i = 0; i < exp.length; i++) {
|
||||
expect(act[i].latest).toBe(exp[i].latest);
|
||||
expect(act[i].latestAt).toEqual(exp[i].latestAt);
|
||||
expect(act[i].recentCount).toBe(exp[i].recentCount);
|
||||
expect([...act[i].recentByDay.keys()].sort()).toEqual(
|
||||
[...exp[i].recentByDay.keys()].sort(),
|
||||
);
|
||||
for (const [day, bucket] of exp[i].recentByDay) {
|
||||
const got = act[i].recentByDay.get(day)!;
|
||||
expect(got.count, day).toBe(bucket.count);
|
||||
expect(got.sum, day).toBeCloseTo(bucket.sum, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it(`glucose and resting-pulse cards equal the raw path (${userId})`, async () => {
|
||||
const res = await buildTargetsResponse({ id: userId, timezone: TZ });
|
||||
// The builder takes its own clock after the reads; rebuild the raw
|
||||
// expectation against the same thirty-day cut.
|
||||
const now = new Date();
|
||||
const recentSince = new Date(now.getTime() - 30 * DAY);
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: new Date(now.getTime() - 365 * DAY) },
|
||||
},
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const expectedGlucose = buildGlucoseTargets({
|
||||
summaries: foldGlucoseTargetRows(raw, recentSince, TZ),
|
||||
profile: {
|
||||
heightCm: 170,
|
||||
dateOfBirth: new Date("1980-05-01T00:00:00Z"),
|
||||
gender: "FEMALE",
|
||||
glucoseUnit: null,
|
||||
hasDiabetes: false,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
timezone: TZ,
|
||||
now,
|
||||
});
|
||||
const glucose = res.targets.filter((t) =>
|
||||
t.type.startsWith("BLOOD_GLUCOSE_"),
|
||||
);
|
||||
expect(glucose.length).toBe(expectedGlucose.length);
|
||||
for (let i = 0; i < glucose.length; i++) {
|
||||
const { average30, ...rest } = glucose[i];
|
||||
const { average30: expAvg, ...expRest } = expectedGlucose[i];
|
||||
expect(rest).toEqual(expRest);
|
||||
if (expAvg === null) expect(average30).toBeNull();
|
||||
else expect(average30).toBeCloseTo(expAvg, 6);
|
||||
}
|
||||
|
||||
const recent = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["PULSE", "RESTING_HEART_RATE"] },
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: recentSince },
|
||||
},
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
const expectedPulse = buildVitalTargets({
|
||||
recentMeasurements: recent.filter(
|
||||
(r) => r.type === "RESTING_HEART_RATE",
|
||||
),
|
||||
restingPulseProxy: deriveRestingProxyFromPulse(
|
||||
recent.filter((r) => r.type === "PULSE"),
|
||||
(d) => userDayKey(d, TZ),
|
||||
),
|
||||
latestByType: {},
|
||||
average30ByType: {},
|
||||
heightCm: 170,
|
||||
age: res.profile.age,
|
||||
gender: "FEMALE",
|
||||
timezone: TZ,
|
||||
now,
|
||||
weightTargetOverride: null,
|
||||
}).targets.find((t) => t.type === "PULSE");
|
||||
const pulse = res.targets.find((t) => t.type === "PULSE");
|
||||
expect(pulse?.current).not.toBeNull();
|
||||
expect(pulse).toEqual(expectedPulse);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -56,7 +56,7 @@ interface ListEnvelope {
|
||||
napCount?: number;
|
||||
awakenings?: number;
|
||||
}>;
|
||||
meta: { total: number; groupBy?: string };
|
||||
meta: { total: number; groupBy?: string; windowFrom?: string };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,4 +160,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => {
|
||||
const stages = body.data.measurements.map((m) => m.sleepStage).sort();
|
||||
expect(stages).toEqual(["CORE", "DEEP", "REM"]);
|
||||
});
|
||||
|
||||
it("reads at most a year ending at `to`, however early `from` is", async () => {
|
||||
// An explicit early `from` used to walk every per-stage row back to it.
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedSession("sleep-window-user");
|
||||
const day = 86_400_000;
|
||||
const morning = new Date();
|
||||
morning.setUTCHours(4, 0, 0, 0);
|
||||
// One night of a single CORE stage every day for two years.
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: 730 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION" as const,
|
||||
value: 420,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH" as const,
|
||||
measuredAt: new Date(morning.getTime() - i * day),
|
||||
externalId: `uuid-night-${i}`,
|
||||
sleepStage: "CORE" as const,
|
||||
})),
|
||||
});
|
||||
const spy = vi.spyOn(prisma.measurement, "findMany");
|
||||
const from = new Date(morning.getTime() - 800 * day).toISOString();
|
||||
const res = await callList(
|
||||
`type=SLEEP_DURATION&from=${encodeURIComponent(from)}&limit=1000`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as ListEnvelope;
|
||||
expect(body.data.meta.total).toBeLessThanOrEqual(366);
|
||||
expect(body.data.meta.total).toBeGreaterThanOrEqual(364);
|
||||
expect(body.data.meta.windowFrom).toBeDefined();
|
||||
const windowFrom = Date.parse(body.data.meta.windowFrom!);
|
||||
expect(Date.now() - windowFrom).toBeLessThan(366 * day);
|
||||
expect(Date.now() - windowFrom).toBeGreaterThan(364 * day);
|
||||
// The read itself was bounded, not just the answer.
|
||||
const read = spy.mock.calls
|
||||
.map(([args]) => args as { where?: { measuredAt?: { gte?: Date } } })
|
||||
.find((args) => args?.where?.measuredAt?.gte);
|
||||
expect(read?.where?.measuredAt?.gte?.getTime()).toBe(windowFrom);
|
||||
|
||||
// Older nights are reached by moving `to` back.
|
||||
const to = new Date(morning.getTime() - 400 * day).toISOString();
|
||||
const older = await callList(
|
||||
`type=SLEEP_DURATION&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=1000`,
|
||||
);
|
||||
const olderBody = (await older.json()) as ListEnvelope;
|
||||
expect(olderBody.data.meta.total).toBeGreaterThanOrEqual(329);
|
||||
expect(olderBody.data.meta.total).toBeLessThanOrEqual(331);
|
||||
|
||||
// A window inside the year is untouched and carries no marker.
|
||||
const recent = await callList(
|
||||
`type=SLEEP_DURATION&from=${encodeURIComponent(new Date(morning.getTime() - 10.5 * day).toISOString())}`,
|
||||
);
|
||||
const recentBody = (await recent.json()) as ListEnvelope;
|
||||
expect(recentBody.data.meta.total).toBe(11);
|
||||
expect(recentBody.data.meta.windowFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user