diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9048aa07d..152d56968 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,45 @@
# Changelog
+## [1.40.1] — 2026-10-04
+
+Restore opens older encrypted values again; faster pages on a long-used
+server; a day of pulse is the mean of its hours; Apple Health imports leave
+out HealthLog's own samples; more memory for the app by default.
+
+### Fixed
+
+- **Restore key probe.** A legacy unprefixed ciphertext was read as a base64
+ string and refused a restore the key opens; legacy values open as strings,
+ probes come only from sections that count, binary probes use their AAD.
+ The drill checks every account of the newest date.
+- **Daily pulse (#1107, migration 0372).** A day is the mean of its local
+ hours' means and a window the mean of its days, in every reader; stored
+ pulse rollups are recomputed. Count, min and max stay over every reading.
+- **Apple Health import (#1109).** Records carrying HealthLog's origin
+ marker or a HealthLog id are left out and counted; cycle samples too.
+- **Blood pressure series.** One source per day for both values.
+- **Backups.** Heartbeat on both backup jobs; per-account attempts recorded
+ (migration 0373), interrupted accounts go last; the preview is built from
+ the writer's counts.
+- **Document previews.** A permanent render failure is recorded (migration 0374) instead of re-queued at every start.
+- **Umami.** Events from a tracker built with a custom endpoint are proxied
+ to the configured Umami host.
+- **Display.** Rest Mode shows its start date; the favicon is transparent;
+ the sidebar no longer flashes scrollbars while it expands.
+
+### Changed
+
+- **Performance.** One Prisma client per process instead of one per bundle;
+ the rollup coverage probe uses a loose index scan and concurrent readers
+ share one probe; timezone offsets are memoised per hour; chart text is
+ measured outside `
`; Insights mounts lower sections near the
+ viewport; a nav prefetch is not re-sent on mount; MCP single-metric reads
+ build only the requested metric; the backup schema loads on first use.
+- **Lab list (#1057).** The range bar and name use a wide card.
+- **Compose.** `APP_MEMORY_LIMIT` defaults to `1536m` (792 MB heap).
+- Dependencies: next 16.3.8, eslint-config-next 16.3.8, zod-openapi 6.0.2,
+ testcontainers 12.2.0, p-limit 7.3.3.
+
## [1.40.0] — 2026-10-04
Back up the encryption key and refuse a key that cannot open the data;
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 0537d2cc1..d1d0675ff 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.40.0
+ version: 1.40.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index 88b88f0eb..c55033afd 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -13,7 +13,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
- hold a 1.5 GB upload in `/tmp` while it parses.
+ hold a 1.5 GB upload in `/tmp` while it parses. The app container is
+ capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its
+ heap at about 792 MB). On a 2 GB host that leaves too little for
+ Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env`
+ (a heap of about 524 MB); a long-used account then runs closer to
+ that heap, and the weekly backup with it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md
index 8bde98d1b..6776d44a3 100644
--- a/docs/self-hosting/scaling.md
+++ b/docs/self-hosting/scaling.md
@@ -163,7 +163,11 @@ the knob as `DB_CONNECTION_LIMIT`, baked into `DATABASE_URL` as
`connection_limit`; `DATABASE_POOL_MAX` remains a working explicit
override. Resolution order: `DB_CONNECTION_LIMIT`, then
`DATABASE_POOL_MAX`, then the `connection_limit` URL parameter, then
-the default of 20.
+the default of 20. In the bundled single-container setup
+(`HEALTHLOG_PROCESS_TYPE=all`) the web requests and the background
+worker run in one process and share that one Prisma pool, so a budget
+sized for the web traffic alone leaves the worker's jobs queueing for
+the same connections.
Plan total Postgres slots as **container_count × budget**.
A stock Postgres 16 container ships with `max_connections = 100`, so
diff --git a/e2e/ai-optional-account.spec.ts b/e2e/ai-optional-account.spec.ts
index 3e7f138fe..6a27a80cd 100644
--- a/e2e/ai-optional-account.spec.ts
+++ b/e2e/ai-optional-account.spec.ts
@@ -25,6 +25,7 @@ import {
seedStoredBriefing,
} from "./setup/ai-optional-fixture";
import { expect, test } from "./setup/test";
+import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -36,12 +37,18 @@ test.afterAll(async () => {
await resetAiOptionalAccount();
});
+/**
+ * The overview with every section mounted. Sections past the first three
+ * render as an empty sentinel until they near the viewport, so a count of
+ * zero taken before they mount would pass whatever they paint.
+ */
async function openOverview(page: Page): Promise {
await page.goto("/insights");
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
+ await revealDeferredSectionsSettled(page);
}
test("J5: withdrawing AI consent deletes the notes written under it", async ({
@@ -80,12 +87,12 @@ test("J4: the Coach memory stays readable and deletable with the Coach hidden",
const title = `Stored conversation ${Date.now()}`;
await seedHiddenCoachWithConversation(title);
- // Hide Coach takes the Coach page and its launcher away...
- await page.goto("/insights");
- await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
- timeout: 20_000,
- });
+ // Hide Coach takes the Coach page, its launcher and every per-card
+ // hand-off away...
+ await openOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
+ await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0);
+ await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0);
// ...and Settings, AI lists what the Coach stored.
await page.goto("/settings/ai");
diff --git a/e2e/ai-optional-overview.spec.ts b/e2e/ai-optional-overview.spec.ts
index dac4533bd..ba0158dd0 100644
--- a/e2e/ai-optional-overview.spec.ts
+++ b/e2e/ai-optional-overview.spec.ts
@@ -21,9 +21,19 @@ import type { Page, Request, Response } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockUnavailable, serveAiBlock } from "./setup/ai-capabilities";
import { expect, test } from "./setup/test";
+import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: STORAGE_STATE_PATH });
+/**
+ * Mount every overview section, then let their reads land. Sections past the
+ * first three render as an empty sentinel until they near the viewport, so a
+ * count of zero taken before this would pass whatever those sections paint.
+ */
+async function mountWholeOverview(page: Page): Promise {
+ await revealDeferredSectionsSettled(page);
+}
+
/** Routes that call a model or serve model-written text. */
const AI_ROUTE = new RegExp(
[
@@ -89,6 +99,7 @@ test.describe("J1: AI switched off by the operator", () => {
);
// Let every section's reads land before judging what painted.
await page.waitForLoadState("networkidle");
+ await mountWholeOverview(page);
// A read refused for want of AI paints one of these on the overview:
// the section boundary, the error card, or the dense error row.
@@ -137,6 +148,7 @@ test.describe("J3: no AI provider", () => {
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
+ await mountWholeOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0);
// The per-card Coach hand-offs paint only while the Coach is available.
@@ -163,6 +175,7 @@ test.describe("J3: no AI provider", () => {
{ timeout: 20_000 },
);
await page.waitForLoadState("networkidle");
+ await mountWholeOverview(page);
await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0);
expect(network.aiRequests).toEqual([]);
diff --git a/e2e/utils/deferred-sections.ts b/e2e/utils/deferred-sections.ts
index 0ca4748ea..247ab9d80 100644
--- a/e2e/utils/deferred-sections.ts
+++ b/e2e/utils/deferred-sections.ts
@@ -36,3 +36,40 @@ export async function revealDeferredSections(page: Page): Promise {
.toBe(0);
await page.evaluate(() => document.querySelector("main")?.scrollTo(0, 0));
}
+
+/**
+ * `revealDeferredSections`, then wait until the reads the newly mounted
+ * sections fired have landed: no request in flight for 750 ms. A
+ * `waitForLoadState("networkidle")` cannot do this: the page reached that
+ * state once already, so it resolves at once, and a count of zero taken then
+ * passes before a deferred section has painted anything.
+ */
+export async function revealDeferredSectionsSettled(page: Page): Promise {
+ let inFlight = 0;
+ const started = () => {
+ inFlight += 1;
+ };
+ const ended = () => {
+ inFlight -= 1;
+ };
+ page.on("request", started);
+ page.on("requestfinished", ended);
+ page.on("requestfailed", ended);
+ try {
+ await revealDeferredSections(page);
+ let quietSince = Date.now();
+ await expect
+ .poll(
+ () => {
+ if (inFlight > 0) quietSince = Date.now();
+ return Date.now() - quietSince >= 750;
+ },
+ { timeout: 20_000, intervals: [100] },
+ )
+ .toBe(true);
+ } finally {
+ page.off("request", started);
+ page.off("requestfinished", ended);
+ page.off("requestfailed", ended);
+ }
+}
diff --git a/package.json b/package.json
index 74f0243db..32ef4824a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.40.0",
+ "version": "1.40.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -80,11 +80,11 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.44.0",
"mmdb-lib": "^3.0.2",
- "next": "16.3.6",
+ "next": "16.3.8",
"next-themes": "^0.4.6",
"nodemailer": "10.0.9",
"otpauth": "^9.5.1",
- "p-limit": "^7.3.1",
+ "p-limit": "^7.3.3",
"pg": "^8.23.0",
"pg-boss": "^12.34.0",
"prisma": "^7.10.0",
@@ -104,7 +104,7 @@
"web-push": "^3.6.7",
"yaml": "^2.8.4",
"zod": "^4.4.3",
- "zod-openapi": "^6.0.1",
+ "zod-openapi": "^6.0.2",
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
@@ -124,7 +124,7 @@
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
- "eslint-config-next": "16.3.6",
+ "eslint-config-next": "16.3.8",
"knip": "^6.32.3",
"lefthook": "^2.1.12",
"pdf-parse": "^2.4.5",
@@ -133,7 +133,7 @@
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.16.1",
"tailwindcss": "^4",
- "testcontainers": "^12.1.0",
+ "testcontainers": "^12.2.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.11"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 83e106bab..9721f633b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,8 +119,8 @@ importers:
specifier: ^3.0.2
version: 3.0.2
next:
- specifier: 16.3.6
- version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ specifier: 16.3.8
+ version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -131,8 +131,8 @@ importers:
specifier: ^9.5.1
version: 9.5.1
p-limit:
- specifier: ^7.3.1
- version: 7.3.1
+ specifier: ^7.3.3
+ version: 7.3.3
pg:
specifier: ^8.23.0
version: 8.23.0
@@ -191,8 +191,8 @@ importers:
specifier: ^4.4.3
version: 4.4.3
zod-openapi:
- specifier: ^6.0.1
- version: 6.0.1(zod@4.4.3)
+ specifier: ^6.0.2
+ version: 6.0.2(zod@4.4.3)
zxcvbn-typescript:
specifier: ^5.0.1
version: 5.0.1
@@ -246,8 +246,8 @@ importers:
specifier: ^9
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-config-next:
- specifier: 16.3.6
- version: 16.3.6(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ specifier: 16.3.8
+ version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
knip:
specifier: ^6.32.3
version: 6.32.3
@@ -273,8 +273,8 @@ importers:
specifier: ^4
version: 4.3.3
testcontainers:
- specifier: ^12.1.0
- version: 12.1.0(supports-color@7.2.0)
+ specifier: ^12.2.0
+ version: 12.2.0(supports-color@7.2.0)
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -492,6 +492,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.29.9':
+ resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-syntax-jsx@7.29.7':
resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
engines: {node: '>=6.9.0'}
@@ -809,160 +814,160 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.35.4':
- resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
+ '@img/sharp-darwin-arm64@0.35.5':
+ resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.35.4':
- resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
+ '@img/sharp-darwin-x64@0.35.5':
+ resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-freebsd-wasm32@0.35.4':
- resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
+ '@img/sharp-freebsd-wasm32@0.35.5':
+ resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
engines: {node: '>=20.9.0'}
os: [freebsd]
- '@img/sharp-libvips-darwin-arm64@1.3.3':
- resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
+ '@img/sharp-libvips-darwin-arm64@1.3.4':
+ resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.3.3':
- resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
+ '@img/sharp-libvips-darwin-x64@1.3.4':
+ resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.3.3':
- resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
+ '@img/sharp-libvips-linux-arm64@1.3.4':
+ resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.3.3':
- resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
+ '@img/sharp-libvips-linux-arm@1.3.4':
+ resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.3.3':
- resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
+ '@img/sharp-libvips-linux-ppc64@1.3.4':
+ resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.3.3':
- resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
+ '@img/sharp-libvips-linux-riscv64@1.3.4':
+ resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.3.3':
- resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
+ '@img/sharp-libvips-linux-s390x@1.3.4':
+ resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.3.3':
- resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
+ '@img/sharp-libvips-linux-x64@1.3.4':
+ resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
- resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
+ resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.3.3':
- resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.4':
+ resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.35.4':
- resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
+ '@img/sharp-linux-arm64@0.35.5':
+ resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.35.4':
- resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
+ '@img/sharp-linux-arm@0.35.5':
+ resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.35.4':
- resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
+ '@img/sharp-linux-ppc64@0.35.5':
+ resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.35.4':
- resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
+ '@img/sharp-linux-riscv64@0.35.5':
+ resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.35.4':
- resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
+ '@img/sharp-linux-s390x@0.35.5':
+ resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.35.4':
- resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
+ '@img/sharp-linux-x64@0.35.5':
+ resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.35.4':
- resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
+ '@img/sharp-linuxmusl-arm64@0.35.5':
+ resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.35.4':
- resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
+ '@img/sharp-linuxmusl-x64@0.35.5':
+ resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.35.4':
- resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
+ '@img/sharp-wasm32@0.35.5':
+ resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
engines: {node: '>=20.9.0'}
- '@img/sharp-webcontainers-wasm32@0.35.4':
- resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
+ '@img/sharp-webcontainers-wasm32@0.35.5':
+ resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.35.4':
- resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
+ '@img/sharp-win32-arm64@0.35.5':
+ resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.35.4':
- resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
+ '@img/sharp-win32-ia32@0.35.5':
+ resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.35.4':
- resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
+ '@img/sharp-win32-x64@0.35.5':
+ resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -1206,60 +1211,60 @@ packages:
'@next/bundle-analyzer@16.3.3':
resolution: {integrity: sha512-pJFJrjUnxac2RBEgRIhXqoRkaSxMGQEGvvLhPKA2h1hXAzq60DzRj8Vp8+um2cMuHRuLcwRFYNlcuS74/hZ7AQ==}
- '@next/env@16.3.6':
- resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==}
+ '@next/env@16.3.8':
+ resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
- '@next/eslint-plugin-next@16.3.6':
- resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==}
+ '@next/eslint-plugin-next@16.3.8':
+ resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
- '@next/swc-darwin-arm64@16.3.6':
- resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==}
+ '@next/swc-darwin-arm64@16.3.8':
+ resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.3.6':
- resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==}
+ '@next/swc-darwin-x64@16.3.8':
+ resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.3.6':
- resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==}
+ '@next/swc-linux-arm64-gnu@16.3.8':
+ resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.3.6':
- resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==}
+ '@next/swc-linux-arm64-musl@16.3.8':
+ resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.3.6':
- resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==}
+ '@next/swc-linux-x64-gnu@16.3.8':
+ resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.3.6':
- resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==}
+ '@next/swc-linux-x64-musl@16.3.8':
+ resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.3.6':
- resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==}
+ '@next/swc-win32-arm64-msvc@16.3.8':
+ resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.3.6':
- resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==}
+ '@next/swc-win32-x64-msvc@16.3.8':
+ resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2970,8 +2975,8 @@ packages:
'@types/ssh2@0.5.52':
resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==}
- '@types/ssh2@1.15.5':
- resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
+ '@types/ssh2@1.15.6':
+ resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@@ -2988,63 +2993,63 @@ packages:
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
- '@typescript-eslint/eslint-plugin@8.70.0':
- resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==}
+ '@typescript-eslint/eslint-plugin@8.71.0':
+ resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.70.0
+ '@typescript-eslint/parser': ^8.71.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.70.0':
- resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
+ '@typescript-eslint/parser@8.71.0':
+ resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.70.0':
- resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
+ '@typescript-eslint/project-service@8.71.0':
+ resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.70.0':
- resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
+ '@typescript-eslint/scope-manager@8.71.0':
+ resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.70.0':
- resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
+ '@typescript-eslint/tsconfig-utils@8.71.0':
+ resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.70.0':
- resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==}
+ '@typescript-eslint/type-utils@8.71.0':
+ resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.70.0':
- resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
+ '@typescript-eslint/types@8.71.0':
+ resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.70.0':
- resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
+ '@typescript-eslint/typescript-estree@8.71.0':
+ resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.70.0':
- resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==}
+ '@typescript-eslint/utils@8.71.0':
+ resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.70.0':
- resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
+ '@typescript-eslint/visitor-keys@8.71.0':
+ resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -3444,8 +3449,8 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
- b4a@1.8.1:
- resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
+ b4a@1.9.0:
+ resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
@@ -3459,16 +3464,16 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- bare-events@2.9.1:
- resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
+ bare-events@2.9.2:
+ resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
- bare-fs@4.8.0:
- resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==}
+ bare-fs@4.8.2:
+ resolution: {integrity: sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w==}
engines: {bare: '>=1.28.0'}
peerDependencies:
bare-buffer: '*'
@@ -3476,11 +3481,11 @@ packages:
bare-buffer:
optional: true
- bare-path@3.1.1:
- resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==}
+ bare-path@3.1.2:
+ resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==}
- bare-stream@2.13.3:
- resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
+ bare-stream@2.13.4:
+ resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
@@ -3493,8 +3498,8 @@ packages:
bare-events:
optional: true
- bare-url@2.5.2:
- resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==}
+ bare-url@2.5.4:
+ resolution: {integrity: sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
@@ -3508,6 +3513,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ baseline-browser-mapping@2.11.26:
+ resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
@@ -3612,6 +3622,9 @@ packages:
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
+ caniuse-lite@1.0.30001814:
+ resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==}
+
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
@@ -3966,8 +3979,8 @@ packages:
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
- docker-compose@1.4.2:
- resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==}
+ docker-compose@1.5.0:
+ resolution: {integrity: sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==}
engines: {node: '>= 6.0.0'}
docker-modem@5.0.7:
@@ -4110,8 +4123,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
- eslint-config-next@16.3.6:
- resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==}
+ eslint-config-next@16.3.8:
+ resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -4633,8 +4646,8 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
- ignore@7.0.9:
- resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==}
+ ignore@7.0.11:
+ resolution: {integrity: sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==}
engines: {node: '>= 4'}
immer@11.1.15:
@@ -5330,14 +5343,19 @@ packages:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
engines: {node: '>=8.0.0'}
- nan@2.28.0:
- resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
+ nan@2.29.0:
+ resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@3.3.19:
+ resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -5356,8 +5374,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.3.6:
- resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==}
+ next@16.3.8:
+ resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5524,8 +5542,8 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
- p-limit@7.3.1:
- resolution: {integrity: sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==}
+ p-limit@7.3.3:
+ resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==}
engines: {node: '>=20'}
p-locate@3.0.0:
@@ -5845,8 +5863,8 @@ packages:
resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==}
engines: {node: '>=18'}
- protobufjs@7.6.5:
- resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
+ protobufjs@7.6.6:
+ resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==}
engines: {node: '>=12.0.0'}
proxy-addr@2.0.7:
@@ -6195,8 +6213,8 @@ packages:
engines: {node: '>=20.18.1'}
hasBin: true
- sharp@0.35.4:
- resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
+ sharp@0.35.5:
+ resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
@@ -6263,6 +6281,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
@@ -6316,8 +6338,8 @@ packages:
stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
- streamx@2.28.0:
- resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
+ streamx@2.28.1:
+ resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -6450,8 +6472,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
- tar-stream@3.2.0:
- resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
+ tar-stream@3.2.1:
+ resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
@@ -6465,8 +6487,8 @@ packages:
tesseract.js@7.0.0:
resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==}
- testcontainers@12.1.0:
- resolution: {integrity: sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==}
+ testcontainers@12.2.0:
+ resolution: {integrity: sha512-Pd0c2eU0DdIuK+Pb3c+vmBUdKjDLgxZmPecQGESFAKyn5LTqSaSw8l7htfMlLwabb/QsUILe6XkxMJw8Ly/ThA==}
engines: {node: '>= 22.22'}
text-decoder@1.2.7:
@@ -6583,8 +6605,8 @@ packages:
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'}
- typescript-eslint@8.70.0:
- resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==}
+ typescript-eslint@8.71.0:
+ resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -6820,8 +6842,8 @@ packages:
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
- which-typed-array@1.1.23:
- resolution: {integrity: sha512-JMh8aK+1B/0bk/YNupICmH5MgCq6yNLKYQUfsZtSDLLCCmxUkHjUY+oOlIa90lO7lHN1woAfpenLdlKpXy9o+A==}
+ which-typed-array@1.1.24:
+ resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==}
engines: {node: '>= 0.4'}
which@2.0.2:
@@ -6898,6 +6920,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yaml@2.9.1:
+ resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
@@ -6940,8 +6967,8 @@ packages:
zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
- zod-openapi@6.0.1:
- resolution: {integrity: sha512-ITUfaevP/JcrsOavyVHFQHz788F7YcmN8wFYRZk20UM16BUafdUzxq8ZM4ahyTv3c6ESvY9ywA+MwLXzIvVVAw==}
+ zod-openapi@6.0.2:
+ resolution: {integrity: sha512-SwC5Ppi2ov8WimzNWP/XK95VqPyLDzxPYj10HgNPzhZH9gO7zx0sxdYL9eq7wOADHx1JddOApjhYDmMWQvnVnw==}
engines: {node: '>=22.14.0'}
peerDependencies:
zod: ^4.0.0
@@ -7232,7 +7259,7 @@ snapshots:
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
- '@babel/parser': 7.29.8
+ '@babel/parser': 7.29.9
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
@@ -7247,7 +7274,7 @@ snapshots:
'@babel/generator@7.29.8':
dependencies:
- '@babel/parser': 7.29.8
+ '@babel/parser': 7.29.9
'@babel/types': 7.29.8
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@@ -7340,6 +7367,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
+ '@babel/parser@7.29.9':
+ dependencies:
+ '@babel/types': 7.29.8
+
'@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
@@ -7385,7 +7416,7 @@ snapshots:
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
- '@babel/parser': 7.29.8
+ '@babel/parser': 7.29.9
'@babel/types': 7.29.8
'@babel/traverse@7.29.8(supports-color@7.2.0)':
@@ -7393,7 +7424,7 @@ snapshots:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-globals': 7.29.7
- '@babel/parser': 7.29.8
+ '@babel/parser': 7.29.9
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@7.2.0)
@@ -7597,14 +7628,14 @@ snapshots:
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
- protobufjs: 7.6.5
+ protobufjs: 7.6.6
yargs: 17.7.3
'@grpc/proto-loader@0.8.1':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
- protobufjs: 7.6.5
+ protobufjs: 7.6.6
yargs: 17.7.3
'@hexagon/base64@1.1.28': {}
@@ -7644,108 +7675,108 @@ snapshots:
'@img/colour@1.1.0':
optional: true
- '@img/sharp-darwin-arm64@0.35.4':
+ '@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.3.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.4
optional: true
- '@img/sharp-darwin-x64@0.35.4':
+ '@img/sharp-darwin-x64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.3.3
+ '@img/sharp-libvips-darwin-x64': 1.3.4
optional: true
- '@img/sharp-freebsd-wasm32@0.35.4':
+ '@img/sharp-freebsd-wasm32@0.35.5':
dependencies:
- '@img/sharp-wasm32': 0.35.4
+ '@img/sharp-wasm32': 0.35.5
optional: true
- '@img/sharp-libvips-darwin-arm64@1.3.3':
+ '@img/sharp-libvips-darwin-arm64@1.3.4':
optional: true
- '@img/sharp-libvips-darwin-x64@1.3.3':
+ '@img/sharp-libvips-darwin-x64@1.3.4':
optional: true
- '@img/sharp-libvips-linux-arm64@1.3.3':
+ '@img/sharp-libvips-linux-arm64@1.3.4':
optional: true
- '@img/sharp-libvips-linux-arm@1.3.3':
+ '@img/sharp-libvips-linux-arm@1.3.4':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.3.3':
+ '@img/sharp-libvips-linux-ppc64@1.3.4':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.3.3':
+ '@img/sharp-libvips-linux-riscv64@1.3.4':
optional: true
- '@img/sharp-libvips-linux-s390x@1.3.3':
+ '@img/sharp-libvips-linux-s390x@1.3.4':
optional: true
- '@img/sharp-libvips-linux-x64@1.3.3':
+ '@img/sharp-libvips-linux-x64@1.3.4':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.3.3':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.4':
optional: true
- '@img/sharp-linux-arm64@0.35.4':
+ '@img/sharp-linux-arm64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.3.3
+ '@img/sharp-libvips-linux-arm64': 1.3.4
optional: true
- '@img/sharp-linux-arm@0.35.4':
+ '@img/sharp-linux-arm@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.3.3
+ '@img/sharp-libvips-linux-arm': 1.3.4
optional: true
- '@img/sharp-linux-ppc64@0.35.4':
+ '@img/sharp-linux-ppc64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.3.3
+ '@img/sharp-libvips-linux-ppc64': 1.3.4
optional: true
- '@img/sharp-linux-riscv64@0.35.4':
+ '@img/sharp-linux-riscv64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.3.3
+ '@img/sharp-libvips-linux-riscv64': 1.3.4
optional: true
- '@img/sharp-linux-s390x@0.35.4':
+ '@img/sharp-linux-s390x@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.3.3
+ '@img/sharp-libvips-linux-s390x': 1.3.4
optional: true
- '@img/sharp-linux-x64@0.35.4':
+ '@img/sharp-linux-x64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.3.3
+ '@img/sharp-libvips-linux-x64': 1.3.4
optional: true
- '@img/sharp-linuxmusl-arm64@0.35.4':
+ '@img/sharp-linuxmusl-arm64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
optional: true
- '@img/sharp-linuxmusl-x64@0.35.4':
+ '@img/sharp-linuxmusl-x64@0.35.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.4
optional: true
- '@img/sharp-wasm32@0.35.4':
+ '@img/sharp-wasm32@0.35.5':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
- '@img/sharp-webcontainers-wasm32@0.35.4':
+ '@img/sharp-webcontainers-wasm32@0.35.5':
dependencies:
- '@img/sharp-wasm32': 0.35.4
+ '@img/sharp-wasm32': 0.35.5
optional: true
- '@img/sharp-win32-arm64@0.35.4':
+ '@img/sharp-win32-arm64@0.35.5':
optional: true
- '@img/sharp-win32-ia32@0.35.4':
+ '@img/sharp-win32-ia32@0.35.5':
optional: true
- '@img/sharp-win32-x64@0.35.4':
+ '@img/sharp-win32-x64@0.35.5':
optional: true
'@inquirer/ansi@2.0.7':
@@ -7994,37 +8025,37 @@ snapshots:
- bufferutil
- utf-8-validate
- '@next/env@16.3.6': {}
+ '@next/env@16.3.8': {}
- '@next/eslint-plugin-next@16.3.6(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
+ '@next/eslint-plugin-next@16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
fast-glob: 3.3.1
transitivePeerDependencies:
- eslint
- '@next/swc-darwin-arm64@16.3.6':
+ '@next/swc-darwin-arm64@16.3.8':
optional: true
- '@next/swc-darwin-x64@16.3.6':
+ '@next/swc-darwin-x64@16.3.8':
optional: true
- '@next/swc-linux-arm64-gnu@16.3.6':
+ '@next/swc-linux-arm64-gnu@16.3.8':
optional: true
- '@next/swc-linux-arm64-musl@16.3.6':
+ '@next/swc-linux-arm64-musl@16.3.8':
optional: true
- '@next/swc-linux-x64-gnu@16.3.6':
+ '@next/swc-linux-x64-gnu@16.3.8':
optional: true
- '@next/swc-linux-x64-musl@16.3.6':
+ '@next/swc-linux-x64-musl@16.3.8':
optional: true
- '@next/swc-win32-arm64-msvc@16.3.6':
+ '@next/swc-win32-arm64-msvc@16.3.8':
optional: true
- '@next/swc-win32-x64-msvc@16.3.6':
+ '@next/swc-win32-x64-msvc@16.3.8':
optional: true
'@noble/hashes@2.2.0': {}
@@ -9537,7 +9568,7 @@ snapshots:
'@testcontainers/postgresql@12.1.0(supports-color@7.2.0)':
dependencies:
- testcontainers: 12.1.0(supports-color@7.2.0)
+ testcontainers: 12.2.0(supports-color@7.2.0)
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -9622,13 +9653,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 26.6.2
- '@types/ssh2': 1.15.5
+ '@types/ssh2': 1.15.6
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 26.6.2
- '@types/ssh2': 1.15.5
+ '@types/ssh2': 1.15.6
'@types/estree@1.0.8': {}
@@ -9689,7 +9720,7 @@ snapshots:
'@types/node': 26.6.2
'@types/ssh2-streams': 0.1.13
- '@types/ssh2@1.15.5':
+ '@types/ssh2@1.15.6':
dependencies:
'@types/node': 18.19.130
@@ -9707,57 +9738,57 @@ snapshots:
dependencies:
'@types/node': 26.6.2
- '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/scope-manager': 8.70.0
- '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.70.0
+ '@typescript-eslint/parser': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.71.0
+ '@typescript-eslint/type-utils': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.71.0
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
- ignore: 7.0.9
+ ignore: 7.0.11
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.70.0
- '@typescript-eslint/types': 8.70.0
- '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.70.0
+ '@typescript-eslint/scope-manager': 8.71.0
+ '@typescript-eslint/types': 8.71.0
+ '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.71.0
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.70.0(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/project-service@8.71.0(supports-color@7.2.0)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3)
- '@typescript-eslint/types': 8.70.0
+ '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.71.0
debug: 4.4.3(supports-color@7.2.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.70.0':
+ '@typescript-eslint/scope-manager@8.71.0':
dependencies:
- '@typescript-eslint/types': 8.70.0
- '@typescript-eslint/visitor-keys': 8.70.0
+ '@typescript-eslint/types': 8.71.0
+ '@typescript-eslint/visitor-keys': 8.71.0
- '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)':
+ '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/types': 8.70.0
- '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/types': 8.71.0
+ '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -9765,14 +9796,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.70.0': {}
+ '@typescript-eslint/types@8.71.0': {}
- '@typescript-eslint/typescript-estree@8.70.0(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/typescript-estree@8.71.0(supports-color@7.2.0)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.70.0(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3)
- '@typescript-eslint/types': 8.70.0
- '@typescript-eslint/visitor-keys': 8.70.0
+ '@typescript-eslint/project-service': 8.71.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.71.0
+ '@typescript-eslint/visitor-keys': 8.71.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6
semver: 7.8.5
@@ -9782,20 +9813,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
- '@typescript-eslint/scope-manager': 8.70.0
- '@typescript-eslint/types': 8.70.0
- '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.71.0
+ '@typescript-eslint/types': 8.71.0
+ '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3)
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.70.0':
+ '@typescript-eslint/visitor-keys@8.71.0':
dependencies:
- '@typescript-eslint/types': 8.70.0
+ '@typescript-eslint/types': 8.71.0
eslint-visitor-keys: 5.0.1
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -10090,7 +10121,7 @@ snapshots:
buffer-crc32: 1.0.0
readable-stream: 4.7.0
readdir-glob: 1.1.3
- tar-stream: 3.2.0
+ tar-stream: 3.2.1
zip-stream: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
@@ -10223,40 +10254,40 @@ snapshots:
axobject-query@4.1.0: {}
- b4a@1.8.1: {}
+ b4a@1.9.0: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
- bare-events@2.9.1: {}
+ bare-events@2.9.2: {}
- bare-fs@4.8.0:
+ bare-fs@4.8.2:
dependencies:
- bare-events: 2.9.1
- bare-path: 3.1.1
- bare-stream: 2.13.3(bare-events@2.9.1)
- bare-url: 2.5.2
+ bare-events: 2.9.2
+ bare-path: 3.1.2
+ bare-stream: 2.13.4(bare-events@2.9.2)
+ bare-url: 2.5.4
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
- bare-path@3.1.1: {}
+ bare-path@3.1.2: {}
- bare-stream@2.13.3(bare-events@2.9.1):
+ bare-stream@2.13.4(bare-events@2.9.2):
dependencies:
- b4a: 1.8.1
- streamx: 2.28.0
+ b4a: 1.9.0
+ streamx: 2.28.1
teex: 1.0.1
optionalDependencies:
- bare-events: 2.9.1
+ bare-events: 2.9.2
transitivePeerDependencies:
- react-native-b4a
- bare-url@2.5.2:
+ bare-url@2.5.4:
dependencies:
- bare-path: 3.1.1
+ bare-path: 3.1.2
base64-arraybuffer@1.0.2:
optional: true
@@ -10265,6 +10296,8 @@ snapshots:
baseline-browser-mapping@2.11.11: {}
+ baseline-browser-mapping@2.11.26: {}
+
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
@@ -10392,6 +10425,8 @@ snapshots:
caniuse-lite@1.0.30001806: {}
+ caniuse-lite@1.0.30001814: {}
+
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.29.2
@@ -10530,7 +10565,7 @@ snapshots:
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
- nan: 2.28.0
+ nan: 2.29.0
optional: true
crc-32@1.2.2: {}
@@ -10704,9 +10739,9 @@ snapshots:
dijkstrajs@1.0.3: {}
- docker-compose@1.4.2:
+ docker-compose@1.5.0:
dependencies:
- yaml: 2.9.0
+ yaml: 2.9.1
docker-modem@5.0.7(supports-color@7.2.0):
dependencies:
@@ -10723,7 +10758,7 @@ snapshots:
'@grpc/grpc-js': 1.14.5
'@grpc/proto-loader': 0.7.15
docker-modem: 5.0.7(supports-color@7.2.0)
- protobufjs: 7.6.5
+ protobufjs: 7.6.6
tar-fs: 2.1.5
transitivePeerDependencies:
- supports-color
@@ -10862,7 +10897,7 @@ snapshots:
typed-array-byte-offset: 1.0.5
typed-array-length: 1.0.8
unbox-primitive: 1.1.0
- which-typed-array: 1.1.23
+ which-typed-array: 1.1.24
es-define-property@1.0.1: {}
@@ -10921,18 +10956,18 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-config-next@16.3.6(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
+ eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
dependencies:
- '@next/eslint-plugin-next': 16.3.6(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
+ '@next/eslint-plugin-next': 16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
globals: 16.4.0
- typescript-eslint: 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ typescript-eslint: 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -10960,22 +10995,22 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
debug: 3.2.7(supports-color@7.2.0)
optionalDependencies:
- '@typescript-eslint/parser': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.2.0
@@ -10986,7 +11021,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
- eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
hasown: 2.0.4
is-core-module: 2.17.0
is-glob: 4.0.3
@@ -10998,7 +11033,7 @@ snapshots:
string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -11026,7 +11061,7 @@ snapshots:
eslint-plugin-react-hooks@7.1.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
- '@babel/parser': 7.29.8
+ '@babel/parser': 7.29.9
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
hermes-parser: 0.25.1
zod: 4.6.5
@@ -11140,7 +11175,7 @@ snapshots:
events-universal@1.0.1:
dependencies:
- bare-events: 2.9.1
+ bare-events: 2.9.2
transitivePeerDependencies:
- bare-abort-controller
@@ -11572,7 +11607,7 @@ snapshots:
ignore@5.3.2: {}
- ignore@7.0.9: {}
+ ignore@7.0.11: {}
immer@11.1.15: {}
@@ -11741,7 +11776,7 @@ snapshots:
is-typed-array@1.1.15:
dependencies:
- which-typed-array: 1.1.23
+ which-typed-array: 1.1.24
is-unicode-supported@1.3.0: {}
@@ -12087,9 +12122,9 @@ snapshots:
magicast@0.5.2:
dependencies:
- '@babel/parser': 7.29.8
+ '@babel/parser': 7.29.9
'@babel/types': 7.29.8
- source-map-js: 1.2.1
+ source-map-js: 1.2.2
make-dir@4.0.0:
dependencies:
@@ -12198,11 +12233,13 @@ snapshots:
dependencies:
lru.min: 1.1.5
- nan@2.28.0:
+ nan@2.29.0:
optional: true
nanoid@3.3.18: {}
+ nanoid@3.3.19: {}
+
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -12214,27 +12251,27 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
- next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
- '@next/env': 16.3.6
+ '@next/env': 16.3.8
'@swc/helpers': 0.5.23
- baseline-browser-mapping: 2.11.11
- caniuse-lite: 1.0.30001806
+ baseline-browser-mapping: 2.11.26
+ caniuse-lite: 1.0.30001814
postcss: 8.5.23
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.3.6
- '@next/swc-darwin-x64': 16.3.6
- '@next/swc-linux-arm64-gnu': 16.3.6
- '@next/swc-linux-arm64-musl': 16.3.6
- '@next/swc-linux-x64-gnu': 16.3.6
- '@next/swc-linux-x64-musl': 16.3.6
- '@next/swc-win32-arm64-msvc': 16.3.6
- '@next/swc-win32-x64-msvc': 16.3.6
+ '@next/swc-darwin-arm64': 16.3.8
+ '@next/swc-darwin-x64': 16.3.8
+ '@next/swc-linux-arm64-gnu': 16.3.8
+ '@next/swc-linux-arm64-musl': 16.3.8
+ '@next/swc-linux-x64-gnu': 16.3.8
+ '@next/swc-linux-x64-musl': 16.3.8
+ '@next/swc-win32-arm64-msvc': 16.3.8
+ '@next/swc-win32-x64-msvc': 16.3.8
'@playwright/test': 1.63.0
- sharp: 0.35.4(@types/node@26.6.2)
+ sharp: 0.35.5(@types/node@26.6.2)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@@ -12442,7 +12479,7 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
- p-limit@7.3.1:
+ p-limit@7.3.3:
dependencies:
yocto-queue: 1.2.2
@@ -12600,9 +12637,9 @@ snapshots:
postcss@8.5.23:
dependencies:
- nanoid: 3.3.18
+ nanoid: 3.3.19
picocolors: 1.1.1
- source-map-js: 1.2.1
+ source-map-js: 1.2.2
postcss@8.5.25:
dependencies:
@@ -12690,7 +12727,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- protobufjs@7.6.5:
+ protobufjs@7.6.6:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
@@ -13191,37 +13228,37 @@ snapshots:
- supports-color
- typescript
- sharp@0.35.4(@types/node@26.6.2):
+ sharp@0.35.5(@types/node@26.6.2):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.35.4
- '@img/sharp-darwin-x64': 0.35.4
- '@img/sharp-freebsd-wasm32': 0.35.4
- '@img/sharp-libvips-darwin-arm64': 1.3.3
- '@img/sharp-libvips-darwin-x64': 1.3.3
- '@img/sharp-libvips-linux-arm': 1.3.3
- '@img/sharp-libvips-linux-arm64': 1.3.3
- '@img/sharp-libvips-linux-ppc64': 1.3.3
- '@img/sharp-libvips-linux-riscv64': 1.3.3
- '@img/sharp-libvips-linux-s390x': 1.3.3
- '@img/sharp-libvips-linux-x64': 1.3.3
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
- '@img/sharp-libvips-linuxmusl-x64': 1.3.3
- '@img/sharp-linux-arm': 0.35.4
- '@img/sharp-linux-arm64': 0.35.4
- '@img/sharp-linux-ppc64': 0.35.4
- '@img/sharp-linux-riscv64': 0.35.4
- '@img/sharp-linux-s390x': 0.35.4
- '@img/sharp-linux-x64': 0.35.4
- '@img/sharp-linuxmusl-arm64': 0.35.4
- '@img/sharp-linuxmusl-x64': 0.35.4
- '@img/sharp-webcontainers-wasm32': 0.35.4
- '@img/sharp-win32-arm64': 0.35.4
- '@img/sharp-win32-ia32': 0.35.4
- '@img/sharp-win32-x64': 0.35.4
+ '@img/sharp-darwin-arm64': 0.35.5
+ '@img/sharp-darwin-x64': 0.35.5
+ '@img/sharp-freebsd-wasm32': 0.35.5
+ '@img/sharp-libvips-darwin-arm64': 1.3.4
+ '@img/sharp-libvips-darwin-x64': 1.3.4
+ '@img/sharp-libvips-linux-arm': 1.3.4
+ '@img/sharp-libvips-linux-arm64': 1.3.4
+ '@img/sharp-libvips-linux-ppc64': 1.3.4
+ '@img/sharp-libvips-linux-riscv64': 1.3.4
+ '@img/sharp-libvips-linux-s390x': 1.3.4
+ '@img/sharp-libvips-linux-x64': 1.3.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.4
+ '@img/sharp-linux-arm': 0.35.5
+ '@img/sharp-linux-arm64': 0.35.5
+ '@img/sharp-linux-ppc64': 0.35.5
+ '@img/sharp-linux-riscv64': 0.35.5
+ '@img/sharp-linux-s390x': 0.35.5
+ '@img/sharp-linux-x64': 0.35.5
+ '@img/sharp-linuxmusl-arm64': 0.35.5
+ '@img/sharp-linuxmusl-x64': 0.35.5
+ '@img/sharp-webcontainers-wasm32': 0.35.5
+ '@img/sharp-win32-arm64': 0.35.5
+ '@img/sharp-win32-ia32': 0.35.5
+ '@img/sharp-win32-x64': 0.35.5
'@types/node': 26.6.2
optional: true
@@ -13284,6 +13321,8 @@ snapshots:
source-map-js@1.2.1: {}
+ source-map-js@1.2.2: {}
+
source-map@0.6.1: {}
split-ca@1.0.1: {}
@@ -13303,7 +13342,7 @@ snapshots:
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
- nan: 2.28.0
+ nan: 2.29.0
stable-hash@0.0.5: {}
@@ -13330,7 +13369,7 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
- streamx@2.28.0:
+ streamx@2.28.1:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
@@ -13479,10 +13518,10 @@ snapshots:
tar-fs@3.1.3:
dependencies:
pump: 3.0.4
- tar-stream: 3.2.0
+ tar-stream: 3.2.1
optionalDependencies:
- bare-fs: 4.8.0
- bare-path: 3.1.1
+ bare-fs: 4.8.2
+ bare-path: 3.1.2
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -13496,12 +13535,12 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
- tar-stream@3.2.0:
+ tar-stream@3.2.1:
dependencies:
- b4a: 1.8.1
- bare-fs: 4.8.0
+ b4a: 1.9.0
+ bare-fs: 4.8.2
fast-fifo: 1.3.2
- streamx: 2.28.0
+ streamx: 2.28.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -13509,7 +13548,7 @@ snapshots:
teex@1.0.1:
dependencies:
- streamx: 2.28.0
+ streamx: 2.28.1
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
@@ -13532,7 +13571,7 @@ snapshots:
transitivePeerDependencies:
- encoding
- testcontainers@12.1.0(supports-color@7.2.0):
+ testcontainers@12.2.0(supports-color@7.2.0):
dependencies:
'@balena/dockerignore': 1.0.2
'@types/dockerode': 4.0.1
@@ -13540,7 +13579,7 @@ snapshots:
async-lock: 1.4.1
byline: 5.0.0
debug: 4.4.3(supports-color@7.2.0)
- docker-compose: 1.4.2
+ docker-compose: 1.5.0
dockerode: 5.0.1(supports-color@7.2.0)
get-port: 5.1.1
proper-lockfile: 4.1.2
@@ -13557,7 +13596,7 @@ snapshots:
text-decoder@1.2.7:
dependencies:
- b4a: 1.8.1
+ b4a: 1.9.0
transitivePeerDependencies:
- react-native-b4a
@@ -13684,12 +13723,12 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
+ typescript-eslint@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/parser': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.70.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
typescript: 6.0.3
transitivePeerDependencies:
@@ -13920,7 +13959,7 @@ snapshots:
isarray: 2.0.5
which-boxed-primitive: 1.1.1
which-collection: 1.0.2
- which-typed-array: 1.1.23
+ which-typed-array: 1.1.24
which-collection@1.0.2:
dependencies:
@@ -13931,7 +13970,7 @@ snapshots:
which-module@2.0.1: {}
- which-typed-array@1.1.23:
+ which-typed-array@1.1.24:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.9
@@ -13995,6 +14034,8 @@ snapshots:
yaml@2.9.0: {}
+ yaml@2.9.1: {}
+
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
@@ -14049,7 +14090,7 @@ snapshots:
zlibjs@0.3.1: {}
- zod-openapi@6.0.1(zod@4.4.3):
+ zod-openapi@6.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
diff --git a/public/sw.js b/public/sw.js
index 32351a83e..0c7bb5af9 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.40.0";
+ /* @sw-version-fallback */ "v1.40.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/__tests__/backup-schema-lazy-guard.test.ts b/src/__tests__/backup-schema-lazy-guard.test.ts
new file mode 100644
index 000000000..7edfd07fb
--- /dev/null
+++ b/src/__tests__/backup-schema-lazy-guard.test.ts
@@ -0,0 +1,75 @@
+/**
+ * `backupPayloadSchema` is built when `src/lib/validations/backup.ts` is
+ * evaluated and holds about 12 MB of heap. The restore job, the stored-backup
+ * readers and the export writer sit on the boot path, so a static value import
+ * of that module from any of them puts the schema in every server process,
+ * whether or not it ever touches a backup. Runtime code reaches it with a
+ * dynamic `import()` at the moment a file is validated, takes only types
+ * statically, and takes the version and the summary from `backup-summary.ts`.
+ *
+ * The one static importer allowed is the OpenAPI registry, which only the
+ * spec generator loads.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const ROOT = join(__dirname, "..");
+const ALLOWED_STATIC = new Set(["lib/openapi/routes/export.ts"]);
+
+function sourceFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const name of readdirSync(dir)) {
+ const path = join(dir, name);
+ if (name === "__tests__" || name === "generated") continue;
+ if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
+ else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) {
+ out.push(path);
+ }
+ }
+ return out;
+}
+
+// `import … from "@/lib/validations/backup"`, across lines, capturing what
+// precedes the specifier. Every importer uses the alias (a relative
+// `./backup` also names `src/lib/cycle/backup.ts`, so it is not matched).
+const STATIC_IMPORT =
+ /^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/validations\/backup["']/gm;
+const DYNAMIC_IMPORT = /import\(\s*["']@\/lib\/validations\/backup["']\s*\)/g;
+
+describe("the backup payload schema loads on first use", () => {
+ const files = sourceFiles(ROOT);
+ const staticValueImporters: string[] = [];
+ let dynamicImports = 0;
+ let typeImports = 0;
+
+ for (const file of files) {
+ const rel = relative(ROOT, file);
+ if (rel === "lib/validations/backup.ts") continue;
+ const text = readFileSync(file, "utf8");
+ for (const match of text.matchAll(STATIC_IMPORT)) {
+ const typeOnly =
+ match[1] !== undefined ||
+ (/^\{[^}]*\}$/.test(match[2]) &&
+ match[2]
+ .slice(1, -1)
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .every((s) => s.startsWith("type ")));
+ if (typeOnly) typeImports++;
+ else if (!ALLOWED_STATIC.has(rel)) staticValueImporters.push(rel);
+ }
+ dynamicImports += [...text.matchAll(DYNAMIC_IMPORT)].length;
+ }
+
+ it("has no static value import outside the OpenAPI registry", () => {
+ expect(staticValueImporters).toEqual([]);
+ });
+
+ it("finds the loaders it guards (an empty match set would pass vacuously)", () => {
+ // upload, restore, preview (store + summary route), download, streamed reader
+ expect(dynamicImports).toBeGreaterThanOrEqual(5);
+ expect(typeImports).toBeGreaterThan(0);
+ });
+});
diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts
index d79aca81d..bf5e3c096 100644
--- a/src/app/api/admin/backups/[id]/download/route.ts
+++ b/src/app/api/admin/backups/[id]/download/route.ts
@@ -38,7 +38,6 @@ import {
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
-import { parseBackupPayload } from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
@@ -108,6 +107,8 @@ export const GET = apiHandler(
let streamed: StreamedBackup;
try {
streamed = await readStreamedBackup(source);
+ // Loaded on first use, not at import (see `backup-summary.ts`).
+ const { parseBackupPayload } = await import("@/lib/validations/backup");
parseBackupPayload(streamed.raw);
} catch (err) {
const readFailure = isStoredBackupReadError(err);
diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts
index 9c95d3f8b..417ab2277 100644
--- a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts
+++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts
@@ -67,14 +67,16 @@ const summarizeMock = vi.fn();
const compatibleMock = vi.fn((version: string) => version !== "99");
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
- summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
- isCompatibleSchemaVersion: (version: string) => compatibleMock(version),
// The measurements are read as a stream and counted one by one.
- BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
}));
+vi.mock("@/lib/validations/backup-summary", () => ({
+ summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
+ isCompatibleSchemaVersion: (version: string) => compatibleMock(version),
+ BACKUP_SCHEMA_VERSION: "2",
+}));
import { GET } from "../route";
import { storedCopyIdentity } from "@/lib/export/backup-preview";
diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts
index 36862e792..bc8eacfc3 100644
--- a/src/app/api/admin/backups/[id]/summary/route.ts
+++ b/src/app/api/admin/backups/[id]/summary/route.ts
@@ -59,9 +59,8 @@ import {
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
- parseBackupPayload,
summarizeBackup,
-} from "@/lib/validations/backup";
+} from "@/lib/validations/backup-summary";
export const dynamic = "force-dynamic";
@@ -123,6 +122,8 @@ async function readPreview(
try {
const streamed = await readStreamedBackup(source);
+ // Loaded on first use, not at import (see `backup-summary.ts`).
+ const { parseBackupPayload } = await import("@/lib/validations/backup");
const payload = parseBackupPayload(streamed.raw);
const summary = {
...summarizeBackup(payload),
diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts
index 063dbf978..b0d14a5ea 100644
--- a/src/app/api/admin/backups/upload/route.ts
+++ b/src/app/api/admin/backups/upload/route.ts
@@ -61,15 +61,13 @@ import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
+import type { BackupPayload } from "@/lib/validations/backup";
import {
BACKUP_SCHEMA_VERSION,
- backupMeasurementSchema,
- backupPayloadSchema,
isCompatibleSchemaVersion,
summarizeBackup,
- type BackupPayload,
type BackupSummary,
-} from "@/lib/validations/backup";
+} from "@/lib/validations/backup-summary";
export const dynamic = "force-dynamic";
@@ -252,6 +250,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
yield chunk;
}
}
+ // Loaded on first use, not at import (see `backup-summary.ts`).
+ const { backupMeasurementSchema, backupPayloadSchema } =
+ await import("@/lib/validations/backup");
let firstWithoutId: number | null = null;
let scanned;
try {
diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts
index fac819089..231b435b4 100644
--- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts
+++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts
@@ -329,6 +329,7 @@ describe("every Coach tool honours the lookback limit", () => {
const ROOT = join(__dirname, "../../../../..");
const COACH_DIR = join(ROOT, "src/lib/ai/coach");
const MCP_DIR = join(ROOT, "src/lib/mcp");
+const SOURCE_SNAPSHOT_FILE = "source-snapshot.ts";
function walk(dir: string): string[] {
const out: string[] = [];
@@ -387,11 +388,23 @@ describe("the source hands the limit on", () => {
});
it("passes the limit to every Coach snapshot build", () => {
- const calls = walk(COACH_DIR).flatMap((file) =>
- callArguments(readFileSync(file, "utf8"), "buildCoachSnapshot").map(
- (args) => ({ file: file.slice(ROOT.length + 1), args }),
- ),
- );
+ // `source-snapshot.ts` is the single-metric build only MCP asks for
+ // (`sourceSnapshot: true`); MCP reads without the Coach's limit, so that
+ // file is checked the other way round below.
+ const calls = walk(COACH_DIR)
+ .filter((file) => !file.endsWith(SOURCE_SNAPSHOT_FILE))
+ .flatMap((file) => {
+ const source = readFileSync(file, "utf8");
+ return ["buildCoachSnapshot", "resolveSnapshotPrelude"].flatMap((fn) =>
+ callArguments(source, fn).map((args) => ({
+ file: `${file.slice(ROOT.length + 1)} ${fn}`,
+ args,
+ })),
+ );
+ });
+ expect(
+ calls.filter((c) => c.file.endsWith("resolveSnapshotPrelude")).length,
+ ).toBeGreaterThan(0);
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call.args, `${call.file} builds without a limit`).toMatch(
@@ -400,6 +413,15 @@ describe("the source hands the limit on", () => {
}
});
+ it("keeps the MCP single-metric build unlimited", () => {
+ const source = readFileSync(join(COACH_DIR, SOURCE_SNAPSHOT_FILE), "utf8");
+ const calls = ["buildCoachSnapshot", "resolveSnapshotPrelude"].flatMap(
+ (fn) => callArguments(source, fn),
+ );
+ expect(calls.length).toBeGreaterThan(0);
+ for (const args of calls) expect(args).not.toMatch(/\breach\b/);
+ });
+
it("passes the limit from the chat loop and nowhere from MCP", () => {
const loop = callArguments(
readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"),
diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts
index a31da2d04..5a2a8751f 100644
--- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts
+++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts
@@ -223,6 +223,11 @@ const VALUE_BLOCKS: readonly ValueBlock[] = [
},
];
+/** The sources a value-series block is built for. */
+export const VALUE_SERIES_SOURCES: ReadonlySet = new Set(
+ VALUE_BLOCKS.map((block) => block.source),
+);
+
const TYPE_TO_REFERENCE_METRIC: Readonly> = {
RESTING_HEART_RATE: "RESTING_HEART_RATE",
OXYGEN_SATURATION: "OXYGEN_SATURATION",
diff --git a/src/lib/ai/coach/snapshot-prelude.ts b/src/lib/ai/coach/snapshot-prelude.ts
new file mode 100644
index 000000000..5acdedc8f
--- /dev/null
+++ b/src/lib/ai/coach/snapshot-prelude.ts
@@ -0,0 +1,329 @@
+/**
+ * The reads and resolutions every Coach snapshot build starts from, shared by
+ * the full builder (`snapshot.ts`) and the single-source builder
+ * (`source-snapshot.ts`). Both must agree on the prefs, the module gates, the
+ * admitted sources, the window cutoffs and the windowed row read, because the
+ * single-source build promises the same section the full build would carry;
+ * keeping these in one place is what makes that promise hold by construction
+ * instead of by two copies staying in step.
+ */
+import { prisma } from "@/lib/db";
+import type { MeasurementType } from "@/generated/prisma/client";
+import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
+import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
+import { resolveUnitPreferences } from "@/lib/measurements/display-transform";
+import { memoizePerRequest } from "@/lib/request-cache";
+import { resolveModuleMap } from "@/lib/modules/gate";
+import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
+import type { CoachDataCluster } from "@/lib/validations/coach-prefs";
+import { clusterSourcesFromPrefs, sourceCluster } from "./clusters";
+import { resolveScope, windowToDays } from "./snapshot-series";
+import {
+ UNBOUNDED_REACH,
+ clampWindow,
+ type CoachHistoryReach,
+} from "./history-reach";
+import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
+
+/**
+ * Day-level cap for the raw timeline. Days within this window are kept
+ * verbatim (one entry per day with weekday). Older days inside the
+ * snapshot window are folded into weekly means so a 90-day window
+ * lands at ~14 day-rows + ~11 week-rows ≈ 25 rows per metric — well
+ * under the 3 000-token Coach turn budget on a 5-metric snapshot.
+ */
+export const DAILY_TIMELINE_DAYS = 14;
+
+/**
+ * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
+ * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
+ * token proxy and, if it exceeds this cap, progressively degrade the
+ * lowest-priority clusters (drop `timeline.recent`, then collapse the
+ * weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which
+ * sits comfortably inside every provider's context alongside the system
+ * prompt + history window. The daily token ledger (`budget.ts`) stays
+ * the per-day cost backstop; this is the per-prompt shape backstop.
+ */
+export const MAX_SNAPSHOT_CHARS = 24_000;
+
+/**
+ * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
+ * that feeds the Coach snapshot timelines. The window read can reach 365 days
+ * (`lastYear` / `allTime`) across high-frequency types (PULSE / glucose are
+ * 200k-row-class), but the prompt only renders ~21 daily + ~10 weekly buckets
+ * per metric, so an uncapped read loaded a year of rows to discard almost all
+ * of them. 6000 keeps the recent-daily + weekly window exact even with several
+ * dense types active (≈ a year of multi-daily readings on one type, or a
+ * handful of types at a few readings/day) while bounding the worst case; the
+ * coarse MONTH/YEAR tail comes from the rollup tier, not this read.
+ */
+const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000;
+
+/**
+ * v1.7.0 — when more than this many clusters are active, cap the
+ * additive (non-core) clusters' timeline window so a 10-cluster,
+ * allTime request can't fan the timeline out across every series at
+ * once. The core clinical clusters keep the user-chosen window.
+ */
+const MULTI_CLUSTER_THRESHOLD = 6;
+const MULTI_CLUSTER_WINDOW_CAP: CoachScopeWindow = "last90days";
+
+/**
+ * Clusters that keep the user-chosen window even under the multi-cluster
+ * cap — the high-signal clinical series.
+ */
+const CORE_CLUSTERS: ReadonlySet = new Set([
+ "medication",
+ "cardio",
+ "glucose",
+]);
+
+/**
+ * Resolve the prefs, module gates and scope a build reads under.
+ *
+ * v1.4.23 H4 — apply per-user `excludeMetrics` BEFORE we read any
+ * measurement rows so the model never sees data the user opted out
+ * of. The filter intersects with the resolved scope (the explicit
+ * `scope` argument from the request body still wins for the
+ * _maximum_ set; prefs only narrow further).
+ *
+ * v1.4.25 W7b — the same prefs read also returns the user's
+ * displayTimezone so the day-key and weekday labels below match the
+ * calendar the user is looking at. Reading both columns in one
+ * query keeps the snapshot's read budget the same as before.
+ *
+ * v1.7.0 — the prefs read now also drives the source default: when
+ * the request omits an explicit `scope.sources`, the resolved scope
+ * expands the user's saved `dataClusters` (legacy default when the
+ * key is absent). So the prefs read must precede `resolveScope`.
+ * v1.18.0 — resolve the per-user module map once at build start so a
+ * disabled data-domain module's data never enters the coach context.
+ * The map read is memoised per-request by the gate, and runs alongside
+ * the prefs read (both only need `userId`), so the cold path pays a
+ * single extra round-trip at most. Disabled modules fold into the same
+ * SYSTEM-side exclusion the user's `excludeMetrics` flow already drives.
+ */
+export async function resolveSnapshotPrelude(
+ userId: string,
+ scope: CoachScope | undefined,
+ /**
+ * The Coach's lookback limit, applied to the window here so every build
+ * that starts from the prelude reads under it. The single-source MCP build
+ * passes none and keeps its own window.
+ */
+ reach: CoachHistoryReach = UNBOUNDED_REACH,
+) {
+ const moduleMapPromise = resolveModuleMap(userId);
+ // v1.20.0 (H-1) — the F1 coach tools each rebuild a single-source snapshot
+ // with a distinct LRU key, so the 60s snapshot cache does not share this read
+ // across the fan-out. Memoise it per-request (the select shape is constant, so
+ // userId is the only key) so up to 6 concurrent tool builds collapse to one
+ // prefs round-trip instead of starving the shared Prisma pool.
+ const prefsRow = await memoizePerRequest(`coach-prefs:${userId}`, () =>
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ coachPrefsJson: true,
+ timezone: true,
+ locale: true,
+ // v1.11.5 — needed to collapse a dual-source sleep night to one
+ // canonical source before reconstructing per-night asleep totals.
+ sourcePriorityJson: true,
+ // v1.15 — the cycle snapshot block is gated on the resolved cycle
+ // toggle (an explicit opt-in/out overrides the gender default). Read
+ // both columns here so a non-cycle account pays no extra round-trip.
+ gender: true,
+ cycleProfile: { select: { cycleTrackingEnabled: true } },
+ // v1.16.16 — the glucose block converts canonical mg/dL to the user's
+ // display unit so the Coach reads the same number every other surface
+ // shows. Read it on this existing prefs hop (no extra round-trip).
+ glucoseUnit: true,
+ // The metric/imperial choice: the grounding bands and the blocks
+ // that carry a mass, length or temperature are written in it.
+ unitPreference: true,
+ // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
+ // the tighter ADA glycemic GOAL band for the glucose reference-grounding
+ // line only; never inferred from a reading, never a diagnosis. Read on
+ // this existing prefs hop (no extra round-trip).
+ hasDiabetes: true,
+ },
+ }),
+ );
+ const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
+ // Resolve the UI locale for the rolling-profile narrative recall. The
+ // narrative rows are keyed by the full UI locale union, so the stored value
+ // is carried through as-is; an unset or unknown value falls back to the app
+ // default (`en`), never to German. The former `=== "en" ? "en" : "de"`
+ // binary made a French account recall a German narrative row.
+ const coachLocale: Locale = locales.includes(prefsRow?.locale as Locale)
+ ? (prefsRow?.locale as Locale)
+ : defaultLocale;
+ const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
+ const { sources: scopedSources, window: requestedWindow } = resolveScope(
+ scope,
+ clusterDefault,
+ );
+ const window = clampWindow(requestedWindow, reach);
+ const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
+ const units = resolveUnitPreferences({
+ unitPreference: prefsRow?.unitPreference,
+ glucoseUnit: prefsRow?.glucoseUnit,
+ });
+ // v1.18.0 — fold disabled data-domain modules into the system exclusion.
+ // `moduleMap[key] === false` means the user turned that module off; the
+ // gate has already resolved every delegation (cycle/coach) so this map
+ // is authoritative. We union the disabled modules' owned sources into
+ // `excluded` so the existing source-narrowing path below removes them
+ // before any row is read — the model never sees a disabled domain.
+ const moduleMap = await moduleMapPromise;
+ const excluded = coachExclusions(prefs, moduleMap);
+ const sources = admitCoachSources(scopedSources, excluded);
+ return {
+ prefsRow,
+ coachLocale,
+ window,
+ userTz,
+ units,
+ recoveryDisabled: moduleMap.recovery === false,
+ excluded,
+ sources,
+ };
+}
+
+/**
+ * The window cutoffs a build reads under, and the per-source cutoff the
+ * additive (non-core) blocks use once many clusters are active.
+ */
+export function resolveSnapshotCutoffs(
+ sources: ReadonlySet,
+ window: CoachScopeWindow,
+ now: Date,
+) {
+ const windowDays = windowToDays(window);
+ const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
+ const recentCutoff = new Date(
+ now.getTime() - DAILY_TIMELINE_DAYS * 24 * 60 * 60 * 1000,
+ );
+
+ // v1.7.0 — when many clusters are active, cap the timeline read
+ // window for the ADDITIVE (non-core) clusters so a 10-cluster /
+ // allTime request cannot fan a dense timeline across every series at
+ // once. The core clinical clusters keep the user-chosen window.
+ const activeClusters = new Set();
+ for (const src of sources) {
+ const c = sourceCluster(src);
+ if (c) activeClusters.add(c);
+ }
+ const multiClusterCapActive = activeClusters.size > MULTI_CLUSTER_THRESHOLD;
+ const additiveCapDays = windowToDays(MULTI_CLUSTER_WINDOW_CAP);
+ const additiveCapCutoff = new Date(
+ now.getTime() - additiveCapDays * 24 * 60 * 60 * 1000,
+ );
+ // Effective `cutoff` for an additive block under the multi-cluster
+ // cap — the later of the window cutoff and the cap cutoff. Core
+ // clusters always use the full window cutoff.
+ const additiveCutoff = (source: CoachScopeSource): Date => {
+ const cluster = sourceCluster(source);
+ if (
+ multiClusterCapActive &&
+ cluster !== null &&
+ !CORE_CLUSTERS.has(cluster) &&
+ additiveCapCutoff > cutoff
+ ) {
+ return additiveCapCutoff;
+ }
+ return cutoff;
+ };
+ return {
+ windowDays,
+ cutoff,
+ recentCutoff,
+ activeClusters,
+ multiClusterCapActive,
+ additiveCutoff,
+ };
+}
+
+/**
+ * The windowed measurement read the timeline blocks share.
+ *
+ * Single fetch for all measurement types — Prisma's filter pushes
+ * the type list into one SQL `WHERE type IN (…)` so we don't pay
+ * per-metric round-trips.
+ */
+export function readSnapshotMeasurementRows(
+ userId: string,
+ wantedTypes: readonly MeasurementType[],
+ cutoff: Date,
+) {
+ if (wantedTypes.length === 0) return Promise.resolve([]);
+ return (
+ prisma.measurement
+ .findMany({
+ where: {
+ userId,
+ type: { in: [...wantedTypes] },
+ measuredAt: { gte: cutoff },
+ deletedAt: null,
+ },
+ // v1.18.10 (P-2) — read NEWEST-first + cap. PULSE / glucose are
+ // 200k-row-class types and the window can reach 365 days
+ // (lastYear / allTime), so an uncapped read pulled the entire
+ // year of high-frequency rows into memory just to fold them into
+ // ~21 daily + ~10 weekly buckets the prompt actually shows. The
+ // newest-first cap keeps the recent-daily timeline exact and only
+ // sheds the deepest weekly buckets on an extreme-volume account;
+ // the coarse MONTH/YEAR tail is read separately from the rollup
+ // tier (`buildCoarseTimelineTail`), so deep history survives.
+ orderBy: { measuredAt: "desc" },
+ take: SNAPSHOT_MEASUREMENT_ROW_CAP,
+ // v1.7.0 — `glucoseContext` rides along so the glucose block
+ // can split fasting / postprandial / random / bedtime without
+ // a second query. NULL on every non-glucose row.
+ select: {
+ type: true,
+ value: true,
+ measuredAt: true,
+ glucoseContext: true,
+ },
+ })
+ // Downstream bucketers re-sort/group internally, but restore
+ // ascending order so any order-sensitive consumer sees the same
+ // shape as before the cap.
+ .then((rows) => rows.reverse())
+ );
+}
+
+/**
+ * Sleep-stage rows from `since` on, with the writer columns the canonical
+ * dedup needs. Writer-level collapse: two HealthKit apps behind one source
+ * (watch stages vs phone in-bed) must not blend into one night.
+ */
+export function readSleepStageRows(userId: string, since: Date) {
+ return prisma.measurement.findMany({
+ where: {
+ userId,
+ type: "SLEEP_DURATION" as never,
+ measuredAt: { gte: since },
+ deletedAt: null,
+ },
+ orderBy: { measuredAt: "asc" },
+ select: {
+ value: true,
+ measuredAt: true,
+ sleepStage: true,
+ source: true,
+ deviceType: true,
+ },
+ });
+}
+
+/** The mood entries the mood timeline folds, tombstones excluded (v1.7.0). */
+export function readSnapshotMoodRows(userId: string, since: Date) {
+ return prisma.moodEntry.findMany({
+ where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
+ orderBy: { moodLoggedAt: "asc" },
+ select: { moodLoggedAt: true, score: true },
+ });
+}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index c6ee40546..10ee28d0b 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -17,20 +17,21 @@
*/
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
-import {
- parseCoachPrefs,
- type CoachDataCluster,
-} from "@/lib/validations/coach-prefs";
-import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
-import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
-import {
- resolveUnitPreferences,
- type UnitPreferences,
-} from "@/lib/measurements/display-transform";
+import type { CoachDataCluster } from "@/lib/validations/coach-prefs";
+import type { UnitPreferences } from "@/lib/measurements/display-transform";
import type { SleepStageRow } from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
+import {
+ DAILY_TIMELINE_DAYS,
+ MAX_SNAPSHOT_CHARS,
+ readSleepStageRows,
+ readSnapshotMeasurementRows,
+ readSnapshotMoodRows,
+ resolveSnapshotCutoffs,
+ resolveSnapshotPrelude,
+} from "./snapshot-prelude";
import { annotateSnapshotFreshness } from "./snapshot-freshness";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
@@ -54,25 +55,14 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
-import { resolveModuleMap } from "@/lib/modules/gate";
-import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import {
COURSES_COMPLIANCE_SELECT,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { BaselineProfile } from "@/lib/insights/derived";
import { toProfileSex } from "@/lib/profile/sex";
-import {
- CLUSTER_PRIORITY,
- clusterSourcesFromPrefs,
- sourceCluster,
-} from "./clusters";
-import type {
- CoachProvenance,
- CoachScope,
- CoachScopeSource,
- CoachScopeWindow,
-} from "./types";
+import { CLUSTER_PRIORITY, sourceCluster } from "./clusters";
+import type { CoachProvenance, CoachScope, CoachScopeSource } from "./types";
import {
readSnapshotCache,
snapshotCacheKey,
@@ -80,8 +70,6 @@ import {
} from "./snapshot-cache";
import {
buildCoarseTimelineTail,
- resolveScope,
- windowToDays,
type CoarseTimelineTail,
} from "./snapshot-series";
import { buildWorkoutsBlock } from "./snapshot-blocks/workouts-block";
@@ -103,7 +91,6 @@ import {
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import {
UNBOUNDED_REACH,
- clampWindow,
fitsReach,
isBounded,
reachFloor,
@@ -151,28 +138,6 @@ export interface CoachSnapshotResult {
units: UnitPreferences;
}
-/**
- * Day-level cap for the raw timeline. Days within this window are kept
- * verbatim (one entry per day with weekday). Older days inside the
- * snapshot window are folded into weekly means so a 90-day window
- * lands at ~14 day-rows + ~11 week-rows ≈ 25 rows per metric — well
- * under the 3 000-token Coach turn budget on a 5-metric snapshot.
- */
-const DAILY_TIMELINE_DAYS = 14;
-
-/**
- * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
- * that feeds the Coach snapshot timelines. The window read can reach 365 days
- * (`lastYear` / `allTime`) across high-frequency types (PULSE / glucose are
- * 200k-row-class), but the prompt only renders ~21 daily + ~10 weekly buckets
- * per metric, so an uncapped read loaded a year of rows to discard almost all
- * of them. 6000 keeps the recent-daily + weekly window exact even with several
- * dense types active (≈ a year of multi-daily readings on one type, or a
- * handful of types at a few readings/day) while bounding the worst case; the
- * coarse MONTH/YEAR tail comes from the rollup tier, not this read.
- */
-const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000;
-
/**
* v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed
* trailing-window artifact, identical across the Sleep page, the dashboard
@@ -192,37 +157,6 @@ const SLEEP_RHYTHM_WINDOW_DAYS = 42;
*/
const COARSE_TAIL_START_DAYS = 90;
-/**
- * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
- * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
- * token proxy and, if it exceeds this cap, progressively degrade the
- * lowest-priority clusters (drop `timeline.recent`, then collapse the
- * weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which
- * sits comfortably inside every provider's context alongside the system
- * prompt + history window. The daily token ledger (`budget.ts`) stays
- * the per-day cost backstop; this is the per-prompt shape backstop.
- */
-const MAX_SNAPSHOT_CHARS = 24_000;
-
-/**
- * v1.7.0 — when more than this many clusters are active, cap the
- * additive (non-core) clusters' timeline window so a 10-cluster,
- * allTime request can't fan the timeline out across every series at
- * once. The core clinical clusters keep the user-chosen window.
- */
-const MULTI_CLUSTER_THRESHOLD = 6;
-const MULTI_CLUSTER_WINDOW_CAP: CoachScopeWindow = "last90days";
-
-/**
- * Clusters that keep the user-chosen window even under the multi-cluster
- * cap — the high-signal clinical series.
- */
-const CORE_CLUSTERS: ReadonlySet = new Set([
- "medication",
- "cardio",
- "glucose",
-]);
-
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
@@ -297,105 +231,40 @@ async function buildCoachSnapshotImpl(
scope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise {
- // v1.4.23 H4 — apply per-user `excludeMetrics` BEFORE we read any
- // measurement rows so the model never sees data the user opted out
- // of. The filter intersects with the resolved scope (the explicit
- // `scope` argument from the request body still wins for the
- // _maximum_ set; prefs only narrow further).
- //
- // v1.4.25 W7b — the same prefs read also returns the user's
- // displayTimezone so the day-key and weekday labels below match the
- // calendar the user is looking at. Reading both columns in one
- // query keeps the snapshot's read budget the same as before.
- //
- // v1.7.0 — the prefs read now also drives the source default: when
- // the request omits an explicit `scope.sources`, the resolved scope
- // expands the user's saved `dataClusters` (legacy default when the
- // key is absent). So the prefs read must precede `resolveScope`.
- // v1.18.0 — resolve the per-user module map once at build start so a
- // disabled data-domain module's data never enters the coach context.
- // The map read is memoised per-request by the gate, and runs alongside
- // the prefs read (both only need `userId`), so the cold path pays a
- // single extra round-trip at most. Disabled modules fold into the same
- // SYSTEM-side exclusion the user's `excludeMetrics` flow already drives.
- const moduleMapPromise = resolveModuleMap(userId);
- // v1.20.0 (H-1) — the F1 coach tools each rebuild a single-source snapshot
- // with a distinct LRU key, so the 60s snapshot cache does not share this read
- // across the fan-out. Memoise it per-request (the select shape is constant, so
- // userId is the only key) so up to 6 concurrent tool builds collapse to one
- // prefs round-trip instead of starving the shared Prisma pool.
- const prefsRow = await memoizePerRequest(`coach-prefs:${userId}`, () =>
- prisma.user.findUnique({
- where: { id: userId },
- select: {
- coachPrefsJson: true,
- timezone: true,
- locale: true,
- // v1.11.5 — needed to collapse a dual-source sleep night to one
- // canonical source before reconstructing per-night asleep totals.
- sourcePriorityJson: true,
- // v1.15 — the cycle snapshot block is gated on the resolved cycle
- // toggle (an explicit opt-in/out overrides the gender default). Read
- // both columns here so a non-cycle account pays no extra round-trip.
- gender: true,
- cycleProfile: { select: { cycleTrackingEnabled: true } },
- // v1.16.16 — the glucose block converts canonical mg/dL to the user's
- // display unit so the Coach reads the same number every other surface
- // shows. Read it on this existing prefs hop (no extra round-trip).
- glucoseUnit: true,
- // The metric/imperial choice: the grounding bands and the blocks
- // that carry a mass, length or temperature are written in it.
- unitPreference: true,
- // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
- // the tighter ADA glycemic GOAL band for the glucose reference-grounding
- // line only; never inferred from a reading, never a diagnosis. Read on
- // this existing prefs hop (no extra round-trip).
- hasDiabetes: true,
- },
- }),
- );
- const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
- // Resolve the UI locale for the rolling-profile narrative recall. The
- // narrative rows are keyed by the full UI locale union, so the stored value
- // is carried through as-is; an unset or unknown value falls back to the app
- // default (`en`), never to German. The former `=== "en" ? "en" : "de"`
- // binary made a French account recall a German narrative row.
- const coachLocale: Locale = locales.includes(prefsRow?.locale as Locale)
- ? (prefsRow?.locale as Locale)
- : defaultLocale;
- const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
- const { sources: scopedSources, window: requestedWindow } = resolveScope(
- scope,
- clusterDefault,
- );
- // The lookback limit caps the narration window, whatever the caller asked
- // for. Every fixed-window block below answers to the same limit.
- const window = clampWindow(requestedWindow, reach);
+ // Prefs, module gates and the admitted sources — see `snapshot-prelude.ts`.
+ // The lookback limit caps the narration window there, whatever the caller
+ // asked for; every fixed-window block below answers to the same limit.
+ const {
+ prefsRow,
+ coachLocale,
+ window,
+ userTz,
+ units,
+ recoveryDisabled,
+ excluded,
+ sources,
+ } = await resolveSnapshotPrelude(userId, scope, reach);
const bounded = isBounded(reach);
- const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
- const units = resolveUnitPreferences({
- unitPreference: prefsRow?.unitPreference,
- glucoseUnit: prefsRow?.glucoseUnit,
- });
const glucoseUnit = units.glucoseUnit;
- // v1.18.0 — fold disabled data-domain modules into the system exclusion.
- // `moduleMap[key] === false` means the user turned that module off; the
- // gate has already resolved every delegation (cycle/coach) so this map
- // is authoritative. We union the disabled modules' owned sources into
- // `excluded` so the existing source-narrowing path below removes them
- // before any row is read — the model never sees a disabled domain.
- const moduleMap = await moduleMapPromise;
- const recoveryDisabled = moduleMap.recovery === false;
- const excluded = coachExclusions(prefs, moduleMap);
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
// exclude-only toggles (not in `CoachScopeSource`); they gate the
// GLP-1 weeklyContext / compliance branch and the anthropometrics
// block respectively.
const excludesMedications = excluded.has("medications");
const excludesAnthropometrics = excluded.has("anthropometrics");
- const sources = admitCoachSources(scopedSources, excluded);
- const windowDays = windowToDays(window);
+ // Pull raw measurement rows once for the configured window so day
+ // and week buckets share a single I/O hop. Mood + compliance live in
+ // separate tables and are loaded conditionally below.
+ const now = new Date();
+ const {
+ windowDays,
+ cutoff,
+ recentCutoff,
+ activeClusters,
+ multiClusterCapActive,
+ additiveCutoff,
+ } = resolveSnapshotCutoffs(sources, window, now);
// v1.11.3 — kick the feature extraction off as a promise now and await
// it alongside the shared measurement read below. `extractFeatures`
// and the measurement `findMany` are independent (each only needs
@@ -461,25 +330,6 @@ async function buildCoachSnapshotImpl(
if (cluster) blockClusters.set(key, cluster);
};
- // Pull raw measurement rows once for the configured window so day
- // and week buckets share a single I/O hop. Mood + compliance live in
- // separate tables and are loaded conditionally below.
- const now = new Date();
- const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
- const recentCutoff = new Date(
- now.getTime() - DAILY_TIMELINE_DAYS * 24 * 60 * 60 * 1000,
- );
-
- // v1.7.0 — when many clusters are active, cap the timeline read
- // window for the ADDITIVE (non-core) clusters so a 10-cluster /
- // allTime request cannot fan a dense timeline across every series at
- // once. The core clinical clusters keep the user-chosen window.
- const activeClusters = new Set();
- for (const src of sources) {
- const c = sourceCluster(src);
- if (c) activeClusters.add(c);
- }
- const multiClusterCapActive = activeClusters.size > MULTI_CLUSTER_THRESHOLD;
// v1.7.0 — record which clusters resolved active for this build so
// the observability dashboards can track cluster adoption + the
// multi-cluster cap firing rate.
@@ -491,25 +341,6 @@ async function buildCoachSnapshotImpl(
multiClusterCap: multiClusterCapActive,
},
});
- const additiveCapDays = windowToDays(MULTI_CLUSTER_WINDOW_CAP);
- const additiveCapCutoff = new Date(
- now.getTime() - additiveCapDays * 24 * 60 * 60 * 1000,
- );
- // Effective `cutoff` for an additive block under the multi-cluster
- // cap — the later of the window cutoff and the cap cutoff. Core
- // clusters always use the full window cutoff.
- const additiveCutoff = (source: CoachScopeSource): Date => {
- const cluster = sourceCluster(source);
- if (
- multiClusterCapActive &&
- cluster !== null &&
- !CORE_CLUSTERS.has(cluster) &&
- additiveCapCutoff > cutoff
- ) {
- return additiveCapCutoff;
- }
- return cutoff;
- };
const wantsBp = sources.has("bp");
const wantsWeight = sources.has("weight");
@@ -579,42 +410,11 @@ async function buildCoachSnapshotImpl(
(source) => COACH_SOURCE_MEASUREMENT_TYPES[source] ?? [],
);
- const measurementRowsPromise =
- wantedTypes.length > 0
- ? prisma.measurement
- .findMany({
- where: {
- userId,
- type: { in: wantedTypes },
- measuredAt: { gte: cutoff },
- deletedAt: null,
- },
- // v1.18.10 (P-2) — read NEWEST-first + cap. PULSE / glucose are
- // 200k-row-class types and the window can reach 365 days
- // (lastYear / allTime), so an uncapped read pulled the entire
- // year of high-frequency rows into memory just to fold them into
- // ~21 daily + ~10 weekly buckets the prompt actually shows. The
- // newest-first cap keeps the recent-daily timeline exact and only
- // sheds the deepest weekly buckets on an extreme-volume account;
- // the coarse MONTH/YEAR tail is read separately from the rollup
- // tier (`buildCoarseTimelineTail`), so deep history survives.
- orderBy: { measuredAt: "desc" },
- take: SNAPSHOT_MEASUREMENT_ROW_CAP,
- // v1.7.0 — `glucoseContext` rides along so the glucose block
- // can split fasting / postprandial / random / bedtime without
- // a second query. NULL on every non-glucose row.
- select: {
- type: true,
- value: true,
- measuredAt: true,
- glucoseContext: true,
- },
- })
- // Downstream bucketers re-sort/group internally, but restore
- // ascending order so any order-sensitive consumer sees the same
- // shape as before the cap.
- .then((rows) => rows.reverse())
- : Promise.resolve([]);
+ const measurementRowsPromise = readSnapshotMeasurementRows(
+ userId,
+ wantedTypes,
+ cutoff,
+ );
// v1.11.3 — `extractFeatures` and the shared measurement read are
// mutually independent and both gate the blocks below (every aggregate
@@ -661,14 +461,7 @@ async function buildCoachSnapshotImpl(
!recoveryDisabled && derivedSources.some((s) => sources.has(s));
const moodRowsPromise =
- wantsMood && features.mood
- ? prisma.moodEntry.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the Coach snapshot.
- where: { userId, deletedAt: null, moodLoggedAt: { gte: cutoff } },
- orderBy: { moodLoggedAt: "asc" },
- select: { moodLoggedAt: true, score: true },
- })
- : null;
+ wantsMood && features.mood ? readSnapshotMoodRows(userId, cutoff) : null;
// v1.16.9 — the adherence timeline derives from the LEDGER tally (the
// same band engine the compliance % + dose history consume), not from a
// raw intake-row count. The raw count read a worker-minted pending row
@@ -718,24 +511,7 @@ async function buildCoachSnapshotImpl(
})
: null;
const sleepRowsPromise = sources.has("sleep")
- ? prisma.measurement.findMany({
- where: {
- userId,
- type: "SLEEP_DURATION" as never,
- measuredAt: { gte: additiveCutoff("sleep") },
- deletedAt: null,
- },
- orderBy: { measuredAt: "asc" },
- // Writer-level collapse: two HealthKit apps behind one source
- // (watch stages vs phone in-bed) must not blend into one night.
- select: {
- value: true,
- measuredAt: true,
- sleepStage: true,
- source: true,
- deviceType: true,
- },
- })
+ ? readSleepStageRows(userId, additiveCutoff("sleep"))
: null;
// v1.17.0 — sleep-rhythm rows. The sleep-debt + chronotype DTO is a fixed
// trailing-42-day artifact (the Sleep page + dashboard read the same window),
@@ -748,28 +524,13 @@ async function buildCoachSnapshotImpl(
const sleepRhythmCutoff = new Date(
now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
- // A limit shorter than the rhythm's fixed 42 days leaves the block out: a
- // debt or chronotype band over fewer nights is not the one the page shows.
+ // source + deviceType feed the canonical writer-dedup so a multi-source
+ // night is counted ONCE, matching every other sleep surface. A limit
+ // shorter than the rhythm's fixed 42 days leaves the block out: a debt or
+ // chronotype band over fewer nights is not the one the page shows.
const sleepRhythmRowsPromise =
sources.has("sleep") && fitsReach(SLEEP_RHYTHM_WINDOW_DAYS, reach)
- ? prisma.measurement.findMany({
- where: {
- userId,
- type: "SLEEP_DURATION" as never,
- measuredAt: { gte: sleepRhythmCutoff },
- deletedAt: null,
- },
- orderBy: { measuredAt: "asc" },
- // source + deviceType feed the canonical writer-dedup so a multi-source
- // night is counted ONCE, matching every other sleep surface.
- select: {
- value: true,
- measuredAt: true,
- sleepStage: true,
- source: true,
- deviceType: true,
- },
- })
+ ? readSleepStageRows(userId, sleepRhythmCutoff)
: null;
const workoutRowsPromise = sources.has("workouts")
? prisma.workout.findMany({
diff --git a/src/lib/ai/coach/source-snapshot.ts b/src/lib/ai/coach/source-snapshot.ts
new file mode 100644
index 000000000..723b94be5
--- /dev/null
+++ b/src/lib/ai/coach/source-snapshot.ts
@@ -0,0 +1,265 @@
+/**
+ * The snapshot one metric's read needs, built from that metric's rows.
+ *
+ * `get_metric_series` answers with one section of a Coach snapshot. Built by
+ * the full builder, that one section paid for everything a Coach turn
+ * carries: the all-type feature extraction, the derived wellness layer, the
+ * trajectory and correlation engines, memory, GLP-1, cycle, illness, labs and
+ * visits — a few hundred SQL statements and the event-loop time to fold them,
+ * for a result that keeps none of it. An MCP client polling one metric every
+ * few minutes paid that on every call.
+ *
+ * This builder runs the same prelude (prefs, module gates, admitted sources,
+ * cutoffs) and the same block builders as `snapshot.ts`, over the requested
+ * source's own reads only, and hands back the same section and the same
+ * reference grounding. Each block reads exactly what the full build reads for
+ * it: the core clinical aggregates come from `extractFeatures` scoped to the
+ * one block (whose bulk read keeps its all-type cap, so the rows a block sees
+ * are unchanged), the timelines from the same windowed read, the coarse tail
+ * from the same rollup reader.
+ *
+ * Two cases go to the full builder instead:
+ *
+ * - a source whose section this module does not build (glucose, workouts,
+ * compliance have their own tools; the environment cluster is ordered
+ * beside the memory / trajectory / correlation blocks in the budget pass,
+ * see below);
+ * - a section large enough that the full build's prompt-budget pass could
+ * plausibly trim it.
+ *
+ * The budget pass is the one thing a single-source build cannot see. The full
+ * builder trims blocks, lowest cluster first, once the WHOLE snapshot passes
+ * `MAX_SNAPSHOT_CHARS`; the requested section is only touched after every
+ * lower-priority block is already collapsed, so what decides it is the section
+ * plus the context that is never shed (labs, illness, anthropometrics) or
+ * ranks above it. A section below `SOURCE_SNAPSHOT_FALLBACK_CHARS` leaves more
+ * than `MAX_SNAPSHOT_CHARS - SOURCE_SNAPSHOT_FALLBACK_CHARS` of room for that
+ * context, which a single-source build does not reach on any account we know
+ * of; above it, the full builder answers and the result is the old one by
+ * construction. Should an account ever carry more context than that room, the
+ * difference is that this build returns the section untrimmed — the trim
+ * exists for a prompt this read never sends.
+ */
+import { annotate } from "@/lib/logging/context";
+import {
+ extractFeatures,
+ type ScopedFeatureBlock,
+} from "@/lib/insights/features";
+import { compactSections } from "@/lib/ai/prompts/compact-sections";
+import { memoizePerRequest } from "@/lib/request-cache";
+import type { MeasurementType } from "@/generated/prisma/client";
+import type { ReferenceMetric } from "@/lib/reference-ranges";
+import type { SleepStageRow } from "@/lib/analytics/sleep-night";
+import { COACH_SOURCE_MEASUREMENT_TYPES } from "./source-measurement-types";
+import { sourceCluster } from "./clusters";
+import { buildCoachSnapshot, type CoachSnapshotResult } from "./snapshot";
+import {
+ DAILY_TIMELINE_DAYS,
+ MAX_SNAPSHOT_CHARS,
+ readSleepStageRows,
+ readSnapshotMeasurementRows,
+ readSnapshotMoodRows,
+ resolveSnapshotCutoffs,
+ resolveSnapshotPrelude,
+} from "./snapshot-prelude";
+import { annotateSnapshotFreshness } from "./snapshot-freshness";
+import { buildReferenceGroundingBlock } from "./reference-grounding";
+import { buildCoarseTimelineTail } from "./snapshot-series";
+import { buildCoreMetricsBlocks } from "./snapshot-blocks/core-metrics-block";
+import {
+ buildValueSeriesBlocks,
+ VALUE_SERIES_SOURCES,
+} from "./snapshot-blocks/value-series-blocks";
+import { buildSleepTimelineBlock } from "./snapshot-blocks/sleep-block";
+import type {
+ CoachProvenance,
+ CoachScopeSource,
+ CoachScopeWindow,
+} from "./types";
+
+/**
+ * Above this pretty-printed size the section goes to the full builder, whose
+ * budget pass may trim it (see the module comment). A 30-day section is a few
+ * thousand characters; a year of blood pressure with its coarse tail is the
+ * kind of section that crosses it.
+ */
+export const SOURCE_SNAPSHOT_FALLBACK_CHARS = MAX_SNAPSHOT_CHARS / 2;
+
+/** Core clinical sources: their aggregate is a feature block. */
+const CORE_FEATURE_BLOCK: Partial<
+ Record
+> = {
+ bp: "bloodPressure",
+ weight: "weight",
+ pulse: "pulse",
+ mood: "mood",
+};
+
+/** Core clinical sources with a coarse MONTH/YEAR tail, by its anchor type. */
+const COARSE_TAIL_TYPE: Partial> = {
+ bp: "BLOOD_PRESSURE_SYS",
+ weight: "WEIGHT",
+ pulse: "PULSE",
+};
+
+/** Whether this module builds `source`'s section itself. */
+export function buildsSourceSection(source: CoachScopeSource): boolean {
+ if (sourceCluster(source) === "environment") return false;
+ return (
+ source in CORE_FEATURE_BLOCK ||
+ source === "sleep" ||
+ VALUE_SERIES_SOURCES.has(source)
+ );
+}
+
+export type CoachSourceSnapshot = Pick<
+ CoachSnapshotResult,
+ "sections" | "referenceGrounding"
+>;
+
+/**
+ * Build the snapshot sections and reference grounding a single-source read
+ * slices, identical to what `buildCoachSnapshot(userId, { sources: [source],
+ * window })` returns for that source's section.
+ */
+export async function buildCoachSourceSnapshot(
+ userId: string,
+ source: CoachScopeSource,
+ window: CoachScopeWindow | undefined,
+): Promise {
+ const scope = { sources: [source], window };
+ if (!buildsSourceSection(source)) {
+ annotate({
+ action: { name: "coach.source_snapshot.full" },
+ meta: { source, reason: "unsupported_source" },
+ });
+ return buildCoachSnapshot(userId, scope);
+ }
+
+ const prelude = await resolveSnapshotPrelude(userId, scope);
+ const { prefsRow, userTz, units, sources } = prelude;
+ const now = new Date();
+ const { windowDays, cutoff, recentCutoff, additiveCutoff } =
+ resolveSnapshotCutoffs(sources, prelude.window, now);
+ // An excluded source leaves `sources` empty: nothing is read, the section
+ // stays absent, and the caller resolves the miss the way it always has.
+ const admitted = sources.has(source);
+
+ const featureBlock = admitted ? CORE_FEATURE_BLOCK[source] : undefined;
+ const coarseType = admitted ? COARSE_TAIL_TYPE[source] : undefined;
+ const wantedTypes =
+ admitted && source !== "sleep" && source !== "mood"
+ ? [...(COACH_SOURCE_MEASUREMENT_TYPES[source] ?? [])]
+ : [];
+
+ const [features, coarseTail, measurementRows] = await Promise.all([
+ featureBlock
+ ? memoizePerRequest(
+ `coach-source-features:${userId}:${windowDays}:${featureBlock}`,
+ () =>
+ extractFeatures(userId, false, {
+ sinceDays: windowDays,
+ only: new Set([featureBlock]),
+ }),
+ )
+ : undefined,
+ coarseType
+ ? buildCoarseTimelineTail(
+ userId,
+ coarseType,
+ now,
+ userTz,
+ prefsRow?.sourcePriorityJson ?? null,
+ )
+ : undefined,
+ readSnapshotMeasurementRows(userId, wantedTypes, cutoff),
+ ]);
+ const moodRows =
+ source === "mood" && admitted && features?.mood
+ ? await readSnapshotMoodRows(userId, cutoff)
+ : null;
+
+ // Provenance and the degrade registry belong to the full builder's prompt;
+ // the block builders still want somewhere to write them.
+ const snapshot: Record = {};
+ const windows = new Set();
+ const metrics = new Set();
+ const counts: NonNullable = {};
+ const registerBlock = () => {};
+ const groundingValues = new Map();
+
+ buildCoreMetricsBlocks({
+ sources,
+ features: features ?? {},
+ measurementRows,
+ moodRows,
+ recentCutoff,
+ userTz,
+ coarseTails: coarseType ? { [source]: coarseTail } : {},
+ snapshot,
+ windows,
+ metrics,
+ counts,
+ registerBlock,
+ groundingValues,
+ units,
+ });
+ buildValueSeriesBlocks({
+ sources,
+ measurementRows,
+ additiveCutoff,
+ recentCutoff,
+ userTz,
+ snapshot,
+ metrics,
+ counts,
+ registerBlock,
+ groundingValues,
+ units,
+ });
+ if (admitted && source === "sleep") {
+ const sleepRows = await readSleepStageRows(userId, additiveCutoff("sleep"));
+ buildSleepTimelineBlock({
+ sleepRows: sleepRows as SleepStageRow[],
+ sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null,
+ userTz,
+ recentCutoff,
+ snapshot,
+ metrics,
+ counts,
+ registerBlock,
+ groundingValues,
+ });
+ }
+
+ snapshot.scope = {
+ window: prelude.window,
+ sources: Array.from(sources),
+ timelineRecentDays: DAILY_TIMELINE_DAYS,
+ };
+ const sections = compactSections(snapshot);
+ annotateSnapshotFreshness(sections);
+
+ const size = JSON.stringify(sections, null, 2).length;
+ if (size > SOURCE_SNAPSHOT_FALLBACK_CHARS) {
+ annotate({
+ action: { name: "coach.source_snapshot.full" },
+ meta: { source, reason: "section_size", chars: size },
+ });
+ return buildCoachSnapshot(userId, scope);
+ }
+
+ const referenceGrounding = buildReferenceGroundingBlock({
+ metrics: Array.from(groundingValues.entries()).map(([metric, value]) => ({
+ metric,
+ value,
+ })),
+ hasDiabetes: prefsRow?.hasDiabetes ?? false,
+ units,
+ });
+ annotate({
+ action: { name: "coach.source_snapshot.built" },
+ meta: { source, chars: size },
+ });
+ return { sections, referenceGrounding };
+}
diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts
index efbda48b0..9e32436e9 100644
--- a/src/lib/ai/coach/tools/executor.ts
+++ b/src/lib/ai/coach/tools/executor.ts
@@ -47,6 +47,7 @@ import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveModuleMap } from "@/lib/modules/gate";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
+import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-snapshot";
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import { readMessageResults } from "@/lib/ai/coach/persistence";
@@ -271,6 +272,13 @@ export async function executeCoachTool(args: {
* already-built cache entry instead of rebuilding a single-source snapshot.
*/
sharedScope?: CoachScope;
+ /**
+ * Build a single-metric read from that source's own rows
+ * (`source-snapshot.ts`) rather than from a Coach snapshot. Set by the MCP
+ * surface, whose reads have no turn snapshot to land on and keep only the
+ * one section; the section is the same either way.
+ */
+ sourceSnapshot?: boolean;
/** v1.39.4 — the chat turn, when the call runs inside one. */
turn?: CoachToolTurnContext;
/**
@@ -281,8 +289,15 @@ export async function executeCoachTool(args: {
*/
reach?: CoachHistoryReach;
}): Promise {
- const { userId, name, rawArguments, fallbackWindow, sharedScope, turn } =
- args;
+ const {
+ userId,
+ name,
+ rawArguments,
+ fallbackWindow,
+ sharedScope,
+ sourceSnapshot = false,
+ turn,
+ } = args;
const reach = args.reach ?? UNBOUNDED_REACH;
if (!isCoachToolName(name) && name !== SHOW_RESULT_TOOL_NAME) {
@@ -316,6 +331,7 @@ export async function executeCoachTool(args: {
sharedScope,
turn,
reach,
+ sourceSnapshot,
);
annotate({
action: { name: "coach.tool.executed" },
@@ -344,6 +360,7 @@ async function dispatch(
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
+ sourceSnapshot: boolean,
): Promise {
const result = await dispatchRead(
name,
@@ -353,6 +370,7 @@ async function dispatch(
sharedScope,
turn,
reach,
+ sourceSnapshot,
);
if (!turn || !result.present || result.table) return result;
return withProjectedTable(
@@ -374,6 +392,7 @@ async function dispatchRead(
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
+ sourceSnapshot: boolean,
): Promise {
// Every branch takes the lookback limit; `coach-history-reach-guard.test.ts`
// fails when one does not.
@@ -385,6 +404,7 @@ async function dispatchRead(
fallbackWindow,
sharedScope,
reach,
+ sourceSnapshot,
);
case "get_glucose_panel":
return getGlucosePanel(
@@ -455,6 +475,7 @@ async function getMetricSeries(
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
+ sourceSnapshot: boolean,
): Promise {
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
@@ -480,11 +501,17 @@ async function getMetricSeries(
return { present: false, reason: "unsupported_metric" };
}
- const snapshot = await buildCoachSnapshot(
- userId,
- scopeFor([metric], window, fallbackWindow, sharedScope, reach),
- { reach },
- );
+ // An MCP read needs one section and nothing else, so it is built from that
+ // source's own rows: the same section, without the context blocks a Coach
+ // turn carries. See `source-snapshot.ts`. Only MCP asks for it, and MCP
+ // reads without the Coach's lookback limit, so it takes no reach.
+ const snapshot = sourceSnapshot
+ ? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow)
+ : await buildCoachSnapshot(
+ userId,
+ scopeFor([metric], window, fallbackWindow, sharedScope, reach),
+ { reach },
+ );
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
return emptyRead(
diff --git a/src/lib/apple-health/own-origin.ts b/src/lib/apple-health/own-origin.ts
index 34f6f2f8c..1a60a05aa 100644
--- a/src/lib/apple-health/own-origin.ts
+++ b/src/lib/apple-health/own-origin.ts
@@ -4,9 +4,13 @@
* from the app, and any Withings, import or manual row the server-to-Health
* mirror writes. Builds from before the marker existed wrote the id alone.
*
- * Such a sample is already in the database under its own source. When the same
- * sample comes back through an Apple Health export it is a second copy of that
- * reading, so the export import leaves it out.
+ * Such a sample is usually already in the database under its own source. When
+ * it comes back through an Apple Health export it is a second copy of that
+ * reading, so the export import leaves it out, but only once its
+ * `HKExternalUUID` names a row of the importing account (a cycle sample: once
+ * that account holds a day-log on its day). The marker alone says HealthLog
+ * wrote the sample, not that this instance still holds it: after a move to a
+ * new instance without a backup, the export is how those values come back.
*
* This is not the predicate the app's own sync uses. The app decides "our own
* echo" from `HKExternalUUID` plus the sample's authoring source being the app
diff --git a/src/lib/export/__tests__/backup-preview.test.ts b/src/lib/export/__tests__/backup-preview.test.ts
index 688087675..b82ac0b6f 100644
--- a/src/lib/export/__tests__/backup-preview.test.ts
+++ b/src/lib/export/__tests__/backup-preview.test.ts
@@ -20,7 +20,8 @@ import {
storedPreviewFor,
} from "@/lib/export/backup-preview";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
-import { backupPayloadSchema, summarizeBackup } from "@/lib/validations/backup";
+import { backupPayloadSchema } from "@/lib/validations/backup";
+import { summarizeBackup } from "@/lib/validations/backup-summary";
const ts = "2026-07-19T07:00:00.000Z";
/** A bytes-column value holding the string codec under key id `v3`. */
@@ -100,8 +101,8 @@ describe("createBackupPreviewCollector", () => {
const expectedKeys = new BackupKeyIdCollector();
expectedKeys.visit(FILE);
- it("says what a full read of the copy says", () => {
- const collector = createBackupPreviewCollector();
+ it("says what a full read of the copy says", async () => {
+ const collector = await createBackupPreviewCollector();
observeAsTheWriterDoes(FILE, collector.observe);
const result = collector.finish(countsOf(FILE));
expect(result).not.toBeNull();
@@ -111,8 +112,8 @@ describe("createBackupPreviewCollector", () => {
expect(result!.keys.keyIds()).toEqual(["v3"]);
});
- it("answers null for a copy that does not name its version, owner and date", () => {
- const collector = createBackupPreviewCollector();
+ it("answers null for a copy that does not name its version, owner and date", async () => {
+ const collector = await createBackupPreviewCollector();
const { userId, ...rest } = FILE;
void userId;
observeAsTheWriterDoes(rest, collector.observe);
@@ -120,6 +121,64 @@ describe("createBackupPreviewCollector", () => {
});
});
+describe("createBackupPreviewCollector against the schema", () => {
+ // A copy the schema refuses must not be described as restorable: a null
+ // preview sends the preview route to read the copy, which says why not.
+ async function previewOf(file: Record) {
+ const collector = await createBackupPreviewCollector();
+ observeAsTheWriterDoes(file, collector.observe);
+ return collector.finish(countsOf(FILE));
+ }
+
+ it("answers null when a bulk row violates the schema", async () => {
+ const measurements = FILE.measurements.map((m, i) =>
+ i === 1 ? { ...m, measuredAt: "not a date" } : m,
+ );
+ expect(await previewOf({ ...FILE, measurements })).toBeNull();
+ });
+
+ it("answers null when a whole section violates the schema", async () => {
+ const labResults = [{ ...FILE.labResults[0], takenAt: 42 }];
+ expect(await previewOf({ ...FILE, labResults })).toBeNull();
+ });
+
+ it("answers null when the header does not parse", async () => {
+ expect(
+ await previewOf({ ...FILE, exportedAt: "yesterday-ish" }),
+ ).toBeNull();
+ });
+
+ it("answers null for a canonical copy with a measurement without an id", async () => {
+ const measurements = FILE.measurements.map((m, i) =>
+ i === 0 ? { ...m, id: undefined } : m,
+ );
+ expect(await previewOf({ ...FILE, measurements })).toBeNull();
+ });
+
+ it("agrees with the whole-document parse on every case above", () => {
+ // The oracle: each refused file is refused by the full schema too.
+ const bad = [
+ {
+ ...FILE,
+ measurements: FILE.measurements.map((m, i) =>
+ i === 1 ? { ...m, measuredAt: "not a date" } : m,
+ ),
+ },
+ { ...FILE, labResults: [{ ...FILE.labResults[0], takenAt: 42 }] },
+ { ...FILE, exportedAt: "yesterday-ish" },
+ {
+ ...FILE,
+ measurements: FILE.measurements.map((m, i) =>
+ i === 0 ? { ...m, id: undefined } : m,
+ ),
+ },
+ ];
+ for (const file of bad) {
+ expect(backupPayloadSchema.safeParse(file).success).toBe(false);
+ }
+ });
+});
+
describe("storedPreviewFor", () => {
const chunked = { data: null, chunkCount: 4, chunkStreamId: "s1" };
const preview = () =>
diff --git a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts
index 457fcb7d9..f23c35dea 100644
--- a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts
+++ b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts
@@ -31,11 +31,13 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() }));
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: vi.fn(),
+ // The streamed reader checks measurements one at a time; this file has none.
+ backupMeasurementSchema: { safeParse: vi.fn() },
+}));
+vi.mock("@/lib/validations/backup-summary", () => ({
isCompatibleSchemaVersion: vi.fn(() => true),
summarizeBackup: vi.fn(() => ({})),
- // The streamed reader checks measurements one at a time; this file has none.
BACKUP_SCHEMA_VERSION: "2",
- backupMeasurementSchema: { safeParse: vi.fn() },
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts
index c96be0554..446998d36 100644
--- a/src/lib/export/backup-preview.ts
+++ b/src/lib/export/backup-preview.ts
@@ -35,7 +35,10 @@ import {
} from "@/lib/export/backup-key-ids";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import type { StoredBackupRef } from "@/lib/export/stored-backup";
-import type { BackupSummary } from "@/lib/validations/backup";
+import {
+ BACKUP_SCHEMA_VERSION,
+ type BackupSummary,
+} from "@/lib/validations/backup-summary";
export interface BackupPreview {
version: 1;
@@ -207,7 +210,9 @@ export interface BackupPreviewCollector {
observe(member: string, value: unknown): void;
/**
* The counts and key uses, once the writer has finished; null when the
- * copy did not name its schema version, owner and date.
+ * copy did not name its schema version, owner and date, or when a section
+ * did not pass the backup schema. A null preview sends the preview route
+ * to read the copy itself, which then says why it cannot be restored.
*/
finish(counts: FullBackupCounts): {
summary: BackupSummary;
@@ -215,19 +220,64 @@ export interface BackupPreviewCollector {
} | null;
}
+type SchemaModule = typeof import("@/lib/validations/backup");
+
+/**
+ * A check of one member of the payload against `backupPayloadSchema`, as the
+ * writer shows it: a whole section, or one row of a bulk table. An array
+ * section is checked an element at a time, so the check holds one element's
+ * copy at most, never a section's. Each value is checked in its JSON form,
+ * exactly as it lands in the copy. A member the schema does not name passes,
+ * as it does under the schema's passthrough.
+ */
+function sectionChecker(
+ mod: SchemaModule,
+): (member: string, value: unknown) => boolean {
+ const shape = mod.backupPayloadSchema.shape as Record;
+ const asWritten = (value: unknown): unknown =>
+ value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+ const unwrap = (schema: z.ZodType): z.ZodType =>
+ schema instanceof z.ZodDefault
+ ? unwrap(schema.unwrap() as z.ZodType)
+ : schema;
+ return (member, value) => {
+ const declared = shape[member];
+ if (!declared) return true;
+ const section = unwrap(declared);
+ if (section instanceof z.ZodArray) {
+ const element = section.element as z.ZodType;
+ const rows = Array.isArray(value) ? value : [value];
+ return rows.every((row) => element.safeParse(asWritten(row)).success);
+ }
+ return declared.safeParse(asWritten(value)).success;
+ };
+}
+
/**
* Work out a weekly copy's preview from what its writer already has in hand:
- * each section as it is written, for the key uses, and the writer's own
- * counts. Nothing is parsed and nothing is kept but the key samples, so the
- * preview costs the pass no memory that grows with the record.
+ * each section as it is written, for the key uses and the schema check, and
+ * the writer's own counts. Nothing is parsed back and nothing is kept but the
+ * key samples, so the preview costs the pass no memory that grows with the
+ * record. The check loads the backup schema (`backup-summary.ts` says why it
+ * is loaded on first use), a fixed cost of the module, not of the record.
*/
-export function createBackupPreviewCollector(): BackupPreviewCollector {
+export async function createBackupPreviewCollector(): Promise {
+ const check = sectionChecker(await import("@/lib/validations/backup"));
const keys = new BackupKeyIdCollector();
const header: Partial<
Record<"schemaVersion" | "userId" | "exportedAt", string>
> = {};
+ let valid = true;
+ let measurementWithoutId = false;
return {
observe(member, value) {
+ if (valid && !check(member, value)) valid = false;
+ if (member === "measurements") {
+ const rows: unknown[] = Array.isArray(value) ? value : [value];
+ if (rows.some((row) => !(row as { id?: unknown } | null)?.id)) {
+ measurementWithoutId = true;
+ }
+ }
if (
(member === "schemaVersion" ||
member === "userId" ||
@@ -242,6 +292,12 @@ export function createBackupPreviewCollector(): BackupPreviewCollector {
finish(counts) {
const { schemaVersion, userId, exportedAt } = header;
if (!schemaVersion || !userId || !exportedAt) return null;
+ if (!valid) return null;
+ // The schema's one whole-document rule: a canonical copy names every
+ // measurement by a stable id.
+ if (schemaVersion === BACKUP_SCHEMA_VERSION && measurementWithoutId) {
+ return null;
+ }
const summary = { schemaVersion, userId, exportedAt } as BackupSummary;
for (const field of SUMMARY_COUNTS) summary[field] = counts[field];
return { summary, keys };
diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts
index f96f79935..802f492c0 100644
--- a/src/lib/export/full-backup-payload.ts
+++ b/src/lib/export/full-backup-payload.ts
@@ -27,7 +27,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import { openSealedForExport } from "@/lib/export/sealed-text";
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
-import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
+import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary";
import {
buildCycleBackupSection,
type CycleBackupSection,
diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts
index e0bd34eef..21c702f21 100644
--- a/src/lib/export/restore-backup.ts
+++ b/src/lib/export/restore-backup.ts
@@ -71,13 +71,12 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
+import type { BackupMeasurement } from "@/lib/validations/backup";
import {
- parseBackupPayload,
isCompatibleSchemaVersion,
summarizeBackup,
- type BackupMeasurement,
type BackupSummary,
-} from "@/lib/validations/backup";
+} from "@/lib/validations/backup-summary";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
recomputeUserMedicationCompliance,
@@ -431,6 +430,9 @@ export async function restoreBackup(
progress.measurementsTotal = streamed.measurementCount;
report("validating", true);
raw = streamed.raw;
+ // Loaded here, not at import: the schema is ~12 MB of heap and a server
+ // that never validates a backup should not hold it (`backup-summary.ts`).
+ const { parseBackupPayload } = await import("@/lib/validations/backup");
payload = parseBackupPayload(raw);
} catch (err) {
const readFailure = isStoredBackupReadError(err);
diff --git a/src/lib/export/store-backup-blob.ts b/src/lib/export/store-backup-blob.ts
index 2b9fe6a11..ec5a0543f 100644
--- a/src/lib/export/store-backup-blob.ts
+++ b/src/lib/export/store-backup-blob.ts
@@ -48,7 +48,7 @@ import {
newChunkStreamId,
} from "@/lib/export/backup-chunks";
import { BackupFormsConflictError } from "@/lib/export/stored-backup";
-import type { BackupSummary } from "@/lib/validations/backup";
+import type { BackupSummary } from "@/lib/validations/backup-summary";
/**
* How long the storing transaction may stay open. The job around it expires
diff --git a/src/lib/export/streamed-backup.ts b/src/lib/export/streamed-backup.ts
index b87c1b848..15bece054 100644
--- a/src/lib/export/streamed-backup.ts
+++ b/src/lib/export/streamed-backup.ts
@@ -23,11 +23,22 @@
*/
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
import { scanBackupJson } from "@/lib/export/backup-json-scan";
-import {
- BACKUP_SCHEMA_VERSION,
+import type {
+ BackupMeasurement,
backupMeasurementSchema,
- type BackupMeasurement,
} from "@/lib/validations/backup";
+import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary";
+
+type MeasurementSchema = typeof backupMeasurementSchema;
+
+/**
+ * The element schema, loaded with the rest of `backup.ts` on the first read
+ * rather than at import: the module sits on the boot path of the restore job
+ * and the schema is ~12 MB of heap (`backup-summary.ts`).
+ */
+async function loadMeasurementSchema(): Promise {
+ return (await import("@/lib/validations/backup")).backupMeasurementSchema;
+}
/** The file's bytes, from the start, every time it is called. */
export type BackupSource = () => AsyncIterable;
@@ -69,8 +80,12 @@ export interface StreamedBackup {
): Promise;
}
-function parseMeasurement(element: unknown, index: number): BackupMeasurement {
- const parsed = backupMeasurementSchema.safeParse(element);
+function parseMeasurement(
+ schema: MeasurementSchema,
+ element: unknown,
+ index: number,
+): BackupMeasurement {
+ const parsed = schema.safeParse(element);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new StreamedBackupInvalidError(
@@ -98,6 +113,7 @@ export async function readStreamedBackup(
source: BackupSource,
options: ReadStreamedBackupOptions = {},
): Promise {
+ const schema = await loadMeasurementSchema();
let firstWithoutId: number | null = null;
let bytes = 0;
const keys = new BackupKeyIdCollector();
@@ -110,7 +126,7 @@ export async function readStreamedBackup(
const { document, streamedCounts } = await scanBackupJson(counted(), {
streamKeys: STREAMED,
onElement: (_key, element, index) => {
- const row = parseMeasurement(element, index);
+ const row = parseMeasurement(schema, element, index);
if (!row.id && firstWithoutId === null) firstWithoutId = index;
keys.visit(element, MEASUREMENTS);
options.onMeasurementChecked?.(index + 1);
@@ -146,7 +162,7 @@ export async function readStreamedBackup(
streamKeys: STREAMED,
skipKeys,
onElement: async (_key, element, index) => {
- batch.push(parseMeasurement(element, index));
+ batch.push(parseMeasurement(schema, element, index));
if (batch.length >= size) {
const full = batch;
batch = [];
diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts
index 05ec366d6..08e790466 100644
--- a/src/lib/insights/features.ts
+++ b/src/lib/insights/features.ts
@@ -54,7 +54,10 @@ import {
computeSignalsOfDay,
type SignalOfDay,
} from "@/lib/insights/signals-of-day";
-import type { RollupGranularity } from "@/generated/prisma/client";
+import type {
+ MeasurementType,
+ RollupGranularity,
+} from "@/generated/prisma/client";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import {
@@ -977,439 +980,19 @@ export function avgInWindow(
const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180;
/**
- * v1.25 — newest-first row cap on the bulk measurement read in
- * `extractFeatures`. Mirrors the Coach snapshot's `SNAPSHOT_MEASUREMENT_ROW_CAP`
- * (6000): the prompt / insights aggregates only ever fold this read into a
- * bounded set of summaries, so a year of dense PULSE / glucose rows is wasted
- * I/O on the shared pool for a heavy-data tenant. Full-history extremes come
- * from `readAllTimeExtremes`, not this read, so the cap stays correct.
+ * The mood feature block and the per-day points it was built from. Split out of
+ * `extractFeatures` so a caller that needs only this block (the single-metric
+ * Coach read) runs exactly the same reads without the rest of the function.
*/
-const FEATURE_MEASUREMENT_ROW_CAP = 6000;
-
-export async function extractFeatures(
+async function readMoodFeature(
userId: string,
- includeRaw: boolean,
- options: {
- sinceDays?: number;
- /**
- * The Coach's lookback floor. Given, nothing older than it is read: the
- * `allTime*` figures cover the history from the floor on, the mood
- * history and its sub-blocks start at it, and the mood forecast, a model
- * fitted on the whole history, is left out.
- */
- historyFloor?: Date;
- } = {},
-): Promise {
- const historyFloor = options.historyFloor ?? null;
- const user = await prisma.user.findUnique({
- where: { id: userId },
- select: {
- heightCm: true,
- dateOfBirth: true,
- gender: true,
- thresholdsJson: true,
- },
- });
-
- const now = Date.now();
-
- // Fetch measurements. Default = ALL (full temporal context for the
- // dashboard / insights generator). Callers that only consume the
- // ≤90-day windows (Coach snapshot per turn) pass `sinceDays: 90` so
- // the per-turn I/O stays bounded — `findMany({ where: { userId } })`
- // is unbounded by user-history size and gets paid once per Coach turn
- // for power users with multi-year Withings imports.
- const sinceDays = options.sinceDays;
- const sinceCutoff =
- typeof sinceDays === "number" && sinceDays > 0
- ? new Date(now - sinceDays * 24 * 60 * 60 * 1000)
- : null;
- // v1.25 — newest-first cap on the bulk feature read, mirroring the Coach
- // snapshot (`SNAPSHOT_MEASUREMENT_ROW_CAP`). Callers pass windows of 365-400
- // days, and PULSE / glucose are 200k-row-class types, so an uncapped read
- // pulled hundreds of thousands of rows per Coach turn and nightly briefing
- // only to fold them into a handful of summaries. Read newest-first, cap, then
- // reverse so every downstream consumer (`byType`, `summarize`, BP pairing,
- // sleep reconstruction, oldest/newest span) still sees ascending order. The
- // cap is safe even unbounded: the genuine all-time extremes are sourced
- // separately via `readAllTimeExtremes` on the bounded branch, so capping only
- // sheds the deepest rows of the bulk read and never relabels an extreme.
- const measurements = await prisma.measurement
- .findMany({
- where: sinceCutoff
- ? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null }
- : { userId, deletedAt: null },
- orderBy: { measuredAt: "desc" },
- take: FEATURE_MEASUREMENT_ROW_CAP,
- // Project only the columns every downstream consumer reads (`byType`,
- // `summarize`, BP pairing, and `reconstructSleepNights`'s `SleepStageRow`).
- // The PULSE / glucose windows are 200k-row-class; pulling every column
- // (notes, externalId, …) is pure wasted I/O on the shared Prisma pool.
- select: {
- type: true,
- value: true,
- measuredAt: true,
- sleepStage: true,
- source: true,
- deviceType: true,
- },
- })
- // Restore ascending order so order-sensitive consumers (oldest/newest span,
- // BP pairing, sleep reconstruction) see the same shape as before the cap.
- .then((rows) => rows.reverse());
-
- // v1.18.11 P1 — when the bulk read is bounded to a recent window, the
- // windowed `summarize()` no longer covers the full history, so the `allTime*`
- // fields would silently become "last `sinceDays` days" extremes. Source the
- // genuine full-history min / max / mean from one grouped aggregation (no row
- // materialisation) so the labels stay honest. Unbounded reads (sinceCutoff
- // null) already see the whole history and skip the extra query.
- const allTimeExtremes = sinceCutoff
- ? await readAllTimeExtremes(
- userId,
- ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
- historyFloor,
- )
- : null;
-
- const byType = (type: string) => measurements.filter((m) => m.type === type);
- // The zone pulse days are read in; resolved only when there is pulse to read.
- const pulseTz = measurements.some((m) => m.type === "PULSE")
- ? await resolveUserTimezone(userId)
- : DEFAULT_TIMEZONE;
-
- const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
-
- // Compute overall data span
- const oldestMeasurement =
- measurements.length > 0 ? measurements[0].measuredAt : null;
- const newestMeasurement =
- measurements.length > 0
- ? measurements[measurements.length - 1].measuredAt
- : null;
- const overallSpanDays =
- oldestMeasurement && newestMeasurement
- ? Math.round(
- (newestMeasurement.getTime() - oldestMeasurement.getTime()) /
- (24 * 60 * 60 * 1000),
- )
- : 0;
-
- // Compute age
- let ageYears: number | null = null;
- if (user?.dateOfBirth) {
- const dob = user.dateOfBirth;
- const today = new Date();
- ageYears = today.getFullYear() - dob.getFullYear();
- const monthDiff = today.getMonth() - dob.getMonth();
- if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
- ageYears--;
- }
- }
-
- const features: AggregatedFeatures = {
- context: {
- heightCm: user?.heightCm ?? null,
- hasBpTargets: !!bpTargets,
- totalMeasurements: measurements.length,
- dataSpanDays: overallSpanDays,
- oldestMeasurementDaysAgo: oldestMeasurement
- ? Math.round(
- (now - oldestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
- )
- : null,
- newestMeasurementDaysAgo: newestMeasurement
- ? Math.round(
- (now - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
- )
- : null,
- ageYears,
- gender: user?.gender ?? null,
- },
- };
-
- // Weight
- const weightData = byType("WEIGHT");
- if (weightData.length > 0) {
- const summary = summarize(toDataPoints(weightData));
- const bmi =
- user?.heightCm && summary.latest
- ? parseFloat((summary.latest / (user.heightCm / 100) ** 2).toFixed(1))
- : null;
-
- features.weight = {
- latest: summary.latest!,
- avg7: summary.avg7,
- avg30: summary.avg30,
- avg90: avgInWindow(weightData, now, 90),
- // v1.18.11 P1 — full-history extremes from the grouped aggregation when
- // the bulk read is windowed; the in-window summary otherwise.
- allTimeAvg: allTimeExtremes
- ? roundMean(allTimeExtremes.get("WEIGHT")?.mean ?? null)
- : summary.count > 0
- ? summary.mean
- : null,
- allTimeMin: allTimeExtremes
- ? (allTimeExtremes.get("WEIGHT")?.min ?? null)
- : summary.count > 0
- ? summary.min
- : null,
- allTimeMax: allTimeExtremes
- ? (allTimeExtremes.get("WEIGHT")?.max ?? null)
- : summary.count > 0
- ? summary.max
- : null,
- slope30: summary.slope30?.slope ?? null,
- outlierCount: summary.anomalyCount ?? 0,
- bmi,
- coverage: computeCoverage(weightData, now),
- };
- const weightTarget = buildWeightTargetFeature(
- resolveWeightTargetOverride(user?.thresholdsJson),
- { avg7: summary.avg7, latest: summary.latest },
- );
- if (weightTarget) features.weight.target = weightTarget;
- }
-
- // Blood Pressure
- const sysData = byType("BLOOD_PRESSURE_SYS");
- const diaData = byType("BLOOD_PRESSURE_DIA");
- if (sysData.length > 0 || diaData.length > 0) {
- const sysSummary =
- sysData.length > 0 ? summarize(toDataPoints(sysData)) : null;
- const diaSummary =
- diaData.length > 0 ? summarize(toDataPoints(diaData)) : null;
-
- let pctInTarget: number | null = null;
- if (bpTargets) {
- const sysByTime = new Map(
- sysData.map((m) => [m.measuredAt.getTime(), m.value]),
- );
- let inTargetCount = 0;
- let pairedCount = 0;
- for (const dia of diaData) {
- const sysVal = sysByTime.get(dia.measuredAt.getTime());
- if (sysVal === undefined) continue;
- pairedCount++;
- // v1.4.16 A2 — one-sided ceiling semantics with hypotension
- // floor. See lib/analytics/bp-in-target.ts.
- if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) {
- inTargetCount++;
- }
- }
- pctInTarget =
- pairedCount > 0
- ? Math.round((inTargetCount / pairedCount) * 100)
- : null;
- }
-
- features.bloodPressure = {
- avgSys30: sysSummary?.avg30 ?? null,
- avgDia30: diaSummary?.avg30 ?? null,
- avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null,
- avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null,
- // v1.18.11 P1 — full-history extremes when the bulk read is windowed.
- allTimeAvgSys: allTimeExtremes
- ? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.mean ?? null)
- : sysSummary?.count
- ? sysSummary.mean
- : null,
- allTimeAvgDia: allTimeExtremes
- ? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.mean ?? null)
- : diaSummary?.count
- ? diaSummary.mean
- : null,
- allTimeMinSys: allTimeExtremes
- ? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.min ?? null)
- : sysSummary?.count
- ? sysSummary.min
- : null,
- allTimeMaxSys: allTimeExtremes
- ? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.max ?? null)
- : sysSummary?.count
- ? sysSummary.max
- : null,
- allTimeMinDia: allTimeExtremes
- ? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.min ?? null)
- : diaSummary?.count
- ? diaSummary.min
- : null,
- allTimeMaxDia: allTimeExtremes
- ? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.max ?? null)
- : diaSummary?.count
- ? diaSummary.max
- : null,
- slopeSys30: sysSummary?.slope30?.slope ?? null,
- slopeDia30: diaSummary?.slope30?.slope ?? null,
- sdSys30: (() => {
- const fromMs = now - 30 * 24 * 60 * 60 * 1000;
- const vals = sysData
- .filter((m) => m.measuredAt.getTime() >= fromMs)
- .map((m) => m.value);
- return stdDev(vals);
- })(),
- sdDia30: (() => {
- const fromMs = now - 30 * 24 * 60 * 60 * 1000;
- const vals = diaData
- .filter((m) => m.measuredAt.getTime() >= fromMs)
- .map((m) => m.value);
- return stdDev(vals);
- })(),
- pulsePressure30: (() => {
- const avgSys = sysSummary?.avg30 ?? null;
- const avgDia = diaSummary?.avg30 ?? null;
- if (avgSys === null || avgDia === null) return null;
- return Math.round((avgSys - avgDia) * 10) / 10;
- })(),
- pctInTarget,
- coverage: computeCoverage(
- [...sysData, ...diaData].sort(
- (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
- ),
- now,
- ),
- };
- }
-
- // Pulse
- const pulseData = byType("PULSE");
- if (pulseData.length > 0) {
- const summary = summarize(toDataPoints(pulseData));
- // A day of pulse is the mean of its local hours' means and a window the
- // mean of its days (see `day-mean.ts`): `summarize` means every reading,
- // so the means come from the shared helper over the same windows.
- const pulseDays = { type: "PULSE", tz: pulseTz };
- const pulseMean = (fromDaysAgo: number) =>
- avgInWindow(pulseData, now, fromDaysAgo, 0, pulseDays);
- const allPulse = readingsMean("PULSE", pulseData, pulseTz);
- features.pulse = {
- avg7: pulseMean(7),
- avg30: pulseMean(30),
- avg90: pulseMean(90),
- // v1.18.11 P1 — full-history extremes when the bulk read is windowed.
- allTimeAvg: allTimeExtremes
- ? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null)
- : allPulse === null
- ? null
- : Math.round(allPulse * 100) / 100,
- allTimeMin: allTimeExtremes
- ? (allTimeExtremes.get("PULSE")?.min ?? null)
- : summary.count > 0
- ? summary.min
- : null,
- allTimeMax: allTimeExtremes
- ? (allTimeExtremes.get("PULSE")?.max ?? null)
- : summary.count > 0
- ? summary.max
- : null,
- slope30: summary.slope30?.slope ?? null,
- anomalyCount: summary.anomalyCount ?? 0,
- coverage: computeCoverage(pulseData, now),
- };
- }
-
- // Body Fat
- const fatData = byType("BODY_FAT");
- if (fatData.length > 0) {
- const summary = summarize(toDataPoints(fatData));
- features.bodyFat = {
- latest: summary.latest,
- avg30: summary.avg30,
- slope30: summary.slope30?.slope ?? null,
- coverage: computeCoverage(fatData, now),
- };
- }
-
- // Grip strength (kg) — clinical-depth signal; the briefing narrates the
- // trajectory, the sex-aware EWGSOP2 floor stays at the display edge.
- const gripData = byType("GRIP_STRENGTH");
- if (gripData.length > 0) {
- const summary = summarize(toDataPoints(gripData));
- features.gripStrength = {
- latest: summary.latest,
- avg30: summary.avg30,
- slope30: summary.slope30?.slope ?? null,
- coverage: computeCoverage(gripData, now),
- };
- }
-
- // Waist circumference (cm) + waist-to-height ratio. WHtR is computed from the
- // freshest circumference and the user's height (the same canonical derivation
- // the detail page uses); omitted when height is unknown.
- const waistData = byType("WAIST_CIRCUMFERENCE");
- if (waistData.length > 0) {
- const summary = summarize(toDataPoints(waistData));
- const whtrLatest =
- user?.heightCm && summary.latest
- ? Math.round((summary.latest / user.heightCm) * 100) / 100
- : null;
- features.waist = {
- latest: summary.latest,
- avg30: summary.avg30,
- slope30: summary.slope30?.slope ?? null,
- whtrLatest,
- coverage: computeCoverage(waistData, now),
- };
- }
-
- // Pain (0–10 NRS, lower-better) — surface a sustained or rising pain burden.
- const painData = byType("PAIN_NRS");
- if (painData.length > 0) {
- const summary = summarize(toDataPoints(painData));
- features.pain = {
- latest: summary.latest,
- avg7: summary.avg7,
- avg30: summary.avg30,
- slope30: summary.slope30?.slope ?? null,
- coverage: computeCoverage(painData, now),
- };
- }
-
- // Sleep Duration
- //
- // SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
- // raw stage rows would average individual stages (and double-count a bare
- // ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
- // feature block through the per-night dedup reconstruction — the same helper
- // the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
- // are per-night TIME-ASLEEP totals in minutes, never stage averages.
- const sleepData = byType("SLEEP_DURATION");
- if (sleepData.length > 0) {
- const [sleepTz, sleepPriority] = await Promise.all([
- resolveUserTimezone(userId),
- loadUserSourcePriority(userId),
- ]);
- const sleepNights = reconstructSleepNights(
- sleepData as unknown as SleepStageRow[],
- sleepTz,
- sleepPriority,
- ).filter((n) => n.asleepMinutes > 0);
- const summary = summarize(
- sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
- );
- features.sleep = {
- avg7: summary.avg7,
- avg30: summary.avg30,
- latest: summary.latest,
- coverage: computeCoverage(
- sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
- now,
- ),
- };
- }
-
- // Activity Steps
- const activityData = byType("ACTIVITY_STEPS");
- if (activityData.length > 0) {
- const summary = summarize(toDataPoints(activityData));
- features.activity = {
- avg7: summary.avg7,
- avg30: summary.avg30,
- latest: summary.latest,
- coverage: computeCoverage(activityData, now),
- };
- }
-
+ /** The Coach's lookback floor, or null: see `extractFeatures`. */
+ historyFloor: Date | null,
+): Promise<{
+ feature: AggregatedFeatures["mood"];
+ dailyPoints: Array<{ measuredAt: Date; value: number; count: number }>;
+}> {
+ let feature: AggregatedFeatures["mood"];
// Mood
//
// v1.4.40 — swap the unbounded `prisma.moodEntry.findMany` for the
@@ -1553,7 +1136,7 @@ export async function extractFeatures(
? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10
: null;
- features.mood = {
+ feature = {
scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
avg7: avg(last7),
avg30: avg(last30),
@@ -1577,6 +1160,491 @@ export async function extractFeatures(
};
}
+ return { feature, dailyPoints: moodDailyPoints };
+}
+
+/**
+ * v1.25 — newest-first row cap on the bulk measurement read in
+ * `extractFeatures`. Mirrors the Coach snapshot's `SNAPSHOT_MEASUREMENT_ROW_CAP`
+ * (6000): the prompt / insights aggregates only ever fold this read into a
+ * bounded set of summaries, so a year of dense PULSE / glucose rows is wasted
+ * I/O on the shared pool for a heavy-data tenant. Full-history extremes come
+ * from `readAllTimeExtremes`, not this read, so the cap stays correct.
+ */
+const FEATURE_MEASUREMENT_ROW_CAP = 6000;
+
+/**
+ * The feature blocks the single-metric Coach read can ask for on their own.
+ * Each is built from exactly the reads the full extraction runs for it.
+ */
+export type ScopedFeatureBlock = "weight" | "bloodPressure" | "pulse" | "mood";
+
+const SCOPED_BLOCK_TYPES: Readonly<
+ Record
+> = {
+ weight: ["WEIGHT"],
+ bloodPressure: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
+ pulse: ["PULSE"],
+ mood: [],
+};
+
+export async function extractFeatures(
+ userId: string,
+ includeRaw: boolean,
+ options: {
+ sinceDays?: number;
+ /**
+ * Build only these blocks (plus `context`) and return. Each block is
+ * identical to the one the full extraction builds: the bulk read keeps its
+ * all-type newest-first cap, so the rows a block sees do not change. Used
+ * by the single-metric Coach read, which reads one block and nothing else.
+ */
+ only?: ReadonlySet;
+ /**
+ * The Coach's lookback floor. Given, nothing older than it is read: the
+ * `allTime*` figures cover the history from the floor on, the mood
+ * history and its sub-blocks start at it, and the mood forecast, a model
+ * fitted on the whole history, is left out.
+ */
+ historyFloor?: Date;
+ } = {},
+): Promise {
+ const only = options.only;
+ const historyFloor = options.historyFloor ?? null;
+ const wants = (block: ScopedFeatureBlock) => !only || only.has(block);
+ const allTimeTypes = (["weight", "bloodPressure", "pulse"] as const).flatMap(
+ (block) => (wants(block) ? SCOPED_BLOCK_TYPES[block] : []),
+ );
+ // The bulk read feeds every block except mood; a mood-only read skips it.
+ const needsMeasurementRows = !only || allTimeTypes.length > 0;
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ heightCm: true,
+ dateOfBirth: true,
+ gender: true,
+ thresholdsJson: true,
+ },
+ });
+
+ const now = Date.now();
+
+ // Fetch measurements. Default = ALL (full temporal context for the
+ // dashboard / insights generator). Callers that only consume the
+ // ≤90-day windows (Coach snapshot per turn) pass `sinceDays: 90` so
+ // the per-turn I/O stays bounded — `findMany({ where: { userId } })`
+ // is unbounded by user-history size and gets paid once per Coach turn
+ // for power users with multi-year Withings imports.
+ const sinceDays = options.sinceDays;
+ const sinceCutoff =
+ typeof sinceDays === "number" && sinceDays > 0
+ ? new Date(now - sinceDays * 24 * 60 * 60 * 1000)
+ : null;
+ // v1.25 — newest-first cap on the bulk feature read, mirroring the Coach
+ // snapshot (`SNAPSHOT_MEASUREMENT_ROW_CAP`). Callers pass windows of 365-400
+ // days, and PULSE / glucose are 200k-row-class types, so an uncapped read
+ // pulled hundreds of thousands of rows per Coach turn and nightly briefing
+ // only to fold them into a handful of summaries. Read newest-first, cap, then
+ // reverse so every downstream consumer (`byType`, `summarize`, BP pairing,
+ // sleep reconstruction, oldest/newest span) still sees ascending order. The
+ // cap is safe even unbounded: the genuine all-time extremes are sourced
+ // separately via `readAllTimeExtremes` on the bounded branch, so capping only
+ // sheds the deepest rows of the bulk read and never relabels an extreme.
+ const measurements = !needsMeasurementRows
+ ? []
+ : await prisma.measurement
+ .findMany({
+ where: sinceCutoff
+ ? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null }
+ : { userId, deletedAt: null },
+ orderBy: { measuredAt: "desc" },
+ take: FEATURE_MEASUREMENT_ROW_CAP,
+ // Project only the columns every downstream consumer reads (`byType`,
+ // `summarize`, BP pairing, and `reconstructSleepNights`'s `SleepStageRow`).
+ // The PULSE / glucose windows are 200k-row-class; pulling every column
+ // (notes, externalId, …) is pure wasted I/O on the shared Prisma pool.
+ select: {
+ type: true,
+ value: true,
+ measuredAt: true,
+ sleepStage: true,
+ source: true,
+ deviceType: true,
+ },
+ })
+ // Restore ascending order so order-sensitive consumers (oldest/newest span,
+ // BP pairing, sleep reconstruction) see the same shape as before the cap.
+ .then((rows) => rows.reverse());
+
+ // v1.18.11 P1 — when the bulk read is bounded to a recent window, the
+ // windowed `summarize()` no longer covers the full history, so the `allTime*`
+ // fields would silently become "last `sinceDays` days" extremes. Source the
+ // genuine full-history min / max / mean from one grouped aggregation (no row
+ // materialisation) so the labels stay honest. Unbounded reads (sinceCutoff
+ // null) already see the whole history and skip the extra query.
+ // Grouped by type, so asking for fewer types leaves each one's figures as
+ // they were.
+ const allTimeExtremes = sinceCutoff
+ ? await readAllTimeExtremes(
+ userId,
+ only
+ ? allTimeTypes
+ : ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
+ historyFloor,
+ )
+ : null;
+
+ const byType = (type: string) => measurements.filter((m) => m.type === type);
+ // The zone pulse days are read in; resolved only when there is pulse to read.
+ const pulseTz = measurements.some((m) => m.type === "PULSE")
+ ? await resolveUserTimezone(userId)
+ : DEFAULT_TIMEZONE;
+
+ const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
+
+ // Compute overall data span
+ const oldestMeasurement =
+ measurements.length > 0 ? measurements[0].measuredAt : null;
+ const newestMeasurement =
+ measurements.length > 0
+ ? measurements[measurements.length - 1].measuredAt
+ : null;
+ const overallSpanDays =
+ oldestMeasurement && newestMeasurement
+ ? Math.round(
+ (newestMeasurement.getTime() - oldestMeasurement.getTime()) /
+ (24 * 60 * 60 * 1000),
+ )
+ : 0;
+
+ // Compute age
+ let ageYears: number | null = null;
+ if (user?.dateOfBirth) {
+ const dob = user.dateOfBirth;
+ const today = new Date();
+ ageYears = today.getFullYear() - dob.getFullYear();
+ const monthDiff = today.getMonth() - dob.getMonth();
+ if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
+ ageYears--;
+ }
+ }
+
+ const features: AggregatedFeatures = {
+ context: {
+ heightCm: user?.heightCm ?? null,
+ hasBpTargets: !!bpTargets,
+ totalMeasurements: measurements.length,
+ dataSpanDays: overallSpanDays,
+ oldestMeasurementDaysAgo: oldestMeasurement
+ ? Math.round(
+ (now - oldestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
+ )
+ : null,
+ newestMeasurementDaysAgo: newestMeasurement
+ ? Math.round(
+ (now - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
+ )
+ : null,
+ ageYears,
+ gender: user?.gender ?? null,
+ },
+ };
+
+ // Weight
+ const weightData = byType("WEIGHT");
+ if (wants("weight") && weightData.length > 0) {
+ const summary = summarize(toDataPoints(weightData));
+ const bmi =
+ user?.heightCm && summary.latest
+ ? parseFloat((summary.latest / (user.heightCm / 100) ** 2).toFixed(1))
+ : null;
+
+ features.weight = {
+ latest: summary.latest!,
+ avg7: summary.avg7,
+ avg30: summary.avg30,
+ avg90: avgInWindow(weightData, now, 90),
+ // v1.18.11 P1 — full-history extremes from the grouped aggregation when
+ // the bulk read is windowed; the in-window summary otherwise.
+ allTimeAvg: allTimeExtremes
+ ? roundMean(allTimeExtremes.get("WEIGHT")?.mean ?? null)
+ : summary.count > 0
+ ? summary.mean
+ : null,
+ allTimeMin: allTimeExtremes
+ ? (allTimeExtremes.get("WEIGHT")?.min ?? null)
+ : summary.count > 0
+ ? summary.min
+ : null,
+ allTimeMax: allTimeExtremes
+ ? (allTimeExtremes.get("WEIGHT")?.max ?? null)
+ : summary.count > 0
+ ? summary.max
+ : null,
+ slope30: summary.slope30?.slope ?? null,
+ outlierCount: summary.anomalyCount ?? 0,
+ bmi,
+ coverage: computeCoverage(weightData, now),
+ };
+ const weightTarget = buildWeightTargetFeature(
+ resolveWeightTargetOverride(user?.thresholdsJson),
+ { avg7: summary.avg7, latest: summary.latest },
+ );
+ if (weightTarget) features.weight.target = weightTarget;
+ }
+
+ // Blood Pressure
+ const sysData = byType("BLOOD_PRESSURE_SYS");
+ const diaData = byType("BLOOD_PRESSURE_DIA");
+ if (wants("bloodPressure") && (sysData.length > 0 || diaData.length > 0)) {
+ const sysSummary =
+ sysData.length > 0 ? summarize(toDataPoints(sysData)) : null;
+ const diaSummary =
+ diaData.length > 0 ? summarize(toDataPoints(diaData)) : null;
+
+ let pctInTarget: number | null = null;
+ if (bpTargets) {
+ const sysByTime = new Map(
+ sysData.map((m) => [m.measuredAt.getTime(), m.value]),
+ );
+ let inTargetCount = 0;
+ let pairedCount = 0;
+ for (const dia of diaData) {
+ const sysVal = sysByTime.get(dia.measuredAt.getTime());
+ if (sysVal === undefined) continue;
+ pairedCount++;
+ // v1.4.16 A2 — one-sided ceiling semantics with hypotension
+ // floor. See lib/analytics/bp-in-target.ts.
+ if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) {
+ inTargetCount++;
+ }
+ }
+ pctInTarget =
+ pairedCount > 0
+ ? Math.round((inTargetCount / pairedCount) * 100)
+ : null;
+ }
+
+ features.bloodPressure = {
+ avgSys30: sysSummary?.avg30 ?? null,
+ avgDia30: diaSummary?.avg30 ?? null,
+ avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null,
+ avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null,
+ // v1.18.11 P1 — full-history extremes when the bulk read is windowed.
+ allTimeAvgSys: allTimeExtremes
+ ? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.mean ?? null)
+ : sysSummary?.count
+ ? sysSummary.mean
+ : null,
+ allTimeAvgDia: allTimeExtremes
+ ? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.mean ?? null)
+ : diaSummary?.count
+ ? diaSummary.mean
+ : null,
+ allTimeMinSys: allTimeExtremes
+ ? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.min ?? null)
+ : sysSummary?.count
+ ? sysSummary.min
+ : null,
+ allTimeMaxSys: allTimeExtremes
+ ? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.max ?? null)
+ : sysSummary?.count
+ ? sysSummary.max
+ : null,
+ allTimeMinDia: allTimeExtremes
+ ? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.min ?? null)
+ : diaSummary?.count
+ ? diaSummary.min
+ : null,
+ allTimeMaxDia: allTimeExtremes
+ ? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.max ?? null)
+ : diaSummary?.count
+ ? diaSummary.max
+ : null,
+ slopeSys30: sysSummary?.slope30?.slope ?? null,
+ slopeDia30: diaSummary?.slope30?.slope ?? null,
+ sdSys30: (() => {
+ const fromMs = now - 30 * 24 * 60 * 60 * 1000;
+ const vals = sysData
+ .filter((m) => m.measuredAt.getTime() >= fromMs)
+ .map((m) => m.value);
+ return stdDev(vals);
+ })(),
+ sdDia30: (() => {
+ const fromMs = now - 30 * 24 * 60 * 60 * 1000;
+ const vals = diaData
+ .filter((m) => m.measuredAt.getTime() >= fromMs)
+ .map((m) => m.value);
+ return stdDev(vals);
+ })(),
+ pulsePressure30: (() => {
+ const avgSys = sysSummary?.avg30 ?? null;
+ const avgDia = diaSummary?.avg30 ?? null;
+ if (avgSys === null || avgDia === null) return null;
+ return Math.round((avgSys - avgDia) * 10) / 10;
+ })(),
+ pctInTarget,
+ coverage: computeCoverage(
+ [...sysData, ...diaData].sort(
+ (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
+ ),
+ now,
+ ),
+ };
+ }
+
+ // Pulse
+ const pulseData = byType("PULSE");
+ if (wants("pulse") && pulseData.length > 0) {
+ const summary = summarize(toDataPoints(pulseData));
+ // A day of pulse is the mean of its local hours' means and a window the
+ // mean of its days (see `day-mean.ts`): `summarize` means every reading,
+ // so the means come from the shared helper over the same windows.
+ const pulseDays = { type: "PULSE", tz: pulseTz };
+ const pulseMean = (fromDaysAgo: number) =>
+ avgInWindow(pulseData, now, fromDaysAgo, 0, pulseDays);
+ const allPulse = readingsMean("PULSE", pulseData, pulseTz);
+ features.pulse = {
+ avg7: pulseMean(7),
+ avg30: pulseMean(30),
+ avg90: pulseMean(90),
+ // v1.18.11 P1 — full-history extremes when the bulk read is windowed.
+ allTimeAvg: allTimeExtremes
+ ? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null)
+ : allPulse === null
+ ? null
+ : Math.round(allPulse * 100) / 100,
+ allTimeMin: allTimeExtremes
+ ? (allTimeExtremes.get("PULSE")?.min ?? null)
+ : summary.count > 0
+ ? summary.min
+ : null,
+ allTimeMax: allTimeExtremes
+ ? (allTimeExtremes.get("PULSE")?.max ?? null)
+ : summary.count > 0
+ ? summary.max
+ : null,
+ slope30: summary.slope30?.slope ?? null,
+ anomalyCount: summary.anomalyCount ?? 0,
+ coverage: computeCoverage(pulseData, now),
+ };
+ }
+
+ if (only) {
+ if (only.has("mood")) {
+ const moodRead = await readMoodFeature(userId, historyFloor);
+ if (moodRead.feature) features.mood = moodRead.feature;
+ }
+ return features;
+ }
+
+ // Body Fat
+ const fatData = byType("BODY_FAT");
+ if (fatData.length > 0) {
+ const summary = summarize(toDataPoints(fatData));
+ features.bodyFat = {
+ latest: summary.latest,
+ avg30: summary.avg30,
+ slope30: summary.slope30?.slope ?? null,
+ coverage: computeCoverage(fatData, now),
+ };
+ }
+
+ // Grip strength (kg) — clinical-depth signal; the briefing narrates the
+ // trajectory, the sex-aware EWGSOP2 floor stays at the display edge.
+ const gripData = byType("GRIP_STRENGTH");
+ if (gripData.length > 0) {
+ const summary = summarize(toDataPoints(gripData));
+ features.gripStrength = {
+ latest: summary.latest,
+ avg30: summary.avg30,
+ slope30: summary.slope30?.slope ?? null,
+ coverage: computeCoverage(gripData, now),
+ };
+ }
+
+ // Waist circumference (cm) + waist-to-height ratio. WHtR is computed from the
+ // freshest circumference and the user's height (the same canonical derivation
+ // the detail page uses); omitted when height is unknown.
+ const waistData = byType("WAIST_CIRCUMFERENCE");
+ if (waistData.length > 0) {
+ const summary = summarize(toDataPoints(waistData));
+ const whtrLatest =
+ user?.heightCm && summary.latest
+ ? Math.round((summary.latest / user.heightCm) * 100) / 100
+ : null;
+ features.waist = {
+ latest: summary.latest,
+ avg30: summary.avg30,
+ slope30: summary.slope30?.slope ?? null,
+ whtrLatest,
+ coverage: computeCoverage(waistData, now),
+ };
+ }
+
+ // Pain (0–10 NRS, lower-better) — surface a sustained or rising pain burden.
+ const painData = byType("PAIN_NRS");
+ if (painData.length > 0) {
+ const summary = summarize(toDataPoints(painData));
+ features.pain = {
+ latest: summary.latest,
+ avg7: summary.avg7,
+ avg30: summary.avg30,
+ slope30: summary.slope30?.slope ?? null,
+ coverage: computeCoverage(painData, now),
+ };
+ }
+
+ // Sleep Duration
+ //
+ // SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
+ // raw stage rows would average individual stages (and double-count a bare
+ // ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
+ // feature block through the per-night dedup reconstruction — the same helper
+ // the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
+ // are per-night TIME-ASLEEP totals in minutes, never stage averages.
+ const sleepData = byType("SLEEP_DURATION");
+ if (sleepData.length > 0) {
+ const [sleepTz, sleepPriority] = await Promise.all([
+ resolveUserTimezone(userId),
+ loadUserSourcePriority(userId),
+ ]);
+ const sleepNights = reconstructSleepNights(
+ sleepData as unknown as SleepStageRow[],
+ sleepTz,
+ sleepPriority,
+ ).filter((n) => n.asleepMinutes > 0);
+ const summary = summarize(
+ sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
+ );
+ features.sleep = {
+ avg7: summary.avg7,
+ avg30: summary.avg30,
+ latest: summary.latest,
+ coverage: computeCoverage(
+ sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
+ now,
+ ),
+ };
+ }
+
+ // Activity Steps
+ const activityData = byType("ACTIVITY_STEPS");
+ if (activityData.length > 0) {
+ const summary = summarize(toDataPoints(activityData));
+ features.activity = {
+ avg7: summary.avg7,
+ avg30: summary.avg30,
+ latest: summary.latest,
+ coverage: computeCoverage(activityData, now),
+ };
+ }
+
+ // Mood — see `readMoodFeature`.
+ const moodRead = await readMoodFeature(userId, historyFloor);
+ const moodDailyPoints = moodRead.dailyPoints;
+ if (moodRead.feature) features.mood = moodRead.feature;
+
// Cross-metric correlations
const weightPoints = toDataPoints(weightData);
const sysPoints = toDataPoints(sysData);
diff --git a/src/lib/jobs/__tests__/backup-pass-attempts.test.ts b/src/lib/jobs/__tests__/backup-pass-attempts.test.ts
new file mode 100644
index 000000000..722fec5ff
--- /dev/null
+++ b/src/lib/jobs/__tests__/backup-pass-attempts.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ heldBackAccounts,
+ orderInterruptedLast,
+} from "../backup-pass-attempts";
+
+type Row = { userId: string; startedAt: Date; finishedAt: Date | null };
+
+/**
+ * A weekly-style pass over `ids`: oldest copy first, held-back accounts last,
+ * at most `budget` accounts per run, `kills` the accounts whose record kills
+ * the process. Returns the accounts copied in each run.
+ */
+function simulate(opts: {
+ ids: string[];
+ budget: number;
+ kills: Set;
+ runs: number;
+ interruptedFirst?: string;
+}): string[][] {
+ let clock = 0;
+ const attempts = new Map();
+ const copiedAt = new Map();
+ if (opts.interruptedFirst) {
+ attempts.set(opts.interruptedFirst, {
+ userId: opts.interruptedFirst,
+ startedAt: new Date(++clock),
+ finishedAt: null,
+ });
+ }
+ const copied: string[][] = [];
+ for (let run = 0; run < opts.runs; run += 1) {
+ const order = orderInterruptedLast(
+ [...opts.ids]
+ .map((id) => ({ id }))
+ .sort(
+ (a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1),
+ ),
+ heldBackAccounts([...attempts.values()]),
+ );
+ const thisRun: string[] = [];
+ for (const { id } of order.slice(0, opts.budget)) {
+ const row = attempts.get(id) ?? {
+ userId: id,
+ startedAt: new Date(0),
+ finishedAt: null,
+ };
+ row.startedAt = new Date(++clock);
+ attempts.set(id, row);
+ if (opts.kills.has(id)) break; // the process dies; the run ends here
+ row.finishedAt = new Date(++clock);
+ copiedAt.set(id, clock);
+ thisRun.push(id);
+ }
+ copied.push(thisRun);
+ }
+ return copied;
+}
+
+describe("heldBackAccounts", () => {
+ const at = (ms: number) => new Date(ms);
+
+ it("holds back an interrupted attempt that is the newest of the pass", () => {
+ const held = heldBackAccounts([
+ { userId: "x", startedAt: at(30), finishedAt: at(10) },
+ { userId: "a", startedAt: at(20), finishedAt: at(21) },
+ ]);
+ expect([...held.keys()]).toEqual(["x"]);
+ });
+
+ it("releases it once a later run has started on another account", () => {
+ const held = heldBackAccounts([
+ { userId: "x", startedAt: at(30), finishedAt: null },
+ { userId: "a", startedAt: at(40), finishedAt: at(41) },
+ ]);
+ expect(held.size).toBe(0);
+ });
+
+ it("ignores finished attempts", () => {
+ const held = heldBackAccounts([
+ { userId: "a", startedAt: at(40), finishedAt: at(41) },
+ ]);
+ expect(held.size).toBe(0);
+ });
+});
+
+describe("the order across runs", () => {
+ const ids = ["x", "a", "b", "c", "d"];
+
+ it("keeps a record that kills the process from costing the others their copies", () => {
+ // `x` has the oldest copy and kills every run that reaches it.
+ const runs = simulate({ ids, budget: 99, kills: new Set(["x"]), runs: 6 });
+ // The first run meets it unwarned; from then on it goes last every run,
+ // because each crash makes its attempt the newest again.
+ expect(runs[0]).toEqual([]);
+ for (const run of runs.slice(1)) {
+ expect([...run].sort()).toEqual(["a", "b", "c", "d"]);
+ }
+ });
+
+ it("still copies an interrupted account when every run stops on its budget", () => {
+ // `x` was interrupted once (a restart, not its record). Every run stops
+ // after three accounts, so kept last for good it would never be reached.
+ const runs = simulate({
+ ids,
+ budget: 3,
+ kills: new Set(),
+ runs: 4,
+ interruptedFirst: "x",
+ });
+ const firstCopy = runs.findIndex((run) => run.includes("x"));
+ expect(firstCopy).toBeGreaterThanOrEqual(0);
+ expect(firstCopy).toBeLessThanOrEqual(1);
+ // And every account gets a copy within the four runs.
+ expect(new Set(runs.flat())).toEqual(new Set(ids));
+ });
+});
diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts
index 95702c8c7..368b4c2d5 100644
--- a/src/lib/jobs/__tests__/offhost-backup.test.ts
+++ b/src/lib/jobs/__tests__/offhost-backup.test.ts
@@ -2,10 +2,8 @@ import { createCipheriv, randomBytes } from "node:crypto";
import type { Readable } from "node:stream";
import { describe, it, expect, beforeEach, vi } from "vitest";
-import {
- BACKUP_SCHEMA_VERSION,
- backupPayloadSchema,
-} from "@/lib/validations/backup";
+import { backupPayloadSchema } from "@/lib/validations/backup";
+import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary";
const mocks = vi.hoisted(() => ({
buildFullBackupPayload: vi.fn(),
diff --git a/src/lib/jobs/__tests__/restore-drill.test.ts b/src/lib/jobs/__tests__/restore-drill.test.ts
index d223c5a45..81eb035ef 100644
--- a/src/lib/jobs/__tests__/restore-drill.test.ts
+++ b/src/lib/jobs/__tests__/restore-drill.test.ts
@@ -7,7 +7,9 @@ import {
runRestoreDrill,
RESTORE_DRILL_CRON,
RESTORE_DRILL_QUEUE,
+ RESTORE_DRILL_SEND_OPTIONS,
} from "../restore-drill";
+import { QUEUE_RUNTIME } from "../queue-runtime";
vi.mock("../report-worker-error", () => ({
reportWorkerError: vi.fn().mockResolvedValue(undefined),
@@ -86,6 +88,36 @@ describe("runRestoreDrill", () => {
expect(RESTORE_DRILL_CRON).toBe("11 4 1 * *");
});
+ it("runs as a locked long pass that pages each account once per run", () => {
+ // Every account's object is read, so the pg-boss default of fifteen
+ // minutes is no ceiling for it; no retry, so a failed account is paged
+ // once per run instead of three times.
+ expect(RESTORE_DRILL_SEND_OPTIONS.expireInSeconds).toBeGreaterThan(15 * 60);
+ expect(RESTORE_DRILL_SEND_OPTIONS.retryLimit).toBe(0);
+ expect(QUEUE_RUNTIME[RESTORE_DRILL_QUEUE]).toMatchObject({
+ runtime: "long",
+ exclusive: "lockedPass",
+ });
+ });
+
+ it("stops between accounts on its budget and counts the ones it did not reach", async () => {
+ const s3 = makeS3Mock({
+ "2026-06-01/user-aaa.json.enc": backupObject({ userId: "user-aaa" }),
+ "2026-06-01/user-mmm.json.enc": backupObject({ userId: "user-mmm" }),
+ "2026-06-01/user-zzz.json.enc": backupObject({ userId: "user-zzz" }),
+ });
+ let asked = 0;
+ const report = await runRestoreDrill(
+ s3,
+ new Date("2026-06-02T04:11:00Z"),
+ () => ++asked > 1,
+ );
+ expect(report.accounts.map((a) => a.objectKey)).toEqual([
+ "2026-06-01/user-aaa.json.enc",
+ ]);
+ expect(report.unchecked).toBe(2);
+ });
+
it("fetches, decrypts, and parses the newest backup object", async () => {
const s3 = makeS3Mock({
"2026-05-30/user-old.json.enc": backupObject(),
diff --git a/src/lib/jobs/backup-pass-attempts.ts b/src/lib/jobs/backup-pass-attempts.ts
index 05c0d919b..389252334 100644
--- a/src/lib/jobs/backup-pass-attempts.ts
+++ b/src/lib/jobs/backup-pass-attempts.ts
@@ -16,6 +16,16 @@
* takes that account last, after everybody else has their copy, and the admin
* backups page names it. Every write and read here is best effort: the marker
* is bookkeeping about a backup, and must never be the reason one fails.
+ *
+ * Last only once. A pass that regularly stops on its time budget never reaches
+ * the end of its list, so an account kept last for as long as its attempt
+ * stays open would never be attempted again, and would never get a copy. An
+ * interrupted account is therefore held back only until a later run has
+ * started on another account (`accountsToTakeLast`): from then on it takes its
+ * ordinary place again. If it kills the process again, its new attempt is the
+ * newest one and it goes last once more. So a crash loop costs the other
+ * accounts at most every second run, and the account itself is attempted at
+ * least every second run.
*/
import type { PrismaClient } from "@/generated/prisma/client";
@@ -83,7 +93,51 @@ export async function readInterruptedBackupAttempts(
}
/**
- * `accounts` in their order, with every interrupted one moved behind the
+ * The interrupted accounts a pass should take last: those whose interrupted
+ * attempt is still the newest attempt of the pass, i.e. no later run has
+ * started on another account since. Rows are one per account. Pure, so the
+ * rule can be tested apart from the database.
+ */
+export function heldBackAccounts(
+ rows: ReadonlyArray<{
+ userId: string;
+ startedAt: Date;
+ finishedAt: Date | null;
+ }>,
+): Map {
+ const out = new Map();
+ for (const row of rows) {
+ const open = row.finishedAt === null || row.finishedAt < row.startedAt;
+ if (!open) continue;
+ const passedOver = rows.some(
+ (other) => other.userId !== row.userId && other.startedAt > row.startedAt,
+ );
+ if (!passedOver) out.set(row.userId, row.startedAt);
+ }
+ return out;
+}
+
+/**
+ * What a pass orders by: the interrupted accounts it takes last
+ * (`heldBackAccounts`). Empty when the table cannot be read.
+ */
+export async function readAccountsToTakeLast(
+ prisma: Db,
+ pass: BackupPass,
+): Promise