From 546e67475f09370ee9a12d084248232899661dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 17:58:53 +0200 Subject: [PATCH 01/17] perf(mcp): build single-metric reads from the metric's own rows get_metric_series (and get_metrics, the metric resources and the review prompts, which fan out over it) answered with one section of a Coach snapshot, but built the whole snapshot to get it: the all-type feature extraction, the derived wellness layer, trajectory, correlations, memory, GLP-1, cycle, illness, labs and visits. On a seeded account with about 215k measurements a resting heart rate read ran 208 SQL statements and blocked the event loop for 175 ms to return under 2 KB. The MCP surface now asks the executor for a source snapshot. It runs the same prelude (prefs, module gates, admitted sources, cutoffs, now shared in snapshot-prelude.ts) and the same block builders over the requested source's reads only. 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 do not change. A section large enough for the full builder's prompt-budget pass to trim, and the environment cluster, go to the full builder, so their results are the old ones by construction. The integration test compares the tool's serialised result with the full builder's section and grounding for nine sources over four windows, the get_metrics batch and the metric resource, on a seeded account dense enough to put the feature row cap into play. --- .../snapshot-blocks/value-series-blocks.ts | 5 + src/lib/ai/coach/snapshot-prelude.ts | 317 ++++++ src/lib/ai/coach/snapshot.ts | 329 +------ src/lib/ai/coach/source-snapshot.ts | 265 +++++ src/lib/ai/coach/tools/executor.ts | 45 +- src/lib/insights/features.ts | 914 ++++++++++-------- src/lib/mcp/__tests__/tools.test.ts | 1 + src/lib/mcp/prompts.ts | 1 + src/lib/mcp/resources.ts | 2 + src/lib/mcp/tools.ts | 5 + .../mcp-metric-series-source-snapshot.test.ts | 292 ++++++ 11 files changed, 1461 insertions(+), 715 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-prelude.ts create mode 100644 src/lib/ai/coach/source-snapshot.ts create mode 100644 tests/integration/mcp-metric-series-source-snapshot.test.ts 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..1335a2a0f --- /dev/null +++ b/src/lib/ai/coach/snapshot-prelude.ts @@ -0,0 +1,317 @@ +/** + * 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 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, +) { + 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 } = resolveScope( + scope, + clusterDefault, + ); + 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 d3115a4e7..c89f9cd21 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"; @@ -140,28 +128,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 @@ -174,37 +140,6 @@ const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000; */ const SLEEP_RHYTHM_WINDOW_DAYS = 42; -/** - * 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 @@ -269,101 +204,37 @@ async function buildCoachSnapshotImpl( userId: string, scope?: CoachScope, ): 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 } = resolveScope( - scope, - clusterDefault, - ); - const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; - const units = resolveUnitPreferences({ - unitPreference: prefsRow?.unitPreference, - glucoseUnit: prefsRow?.glucoseUnit, - }); + // Prefs, module gates and the admitted sources — see `snapshot-prelude.ts`. + const { + prefsRow, + coachLocale, + window, + userTz, + units, + recoveryDisabled, + excluded, + sources, + } = await resolveSnapshotPrelude(userId, scope); 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 @@ -422,25 +293,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. @@ -452,25 +304,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"); @@ -532,42 +365,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 @@ -614,14 +416,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 @@ -671,24 +466,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), @@ -701,25 +479,10 @@ async function buildCoachSnapshotImpl( const sleepRhythmCutoff = new Date( now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000, ); + // source + deviceType feed the canonical writer-dedup so a multi-source + // night is counted ONCE, matching every other sleep surface. const sleepRhythmRowsPromise = sources.has("sleep") - ? 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 814842567..c7b52b897 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"; @@ -241,11 +242,25 @@ 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; }): Promise { - const { userId, name, rawArguments, fallbackWindow, sharedScope, turn } = - args; + const { + userId, + name, + rawArguments, + fallbackWindow, + sharedScope, + sourceSnapshot = false, + turn, + } = args; if (!isCoachToolName(name) && name !== SHOW_RESULT_TOOL_NAME) { annotate({ @@ -277,6 +292,7 @@ export async function executeCoachTool(args: { fallbackWindow, sharedScope, turn, + sourceSnapshot, ); annotate({ action: { name: "coach.tool.executed" }, @@ -304,6 +320,7 @@ async function dispatch( fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, turn: CoachToolTurnContext | undefined, + sourceSnapshot: boolean, ): Promise { const result = await dispatchRead( name, @@ -312,6 +329,7 @@ async function dispatch( fallbackWindow, sharedScope, turn, + sourceSnapshot, ); if (!turn || !result.present || result.table) return result; return withProjectedTable( @@ -331,10 +349,17 @@ async function dispatchRead( fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, turn: CoachToolTurnContext | undefined, + sourceSnapshot: boolean, ): Promise { switch (name) { case "get_metric_series": - return getMetricSeries(userId, rawArgs, fallbackWindow, sharedScope); + return getMetricSeries( + userId, + rawArgs, + fallbackWindow, + sharedScope, + sourceSnapshot, + ); case "get_glucose_panel": return getGlucosePanel(userId, rawArgs, fallbackWindow, sharedScope); case "get_sleep": @@ -388,6 +413,7 @@ async function getMetricSeries( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + sourceSnapshot: boolean, ): Promise { const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_metric_series", parsed.error); @@ -413,10 +439,15 @@ async function getMetricSeries( return { present: false, reason: "unsupported_metric" }; } - const snapshot = await buildCoachSnapshot( - userId, - scopeFor([metric], window, fallbackWindow, sharedScope), - ); + // 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`. + const snapshot = sourceSnapshot + ? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow) + : await buildCoachSnapshot( + userId, + scopeFor([metric], window, fallbackWindow, sharedScope), + ); const section = pickSection(snapshot.sections, sectionKey); if (section === undefined) { return emptyRead( diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 48f9497d3..769191201 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 { @@ -959,430 +962,15 @@ 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( - userId: string, - includeRaw: boolean, - options: { sinceDays?: number } = {}, -): Promise { - 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", - ]) - : 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), - }; - } - +async function readMoodFeature(userId: string): 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 @@ -1516,7 +1104,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), @@ -1540,6 +1128,482 @@ 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; + } = {}, +): Promise { + const only = options.only; + 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"], + ) + : 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); + 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); + 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/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 7b223ba77..57e17001f 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -344,6 +344,7 @@ describe("get_metric_series", () => { expect(executeCoachTool).toHaveBeenCalledWith({ userId: "user-1", + sourceSnapshot: true, name: "get_metric_series", rawArguments: JSON.stringify({ metric: "bp", window: "last90days" }), }); diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 1ac52dd8e..bec471a62 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -157,6 +157,7 @@ async function read( ): Promise { return executeCoachTool({ userId: ctx.userId, + sourceSnapshot: true, name, rawArguments: JSON.stringify(args), }); diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index f0724414e..d3374d90c 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -460,6 +460,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [ const metric = firstVar(variables.type); const result = await executeCoachTool({ userId: ctx.userId, + sourceSnapshot: true, name: "get_metric_series", rawArguments: JSON.stringify({ metric }), }); @@ -483,6 +484,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [ const window = firstVar(variables.window); const result = await executeCoachTool({ userId: ctx.userId, + sourceSnapshot: true, name: "get_metric_series", rawArguments: JSON.stringify(window ? { metric, window } : { metric }), }); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 1940e76c4..8a333944a 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -139,6 +139,9 @@ async function runCoachTool( ): Promise { const result = await executeCoachTool({ userId: ctx.userId, + // The single-metric read keeps one section, so it is built from that + // source's rows (see `source-snapshot.ts`). + ...(name === "get_metric_series" ? { sourceSnapshot: true } : {}), name, rawArguments: JSON.stringify(args ?? {}), }); @@ -654,6 +657,7 @@ function searchAndFetchTools(): McpToolDefinition[] { } const result = await executeCoachTool({ userId: ctx.userId, + sourceSnapshot: true, name: "get_metric_series", rawArguments: JSON.stringify({ metric: rid }), }); @@ -1566,6 +1570,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ page.map(async (metric) => { const r = (await executeCoachTool({ userId: ctx.userId, + sourceSnapshot: true, name: "get_metric_series", rawArguments: JSON.stringify({ metric, diff --git a/tests/integration/mcp-metric-series-source-snapshot.test.ts b/tests/integration/mcp-metric-series-source-snapshot.test.ts new file mode 100644 index 000000000..62294845a --- /dev/null +++ b/tests/integration/mcp-metric-series-source-snapshot.test.ts @@ -0,0 +1,292 @@ +/** + * The MCP single-metric reads build one source's section from that source's + * rows (`source-snapshot.ts`) instead of a whole Coach snapshot. The promise + * is that nothing a client receives changes, so this pins it against the full + * builder on real rows: for every seeded source, across windows, the tool's + * result serialises to exactly what `buildCoachSnapshot` scoped to that source + * yields for the same section and grounding — the result `get_metric_series` + * returned before. A dense series pushes the feature extraction's all-type row + * cap into play, a year of blood pressure is large enough to go back to the + * full builder, and an absent source stays an explicit absence. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { MCP_TOOLS } from "@/lib/mcp/tools"; +import { MCP_RESOURCE_TEMPLATES } from "@/lib/mcp/resources"; +import type { McpAuthContext } from "@/lib/mcp/auth"; +import { + __resetCoachSnapshotCacheForTests, + buildCoachSnapshot, +} from "@/lib/ai/coach/snapshot"; +import { SOURCE_SNAPSHOT_FALLBACK_CHARS } from "@/lib/ai/coach/source-snapshot"; +import { COACH_SOURCE_SNAPSHOT_KEY } from "@/lib/ai/coach/tools/source-keys"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { ensureUserMoodRollupsFresh } from "@/lib/rollups/mood-rollups"; +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; + +let userId = ""; + +function ctx(): McpAuthContext { + return { + userId, + tokenId: "token-int", + scopes: ["health:read"], + binding: `${userId}:token-int`, + canRead: true, + canWrite: false, + }; +} + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +/** What `get_metric_series` answered before: the full builder, sliced. */ +async function oldPathResult( + metric: CoachScopeSource, + window: CoachScopeWindow | undefined, +) { + __resetCoachSnapshotCacheForTests(); + const snapshot = await buildCoachSnapshot(userId, { + sources: [metric], + window, + }); + const section = snapshot.sections[COACH_SOURCE_SNAPSHOT_KEY[metric]!]; + if (section === undefined) return { section, result: undefined }; + return { + section, + result: { + present: true, + data: { metric, section }, + grounding: snapshot.referenceGrounding ?? undefined, + }, + }; +} + +async function newPathResult( + metric: CoachScopeSource, + window: CoachScopeWindow | undefined, +) { + __resetCoachSnapshotCacheForTests(); + return tool("get_metric_series").run( + ctx(), + window ? { metric, window } : { metric }, + ); +} + +const PRESENT: CoachScopeSource[] = [ + "pulse", + "resting_hr", + "bp", + "weight", + "sleep", + "steps", + "hrv", + "body_fat", + "mood", +]; +const WINDOWS: Array = [ + undefined, + "last7days", + "last90days", + "allTime", +]; + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const user = await prisma.user.create({ + data: { + username: "mcp-source-snapshot", + email: "mcp-source-snapshot@example.test", + timezone: "Europe/Berlin", + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + heightCm: 180, + }, + }); + userId = user.id; + const now = Date.now(); + + const rows: Array<{ + userId: string; + type: MeasurementType; + value: number; + unit: string; + source: "APPLE_HEALTH" | "WITHINGS"; + measuredAt: Date; + sleepStage?: SleepStage; + externalId?: string; + }> = []; + const add = ( + type: MeasurementType, + unit: string, + count: number, + stepMs: number, + value: (i: number) => number, + source: "APPLE_HEALTH" | "WITHINGS" = "APPLE_HEALTH", + ) => { + for (let i = 0; i < count; i++) { + rows.push({ + userId, + type, + value: value(i), + unit, + source, + measuredAt: new Date(now - i * stepMs - 7 * 60_000), + }); + } + }; + // Hourly pulse for 400 days: more than the feature extraction's 6000-row + // all-type cap on its own, so the cap decides which weight rows the core + // aggregates see, in both builds. + add("PULSE", "bpm", 400 * 24, HOUR_MS, (i) => 60 + ((i * 7) % 25)); + add("RESTING_HEART_RATE", "bpm", 400, DAY_MS, (i) => 52 + (i % 9)); + add("HEART_RATE_VARIABILITY", "ms", 400, DAY_MS, (i) => 40 + (i % 13)); + add( + "BLOOD_PRESSURE_SYS", + "mmHg", + 800, + 12 * HOUR_MS, + (i) => 115 + ((i * 7) % 22), + "WITHINGS", + ); + add( + "BLOOD_PRESSURE_DIA", + "mmHg", + 800, + 12 * HOUR_MS, + (i) => 74 + ((i * 5) % 13), + "WITHINGS", + ); + add("WEIGHT", "kg", 400, DAY_MS, (i) => 81 + (i % 6) * 0.3, "WITHINGS"); + add("BODY_FAT", "%", 120, 3 * DAY_MS, (i) => 20 + (i % 5) * 0.2, "WITHINGS"); + for (let d = 1; d <= 120; d++) { + rows.push({ + userId, + type: "ACTIVITY_STEPS", + value: 6000 + ((d * 977) % 5000), + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date(now - d * DAY_MS), + externalId: `stats:HKQuantityTypeIdentifierStepCount:${d}`, + }); + const stages: SleepStage[] = ["CORE", "DEEP", "CORE", "REM", "AWAKE"]; + stages.forEach((sleepStage, s) => { + rows.push({ + userId, + type: "SLEEP_DURATION", + value: 45, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: new Date(now - d * DAY_MS - 6 * HOUR_MS + s * 50 * 60_000), + sleepStage, + }); + }); + } + await prisma.measurement.createMany({ data: rows }); + + const moods = Array.from({ length: 90 }, (_, d) => { + const at = new Date(now - d * DAY_MS - 2 * HOUR_MS); + return { + userId, + date: at.toISOString().slice(0, 10), + mood: "GUT", + score: 2 + (d % 4), + moodLoggedAt: at, + }; + }); + await prisma.moodEntry.createMany({ data: moods }); + + await recomputeUserRollups(userId, { + from: new Date(now - 420 * DAY_MS), + to: new Date(now), + }); + // The mood feature warms its rollup tier fire-and-forget on first read; + // settle it now so both builds read the same tier. + await ensureUserMoodRollupsFresh(userId); +}, 120_000); + +describe("get_metric_series built from one source's rows", () => { + it.each(PRESENT.flatMap((m) => WINDOWS.map((w) => [m, w] as const)))( + "%s over %s answers exactly what the full snapshot answered", + async (metric, window) => { + const before = await oldPathResult(metric, window); + expect(before.result).toBeDefined(); + const after = await newPathResult(metric, window); + expect(JSON.stringify(after)).toBe(JSON.stringify(before.result)); + }, + ); + + it("sends a section large enough for the budget pass to the full builder", async () => { + // Without a case past the threshold the fallback branch is never run and + // the equality above would say nothing about it. + const sizes = await Promise.all( + (["bp", "pulse", "weight"] as const).map(async (m) => { + const { section } = await oldPathResult(m, "allTime"); + return JSON.stringify({ section }, null, 2).length; + }), + ); + expect(Math.max(...sizes)).toBeGreaterThan(SOURCE_SNAPSHOT_FALLBACK_CHARS); + }); + + it("keeps an absent source an explicit absence", async () => { + for (const metric of ["vo2_max", "spo2", "walking_speed"] as const) { + expect((await oldPathResult(metric, undefined)).result).toBeUndefined(); + const after = (await newPathResult(metric, undefined)) as { + present: boolean; + reason?: string; + }; + expect(after.present).toBe(false); + expect(after.reason).toBe("no_data"); + } + }); + + it("answers get_metrics and the metric resource with the same per-metric results", async () => { + const metrics = ["resting_hr", "pulse", "bp", "weight", "vo2_max"] as const; + __resetCoachSnapshotCacheForTests(); + const batch = (await tool("get_metrics").run(ctx(), { + metrics: [...metrics], + })) as { + present: boolean; + results: Array<{ metric: string; present: boolean; data?: unknown }>; + }; + expect(batch.present).toBe(true); + for (const entry of batch.results) { + const metric = entry.metric as CoachScopeSource; + const { result } = await oldPathResult(metric, undefined); + if (!result) { + expect(entry.present).toBe(false); + continue; + } + expect(JSON.stringify(entry)).toBe( + JSON.stringify({ + metric, + present: true, + data: result.data, + ...(result.grounding ? { grounding: result.grounding } : {}), + }), + ); + } + + const resource = MCP_RESOURCE_TEMPLATES.find((r) => r.name === "metric")!; + __resetCoachSnapshotCacheForTests(); + const viaResource = await resource.read(ctx(), { type: "resting_hr" }); + expect(JSON.stringify(viaResource)).toBe( + JSON.stringify((await oldPathResult("resting_hr", undefined)).result), + ); + }); +}); From 13ea84918866414239a6c199e86663829327e582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 18:11:19 +0200 Subject: [PATCH 02/17] perf(backup): load the backup payload schema on first use backupPayloadSchema is built when validations/backup.ts is evaluated and holds about 12 MB of heap. The restore job, the stored-backup readers and the export writer import that module, and they sit on the boot path, so every server process built the schema at start whether or not it ever touched a backup. The schema version, the compatibility check and the per-section summary move to validations/backup-summary.ts, which never builds the schema. The call sites that validate a file (upload, restore, the summary and download routes, and the streamed reader's element check) load backup.ts with a dynamic import at that moment; everything else takes only types. The OpenAPI registry keeps its static import, since only the spec generator loads it. After warm-up on a production build under --max-old-space-size=524, the used heap drops by about 29 MB, and the schema no longer appears in a heap snapshot. A structural test refuses a new static value import of the module outside the registry. --- .../backup-schema-lazy-guard.test.ts | 75 ++++++ .../api/admin/backups/[id]/download/route.ts | 3 +- .../[id]/summary/__tests__/route.test.ts | 8 +- .../api/admin/backups/[id]/summary/route.ts | 5 +- src/app/api/admin/backups/upload/route.ts | 9 +- .../export/__tests__/backup-preview.test.ts | 3 +- .../restore-backup-owner-mismatch.test.ts | 6 +- src/lib/export/backup-preview.ts | 2 +- src/lib/export/full-backup-payload.ts | 2 +- src/lib/export/restore-backup.ts | 8 +- src/lib/export/store-backup-blob.ts | 2 +- src/lib/export/streamed-backup.ts | 30 ++- src/lib/jobs/__tests__/offhost-backup.test.ts | 6 +- src/lib/validations/__tests__/backup.test.ts | 8 +- src/lib/validations/backup-summary.ts | 216 ++++++++++++++++++ src/lib/validations/backup.ts | 207 +---------------- tests/integration/backup-preview.test.ts | 4 +- tests/integration/backup-round-trip.test.ts | 3 +- 18 files changed, 355 insertions(+), 242 deletions(-) create mode 100644 src/__tests__/backup-schema-lazy-guard.test.ts create mode 100644 src/lib/validations/backup-summary.ts 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/export/__tests__/backup-preview.test.ts b/src/lib/export/__tests__/backup-preview.test.ts index 688087675..8016c6c4c 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`. */ 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..91eebab21 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -35,7 +35,7 @@ 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 type { BackupSummary } from "@/lib/validations/backup-summary"; export interface BackupPreview { version: 1; 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/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/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index c10d46e65..fd037786f 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -18,12 +18,8 @@ * of bubbling out of `prisma.createMany` with a useless message. */ import { describe, expect, it } from "vitest"; -import { - backupPayloadSchema, - isCompatibleSchemaVersion, - parseBackupPayload, - summarizeBackup, -} from "../backup"; +import { backupPayloadSchema, parseBackupPayload } from "../backup"; +import { isCompatibleSchemaVersion, summarizeBackup } from "../backup-summary"; const baseEntry = { date: "2026-05-08", diff --git a/src/lib/validations/backup-summary.ts b/src/lib/validations/backup-summary.ts new file mode 100644 index 000000000..cd04d0eae --- /dev/null +++ b/src/lib/validations/backup-summary.ts @@ -0,0 +1,216 @@ +/** + * What a backup is, without the schema that checks one. + * + * `backupPayloadSchema` (`backup.ts`) is the largest Zod tree in the server: + * built at import, it holds about 12 MB of heap. Everything here is what the + * backup paths need besides that check: the schema version, whether a file's + * version is one this server reads, and the per-section counts of a payload + * that has already been parsed. Importing this module never builds the + * schema, so a module on the boot path (the restore job, the stored-backup + * readers, the export writer) can import it freely and load `backup.ts` with + * a dynamic `import()` at the moment a file is actually validated. + */ +import type { BackupPayload } from "@/lib/validations/backup"; + +export const BACKUP_SCHEMA_VERSION = "2" as const; +export const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; + +/** + * Numeric counts of each backed-up record kind. Returned in the + * upload + restore API responses so the admin sees what they + * uploaded/restored without having to download the file again. + */ +export interface BackupSummary { + schemaVersion: string; + userId: string; + exportedAt: string; + measurements: number; + medications: number; + intakeEvents: number; + /** Side effects recorded against a drug, across every medication. */ + medicationSideEffects: number; + /** Pinned efficacy targets, across every medication. */ + medicationEfficacyTargets: number; + /** Archived schedule eras, across every medication. */ + medicationScheduleRevisions: number; + moodEntries: number; + /** v1.15.0 — observed cycle spans in the backup. */ + cycles: number; + /** v1.15.0 — cycle day-logs in the backup. */ + cycleDayLogs: number; + /** Lab results in the backup. */ + labResults: number; + nutrientDays: number; + /** User-scoped biomarker catalog entries in the backup. */ + biomarkers: number; + /** Illness episodes, including flares/exacerbations. */ + illnessEpisodes: number; + /** Illness day-logs across every episode. */ + illnessDayLogs: number; + /** Allergy/intolerance records in the backup. */ + allergies: number; + /** Family-history entries in the backup. */ + familyHistory: number; + /** Workout summary records in the backup. */ + workouts: number; + /** Document records (ciphertext included in canonical DR payloads). */ + documents: number; + /** Which documents were filed against which condition. */ + documentConditionLinks: number; + /** Facts staged out of a document, with their review decision. */ + extractedFacts: number; + /** 1 when the account's durable self-context rides the file, 0 otherwise. */ + healthProfile: number; + /** Effective-dated structured health-profile revisions. */ + healthProfileFactRevisions: number; + /** Metrics the account defined itself. */ + customMetrics: number; + /** Readings across every user-defined metric. */ + customMetricEntries: number; + /** Persisted accepted correlation identities and dismissal decisions. */ + correlationPatterns: number; + /** Stored day curves for the cumulative metrics, across every metric. */ + intradayProfiles: number; + /** Local days whose health score was written down as it was shown. */ + healthScoreRecords: number; + /** 1 when the record's setup answers ride the file, 0 otherwise. */ + onboardingRecords: number; + /** v1.37.19 (A6-8) — the visit address book. */ + practitioners: number; + /** v1.37.19 (A6-8) — doctor visits, planned and past. */ + encounters: number; + /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ + encounterLinks: number; + /** v1.37.19 (A6-8) — immunization log entries. */ + vaccinations: number; + /** v1.37.19 (A6-8) — vaccination↔document links. */ + vaccinationLinks: number; + /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ + measurementReminders: number; + /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ + measurementReminderEvents: number; + /** Coach conversation threads. */ + coachConversations: number; + /** Coach turns across every thread. */ + coachMessages: number; + /** Durable facts, agreed plans and things to bring back up. */ + coachFacts: number; + coachPlans: number; + coachReminders: number; + /** Completed screener administrations. Disaster-recovery payloads only. */ + mentalHealthAssessments: number; + /** Consent records. Disaster-recovery payloads only. */ + consentReceipts: number; + /** Personal bests across every metric and sport slot. */ + personalRecords: number; + /** Badges the account has unlocked, each with the day it was earned. */ + userAchievements: number; + /** Local days with an environmental reading. */ + environmentContexts: number; + /** Declared stretches spent away from home. */ + environmentTravelLocations: number; + /** ECG strips, with their rhythm verdict and their trace. */ + ecgRecordings: number; +} + +export function summarizeBackup(payload: BackupPayload): BackupSummary { + return { + schemaVersion: payload.schemaVersion, + userId: payload.userId, + exportedAt: payload.exportedAt, + measurements: payload.measurements.length, + medications: payload.medications.length, + intakeEvents: payload.intakeEvents.length, + medicationSideEffects: payload.medications.reduce( + (sum, medication) => sum + medication.sideEffects.length, + 0, + ), + medicationEfficacyTargets: payload.medications.reduce( + (sum, medication) => sum + medication.efficacyTargets.length, + 0, + ), + medicationScheduleRevisions: payload.medications.reduce( + (sum, medication) => sum + medication.scheduleRevisions.length, + 0, + ), + moodEntries: payload.moodEntries.length, + cycles: payload.cycles.length, + cycleDayLogs: payload.cycleDayLogs.length, + nutrientDays: payload.nutrientDays.length, + labResults: payload.labResults.length, + biomarkers: payload.biomarkers.length, + illnessEpisodes: payload.illnessEpisodes.length, + illnessDayLogs: payload.illnessEpisodes.reduce( + (sum, e) => sum + e.dayLogs.length, + 0, + ), + allergies: payload.allergies.length, + familyHistory: payload.familyHistory.length, + workouts: payload.workouts.length, + documents: payload.documents.length, + // Counted from the release that carries them, so the admin's "what did I + // just restore" answer never under-counts a filed vault the way it once + // did for visits. + documentConditionLinks: payload.documentConditionLinks.length, + extractedFacts: payload.extractedFacts.length, + healthProfile: payload.healthProfile ? 1 : 0, + healthProfileFactRevisions: payload.healthProfileFacts.length, + customMetrics: payload.customMetrics.length, + customMetricEntries: payload.customMetrics.reduce( + (sum, metric) => sum + metric.entries.length, + 0, + ), + correlationPatterns: payload.correlationPatterns.length, + intradayProfiles: payload.intradayProfiles.length, + healthScoreRecords: payload.healthScoreRecords.length, + onboardingRecords: payload.onboardingRecord ? 1 : 0, + // v1.37.19 (A6-8) — the sections restored since 08-01 were written and + // restored but absent from this report, so the admin's "what did I just + // restore" answer silently under-counted a file that carried visits or + // an Impfpass. + practitioners: payload.practitioners.length, + encounters: payload.encounters.length, + encounterLinks: + payload.encounterDocumentLinks.length + + payload.encounterLabLinks.length + + payload.encounterConditionLinks.length, + vaccinations: payload.vaccinations.length, + vaccinationLinks: payload.vaccinationDocumentLinks.length, + // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, + // so the admin's "what did I just restore" answer never under-counts a + // file with reminders the way it once did for visits. + measurementReminders: payload.measurementReminders.length, + measurementReminderEvents: payload.measurementReminderEvents.length, + coachConversations: payload.coachConversations.length, + coachMessages: payload.coachConversations.reduce( + (total, conversation) => total + conversation.messages.length, + 0, + ), + coachFacts: payload.coachFacts.length, + coachPlans: payload.coachPlans.length, + coachReminders: payload.coachReminders.length, + mentalHealthAssessments: payload.mentalHealthAssessments.length, + consentReceipts: payload.consentReceipts.length, + // Counted from the release that carries them, so the admin's "what did I + // just restore" answer never under-counts a file with bests, badges or an + // environmental history the way it once did for visits. + personalRecords: payload.personalRecords.length, + userAchievements: payload.userAchievements.length, + environmentContexts: payload.environmentContexts.length, + environmentTravelLocations: payload.environmentTravelLocations.length, + ecgRecordings: payload.ecgRecordings.length, + }; +} + +/** + * The schemaVersion the system understands today. Used by the upload + * route to reject inbound files written by a *future* HealthLog instance + * — restoring them under the current code might silently drop data the + * new shape carried. + */ +export function isCompatibleSchemaVersion(version: string): boolean { + return ( + version === LEGACY_BACKUP_SCHEMA_VERSION || + version === BACKUP_SCHEMA_VERSION + ); +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index f0a31c31f..e47df648e 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -88,14 +88,15 @@ import { DOCUMENT_SOURCE_SYSTEMS, } from "@/lib/validations/inbound-documents"; import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; +import { + BACKUP_SCHEMA_VERSION, + LEGACY_BACKUP_SCHEMA_VERSION, +} from "@/lib/validations/backup-summary"; import { UNITS_PER_DOSE_MESSAGE, isSupportedUnitsPerDose, } from "@/lib/medications/units-per-dose"; -export const BACKUP_SCHEMA_VERSION = "2" as const; -const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; - const isoDateTime = z .string() .min(1) @@ -2078,193 +2079,6 @@ export type BackupPayload = z.infer; export const backupMeasurementSchema = measurementSchema; export type BackupMeasurement = z.infer; -/** - * Numeric counts of each backed-up record kind. Returned in the - * upload + restore API responses so the admin sees what they - * uploaded/restored without having to download the file again. - */ -export interface BackupSummary { - schemaVersion: string; - userId: string; - exportedAt: string; - measurements: number; - medications: number; - intakeEvents: number; - /** Side effects recorded against a drug, across every medication. */ - medicationSideEffects: number; - /** Pinned efficacy targets, across every medication. */ - medicationEfficacyTargets: number; - /** Archived schedule eras, across every medication. */ - medicationScheduleRevisions: number; - moodEntries: number; - /** v1.15.0 — observed cycle spans in the backup. */ - cycles: number; - /** v1.15.0 — cycle day-logs in the backup. */ - cycleDayLogs: number; - /** Lab results in the backup. */ - labResults: number; - nutrientDays: number; - /** User-scoped biomarker catalog entries in the backup. */ - biomarkers: number; - /** Illness episodes, including flares/exacerbations. */ - illnessEpisodes: number; - /** Illness day-logs across every episode. */ - illnessDayLogs: number; - /** Allergy/intolerance records in the backup. */ - allergies: number; - /** Family-history entries in the backup. */ - familyHistory: number; - /** Workout summary records in the backup. */ - workouts: number; - /** Document records (ciphertext included in canonical DR payloads). */ - documents: number; - /** Which documents were filed against which condition. */ - documentConditionLinks: number; - /** Facts staged out of a document, with their review decision. */ - extractedFacts: number; - /** 1 when the account's durable self-context rides the file, 0 otherwise. */ - healthProfile: number; - /** Effective-dated structured health-profile revisions. */ - healthProfileFactRevisions: number; - /** Metrics the account defined itself. */ - customMetrics: number; - /** Readings across every user-defined metric. */ - customMetricEntries: number; - /** Persisted accepted correlation identities and dismissal decisions. */ - correlationPatterns: number; - /** Stored day curves for the cumulative metrics, across every metric. */ - intradayProfiles: number; - /** Local days whose health score was written down as it was shown. */ - healthScoreRecords: number; - /** 1 when the record's setup answers ride the file, 0 otherwise. */ - onboardingRecords: number; - /** v1.37.19 (A6-8) — the visit address book. */ - practitioners: number; - /** v1.37.19 (A6-8) — doctor visits, planned and past. */ - encounters: number; - /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ - encounterLinks: number; - /** v1.37.19 (A6-8) — immunization log entries. */ - vaccinations: number; - /** v1.37.19 (A6-8) — vaccination↔document links. */ - vaccinationLinks: number; - /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ - measurementReminders: number; - /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ - measurementReminderEvents: number; - /** Coach conversation threads. */ - coachConversations: number; - /** Coach turns across every thread. */ - coachMessages: number; - /** Durable facts, agreed plans and things to bring back up. */ - coachFacts: number; - coachPlans: number; - coachReminders: number; - /** Completed screener administrations. Disaster-recovery payloads only. */ - mentalHealthAssessments: number; - /** Consent records. Disaster-recovery payloads only. */ - consentReceipts: number; - /** Personal bests across every metric and sport slot. */ - personalRecords: number; - /** Badges the account has unlocked, each with the day it was earned. */ - userAchievements: number; - /** Local days with an environmental reading. */ - environmentContexts: number; - /** Declared stretches spent away from home. */ - environmentTravelLocations: number; - /** ECG strips, with their rhythm verdict and their trace. */ - ecgRecordings: number; -} - -export function summarizeBackup(payload: BackupPayload): BackupSummary { - return { - schemaVersion: payload.schemaVersion, - userId: payload.userId, - exportedAt: payload.exportedAt, - measurements: payload.measurements.length, - medications: payload.medications.length, - intakeEvents: payload.intakeEvents.length, - medicationSideEffects: payload.medications.reduce( - (sum, medication) => sum + medication.sideEffects.length, - 0, - ), - medicationEfficacyTargets: payload.medications.reduce( - (sum, medication) => sum + medication.efficacyTargets.length, - 0, - ), - medicationScheduleRevisions: payload.medications.reduce( - (sum, medication) => sum + medication.scheduleRevisions.length, - 0, - ), - moodEntries: payload.moodEntries.length, - cycles: payload.cycles.length, - cycleDayLogs: payload.cycleDayLogs.length, - nutrientDays: payload.nutrientDays.length, - labResults: payload.labResults.length, - biomarkers: payload.biomarkers.length, - illnessEpisodes: payload.illnessEpisodes.length, - illnessDayLogs: payload.illnessEpisodes.reduce( - (sum, e) => sum + e.dayLogs.length, - 0, - ), - allergies: payload.allergies.length, - familyHistory: payload.familyHistory.length, - workouts: payload.workouts.length, - documents: payload.documents.length, - // Counted from the release that carries them, so the admin's "what did I - // just restore" answer never under-counts a filed vault the way it once - // did for visits. - documentConditionLinks: payload.documentConditionLinks.length, - extractedFacts: payload.extractedFacts.length, - healthProfile: payload.healthProfile ? 1 : 0, - healthProfileFactRevisions: payload.healthProfileFacts.length, - customMetrics: payload.customMetrics.length, - customMetricEntries: payload.customMetrics.reduce( - (sum, metric) => sum + metric.entries.length, - 0, - ), - correlationPatterns: payload.correlationPatterns.length, - intradayProfiles: payload.intradayProfiles.length, - healthScoreRecords: payload.healthScoreRecords.length, - onboardingRecords: payload.onboardingRecord ? 1 : 0, - // v1.37.19 (A6-8) — the sections restored since 08-01 were written and - // restored but absent from this report, so the admin's "what did I just - // restore" answer silently under-counted a file that carried visits or - // an Impfpass. - practitioners: payload.practitioners.length, - encounters: payload.encounters.length, - encounterLinks: - payload.encounterDocumentLinks.length + - payload.encounterLabLinks.length + - payload.encounterConditionLinks.length, - vaccinations: payload.vaccinations.length, - vaccinationLinks: payload.vaccinationDocumentLinks.length, - // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, - // so the admin's "what did I just restore" answer never under-counts a - // file with reminders the way it once did for visits. - measurementReminders: payload.measurementReminders.length, - measurementReminderEvents: payload.measurementReminderEvents.length, - coachConversations: payload.coachConversations.length, - coachMessages: payload.coachConversations.reduce( - (total, conversation) => total + conversation.messages.length, - 0, - ), - coachFacts: payload.coachFacts.length, - coachPlans: payload.coachPlans.length, - coachReminders: payload.coachReminders.length, - mentalHealthAssessments: payload.mentalHealthAssessments.length, - consentReceipts: payload.consentReceipts.length, - // Counted from the release that carries them, so the admin's "what did I - // just restore" answer never under-counts a file with bests, badges or an - // environmental history the way it once did for visits. - personalRecords: payload.personalRecords.length, - userAchievements: payload.userAchievements.length, - environmentContexts: payload.environmentContexts.length, - environmentTravelLocations: payload.environmentTravelLocations.length, - ecgRecordings: payload.ecgRecordings.length, - }; -} - /** * Parse a JSON blob (string or already-parsed object) against * `backupPayloadSchema`, returning a typed payload. Throws ZodError on @@ -2280,16 +2094,3 @@ export function parseBackupPayload(input: string | unknown): BackupPayload { const raw = typeof input === "string" ? JSON.parse(input) : input; return backupPayloadSchema.parse(raw); } - -/** - * The schemaVersion the system understands today. Used by the upload - * route to reject inbound files written by a *future* HealthLog instance - * — restoring them under the current code might silently drop data the - * new shape carried. - */ -export function isCompatibleSchemaVersion(version: string): boolean { - return ( - version === LEGACY_BACKUP_SCHEMA_VERSION || - version === BACKUP_SCHEMA_VERSION - ); -} diff --git a/tests/integration/backup-preview.test.ts b/tests/integration/backup-preview.test.ts index f7d984af0..81757c6c7 100644 --- a/tests/integration/backup-preview.test.ts +++ b/tests/integration/backup-preview.test.ts @@ -126,8 +126,8 @@ async function preview(id: string) { /** The counts a full read of the stored copy gives: the old preview. */ async function fullReadSummary(id: string) { - const { parseBackupPayload, summarizeBackup } = - await import("@/lib/validations/backup"); + const { parseBackupPayload } = await import("@/lib/validations/backup"); + const { summarizeBackup } = await import("@/lib/validations/backup-summary"); const opensBefore = opened.count; const payload = parseBackupPayload( JSON.parse(await readStoredBackup(getPrismaClient(), id)), diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6314593f5..045447121 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -79,7 +79,8 @@ import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids"; import { createBackupPreviewCollector } from "@/lib/export/backup-preview"; -import { parseBackupPayload, summarizeBackup } from "@/lib/validations/backup"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { summarizeBackup } from "@/lib/validations/backup-summary"; import { TWO_ENDED_MODELS, USER_COLUMN_BACKUP_CLASS, From 016278ba247fe6e82a6cc1c03e9618dad9b7b6fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 19:21:01 +0200 Subject: [PATCH 03/17] test(mcp): analyze the seeded rows before the metric-series golden test The first case spent 14 s per full-snapshot build in the all-time pulse aggregate, planned against a table with no statistics, until autovacuum analyzed it; on a loaded runner that took it past its 60 s budget. Analyze measurements and rollups after seeding, as a production table would be. The cases and the comparison they make are unchanged. --- .../integration/mcp-metric-series-source-snapshot.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/integration/mcp-metric-series-source-snapshot.test.ts b/tests/integration/mcp-metric-series-source-snapshot.test.ts index 62294845a..3759189dc 100644 --- a/tests/integration/mcp-metric-series-source-snapshot.test.ts +++ b/tests/integration/mcp-metric-series-source-snapshot.test.ts @@ -218,6 +218,13 @@ beforeAll(async () => { // The mood feature warms its rollup tier fire-and-forget on first read; // settle it now so both builds read the same tier. await ensureUserMoodRollupsFresh(userId); + // Planner statistics for the rows just written. Without them the + // all-time pulse aggregate (`readAllTimeExtremes`, day-weighted for pulse) + // plans against an empty table and runs for ~14 s per call until + // autovacuum's analyze happens to land, which on a loaded runner took the + // first case past its 60 s budget. A production table always has + // statistics, so this measures what production runs. + await prisma.$executeRawUnsafe("ANALYZE measurements, measurement_rollups"); }, 120_000); describe("get_metric_series built from one source's rows", () => { From d64cbbaebfc06458d9577dd4e15d643e1b6f33e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:31:43 +0000 Subject: [PATCH 04/17] chore(deps-dev): bump testcontainers from 12.1.0 to 12.2.0 Bumps [testcontainers](https://github.com/testcontainers/testcontainers-node) from 12.1.0 to 12.2.0. - [Release notes](https://github.com/testcontainers/testcontainers-node/releases) - [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.1.0...v12.2.0) --- updated-dependencies: - dependency-name: testcontainers dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 145 ++++++++++++++++++++++++++----------------------- 2 files changed, 77 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index 74f0243db..96a47ab85 100644 --- a/package.json +++ b/package.json @@ -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..3ed533683 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -2970,8 +2970,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==} @@ -3444,8 +3444,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 +3459,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 +3476,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 +3493,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==} @@ -3966,8 +3966,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: @@ -5330,8 +5330,8 @@ 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==} @@ -5845,8 +5845,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: @@ -6316,8 +6316,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 +6450,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 +6465,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: @@ -6898,6 +6898,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'} @@ -7597,14 +7602,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': {} @@ -9537,7 +9542,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 +9627,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 +9694,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 @@ -10090,7 +10095,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 +10228,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 @@ -10530,7 +10535,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 +10709,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 +10728,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 @@ -11140,7 +11145,7 @@ snapshots: events-universal@1.0.1: dependencies: - bare-events: 2.9.1 + bare-events: 2.9.2 transitivePeerDependencies: - bare-abort-controller @@ -12198,7 +12203,7 @@ snapshots: dependencies: lru.min: 1.1.5 - nan@2.28.0: + nan@2.29.0: optional: true nanoid@3.3.18: {} @@ -12690,7 +12695,7 @@ snapshots: transitivePeerDependencies: - supports-color - protobufjs@7.6.5: + protobufjs@7.6.6: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -13303,7 +13308,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 +13335,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 +13484,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 +13501,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 +13514,7 @@ snapshots: teex@1.0.1: dependencies: - streamx: 2.28.0 + streamx: 2.28.1 transitivePeerDependencies: - bare-abort-controller - react-native-b4a @@ -13532,7 +13537,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 +13545,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 +13562,7 @@ snapshots: text-decoder@1.2.7: dependencies: - b4a: 1.8.1 + b4a: 1.9.0 transitivePeerDependencies: - react-native-b4a @@ -13995,6 +14000,8 @@ snapshots: yaml@2.9.0: {} + yaml@2.9.1: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 From 10d5b7334e3411043b6f2439f948fe69f573ddde Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:31:50 +0000 Subject: [PATCH 05/17] chore(deps): bump zod-openapi from 6.0.1 to 6.0.2 Bumps [zod-openapi](https://github.com/samchungy/zod-openapi) from 6.0.1 to 6.0.2. - [Release notes](https://github.com/samchungy/zod-openapi/releases) - [Changelog](https://github.com/samchungy/zod-openapi/blob/master/CHANGELOG.md) - [Commits](https://github.com/samchungy/zod-openapi/compare/zod-openapi@6.0.1...zod-openapi@6.0.2) --- updated-dependencies: - dependency-name: zod-openapi dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 74f0243db..6670481bc 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83e106bab..3b284fb04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -6940,8 +6940,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 @@ -14049,7 +14049,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 From ddb5c6462a9c98ba85ee4d32e7cded7717419b81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:32:08 +0000 Subject: [PATCH 06/17] chore(deps-dev): bump eslint-config-next from 16.3.6 to 16.3.8 Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.3.6 to 16.3.8. - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.3.8/packages/eslint-config-next) --- updated-dependencies: - dependency-name: eslint-config-next dependency-version: 16.3.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 199 ++++++++++++++++++++++++++----------------------- 2 files changed, 105 insertions(+), 96 deletions(-) diff --git a/package.json b/package.json index 74f0243db..339476ffe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83e106bab..7a6da837f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -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'} @@ -1209,8 +1214,8 @@ packages: '@next/env@16.3.6': resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} - '@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==} @@ -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': @@ -4110,8 +4115,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 +4638,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: @@ -6583,8 +6588,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 +6825,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: @@ -7232,7 +7237,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 +7252,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 +7345,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 +7394,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 +7402,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) @@ -7996,7 +8005,7 @@ snapshots: '@next/env@16.3.6': {} - '@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 @@ -9707,57 +9716,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 +9774,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 +9791,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': @@ -10862,7 +10871,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 +10930,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 +10969,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 +10995,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 +11007,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 +11035,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 @@ -11572,7 +11581,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.9: {} + ignore@7.0.11: {} immer@11.1.15: {} @@ -11741,7 +11750,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,7 +12096,7 @@ 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 @@ -13684,12 +13693,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 +13929,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 +13940,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 From c064210a34fb9e0c00d2ea129d6224a70d5b53e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:33:00 +0000 Subject: [PATCH 07/17] chore(deps): bump p-limit from 7.3.1 to 7.3.3 Bumps [p-limit](https://github.com/sindresorhus/p-limit) from 7.3.1 to 7.3.3. - [Release notes](https://github.com/sindresorhus/p-limit/releases) - [Commits](https://github.com/sindresorhus/p-limit/compare/v7.3.1...v7.3.3) --- updated-dependencies: - dependency-name: p-limit dependency-version: 7.3.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 74f0243db..3a1c92802 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83e106bab..98f02e33d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -5524,8 +5524,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: @@ -12442,7 +12442,7 @@ snapshots: dependencies: yocto-queue: 0.1.0 - p-limit@7.3.1: + p-limit@7.3.3: dependencies: yocto-queue: 1.2.2 From 8fd58e98241b7435ce29bbd8395d42cc39fa73a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:33:27 +0000 Subject: [PATCH 08/17] chore(deps): bump next from 16.3.6 to 16.3.8 Bumps [next](https://github.com/vercel/next.js) from 16.3.6 to 16.3.8. - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.3.6...v16.3.8) --- updated-dependencies: - dependency-name: next dependency-version: 16.3.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 355 ++++++++++++++++++++++++++----------------------- 2 files changed, 191 insertions(+), 166 deletions(-) diff --git a/package.json b/package.json index 74f0243db..29280b638 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83e106bab..aecd92c92 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) @@ -809,160 +809,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 +1206,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/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] @@ -3508,6 +3508,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 +3617,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'} @@ -5338,6 +5346,11 @@ packages: 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 +5369,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: @@ -6195,8 +6208,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 +6276,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'} @@ -7644,108 +7661,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,7 +8011,7 @@ 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))': dependencies: @@ -8003,28 +8020,28 @@ snapshots: 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': {} @@ -10265,6 +10282,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 +10411,8 @@ snapshots: caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001814: {} + canvg@3.0.11: dependencies: '@babel/runtime': 7.29.2 @@ -12089,7 +12110,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -12203,6 +12224,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.19: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -12214,27 +12237,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' @@ -12600,9 +12623,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: @@ -13191,37 +13214,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 +13307,8 @@ snapshots: source-map-js@1.2.1: {} + source-map-js@1.2.2: {} + source-map@0.6.1: {} split-ca@1.0.1: {} From 5e5e387fab74d100545806a9757947bb2e6ba952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 20:55:14 +0200 Subject: [PATCH 09/17] chore(release): v1.40.1 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 43 insertions(+), 3 deletions(-) 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 bb71a2e8e..54c7d32f5 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/package.json b/package.json index 3655fa286..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", 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/*` From 4be7109a2b92c0029779281f3f3a7ffd184df3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 21:13:28 +0200 Subject: [PATCH 10/17] fix(medications): refetch an invalidated prefetch on mount A hover prefetch excused the mount refetch even after a dose logged elsewhere had invalidated the medication reads, so the page showed the compliance from before the dose. An invalidated entry now always refetches. --- .../__tests__/prefetch-medications.test.ts | 22 +++++++++++++++++++ src/lib/queries/prefetch-medications.ts | 9 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/queries/__tests__/prefetch-medications.test.ts b/src/lib/queries/__tests__/prefetch-medications.test.ts index 22ee0f750..dc004b368 100644 --- a/src/lib/queries/__tests__/prefetch-medications.test.ts +++ b/src/lib/queries/__tests__/prefetch-medications.test.ts @@ -125,6 +125,28 @@ describe("refetchMedicationsOnMount", () => { unsubscribe(); }); + it("refetches a prefetched read that was invalidated before the mount", async () => { + // Hover the nav link, then log a dose elsewhere before the page mounts: + // the dose invalidates the medication reads, and the prefetched answer + // no longer holds the compliance the user just changed. + const { client, calls } = setup(); + prefetchMedicationsList(client); + await vi.waitFor(() => expect(calls("/api/medications")).toBe(1)); + await vi.waitFor(() => + expect(client.getQueryState(queryKeys.medications())?.status).toBe( + "success", + ), + ); + await client.invalidateQueries({ queryKey: queryKeys.medications() }); + expect(client.getQueryState(queryKeys.medications())?.isInvalidated).toBe( + true, + ); + + const { refetched, unsubscribe } = await mountList(client); + expect(refetched).toBe(true); + unsubscribe(); + }); + it("still refetches on mount when the cache holds an older read", async () => { const { client } = setup(); const queryFn = vi.fn(async () => []); diff --git a/src/lib/queries/prefetch-medications.ts b/src/lib/queries/prefetch-medications.ts index 6058fc137..d78e63c52 100644 --- a/src/lib/queries/prefetch-medications.ts +++ b/src/lib/queries/prefetch-medications.ts @@ -93,11 +93,18 @@ export function prefetchMedicationsList( * the identical request a second time, a few hundred milliseconds after the * first answer landed, on every visit to the page. A return to the page with * an older cache entry, or a direct load, still refetches on mount. + * + * An invalidated entry always refetches: a dose logged elsewhere in the app + * between the prefetch and the mount invalidates these reads, and the + * prefetched answer is then the stale one. */ export function refetchMedicationsOnMount( read: MedicationsRead, -): (query: { state: { dataUpdatedAt: number } }) => boolean | "always" { +): (query: { + state: { dataUpdatedAt: number; isInvalidated: boolean }; +}) => boolean | "always" { return (query) => { + if (query.state.isInvalidated) return "always"; const startedAt = prefetchStartedAt.get(read); const answeredThisVisit = startedAt !== undefined && From 4261139dfae6f7318ab5c1c8b5629afbefd6dabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 21:19:32 +0200 Subject: [PATCH 11/17] fix(backup): run the restore drill as a locked long pass The drill now reads every account's newest off-host object but still ran on the default fifteen-minute expiry, without a lock and with two retries, so a large instance could see it killed, run beside itself, and page every failed account three times. It now carries a four-hour expiry, stops between accounts on its budget and reports the objects it did not reach, holds a pass lock at the binding, and is not retried. --- src/lib/jobs/__tests__/restore-drill.test.ts | 32 +++++++++++++ src/lib/jobs/job-outcome.ts | 1 + src/lib/jobs/queue-runtime.ts | 10 +++- src/lib/jobs/reminder/register-maintenance.ts | 13 +++-- src/lib/jobs/restore-drill.ts | 47 +++++++++++++++++-- 5 files changed, 94 insertions(+), 9 deletions(-) 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/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 2ef5e5cc0..33e994a32 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -200,6 +200,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "restore_drill_medications", "restore_drill_mood_entries", "restore_drill_plaintext_bytes", + "restore_drill_unchecked", "restore_drill_stale", "restore_fail_cause", "restore_failed", diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 18177c274..3dd254b8f 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -36,6 +36,7 @@ import { DOCUMENT_AI_RUN_EXPIRE_SECONDS } from "@/lib/jobs/document-ai-run"; import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy"; import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup"; import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore"; +import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill"; import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker"; import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import"; @@ -267,7 +268,14 @@ export const QUEUE_RUNTIME: Readonly> = { exclusive: "lockedPass", why: "Uploads every account's record off-host.", }, - "data-restore-drill": short("Fetches and stream-parses one off-host object."), + "data-restore-drill": { + runtime: "long", + expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS, + expiryVia: ["RESTORE_DRILL_SEND_OPTIONS"], + stop: { file: "lib/jobs/restore-drill.ts", fn: "handleRestoreDrill" }, + exclusive: "lockedPass", + why: "Fetches and stream-parses every account's newest off-host object.", + }, "offhost-backup-purge": short("At most 200 delete requests per run."), "backup-restore": { runtime: "long", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 6fd906114..d774495d4 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -83,6 +83,7 @@ import { handleRestoreDrill, RESTORE_DRILL_CRON, RESTORE_DRILL_QUEUE, + RESTORE_DRILL_SEND_OPTIONS, } from "@/lib/jobs/restore-drill"; import { BACKUP_RESTORE_QUEUE, @@ -551,8 +552,10 @@ const allQueues = [ // a bulk DELETE or UPDATE that timed out will time out again on an immediate // retry, so pg-boss's default two would run the same doomed statement three // times a night. The queues without it are the ones whose failure is -// transient — the TLS probe, the off-host upload, the restore drill, the -// per-user environment fetch — and those still want the default retries. +// transient — the TLS probe, the off-host upload, the per-user environment +// fetch — and those still want the default retries. The restore drill sends +// `retryLimit: 0` in its own options for the other reason: it pages each +// failed account itself, and a retry would page the same accounts again. const schedules: ScheduleEntry[] = [ // v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload) // that fans out one per-user environment fetch per opted-in account. @@ -571,7 +574,7 @@ const schedules: ScheduleEntry[] = [ cronIsTheRetry, ], [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON, OFFHOST_BACKUP_SEND_OPTIONS], - [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], + [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON, RESTORE_DRILL_SEND_OPTIONS], // Nightly backstop for the purge the deletion kicks straight away: a // request the bucket refused, or one written while the queue was down. [OFFHOST_PURGE_QUEUE, OFFHOST_PURGE_CRON, cronIsTheRetry], @@ -832,11 +835,13 @@ export async function registerMaintenanceQueues( { localConcurrency: 1, includeMetadata: true }, lockedPass(OFFHOST_BACKUP_QUEUE, () => WHOLE_PASS, handleOffhostBackup), ); + // Locked: a drill that outlives a restart or a manual run never reads the + // bucket beside another one. await createAndWork( boss, RESTORE_DRILL_QUEUE, { localConcurrency: 1 }, - handleRestoreDrill, + lockedPass(RESTORE_DRILL_QUEUE, () => WHOLE_PASS, handleRestoreDrill), ); await createAndWork( boss, diff --git a/src/lib/jobs/restore-drill.ts b/src/lib/jobs/restore-drill.ts index caec85d00..1a6854bc1 100644 --- a/src/lib/jobs/restore-drill.ts +++ b/src/lib/jobs/restore-drill.ts @@ -21,6 +21,13 @@ * Schedule: 04:11 on the 1st of each month (`11 4 1 * *`), after the * 02:30 nightly upload and the 03:xx cleanup window, on a minute slot * no other cron uses. + * + * The drill reads every account's object, so it runs as a long pass: an + * explicit expiry (`RESTORE_DRILL_EXPIRE_SECONDS`), a stop between accounts + * on the job's budget, a lock so two drills never overlap (`lockedPass` at + * the binding), and no retry. A retry would re-read the same objects and + * page the same failed accounts a second and third time; each account is + * reported once per run, and the next drill is the retry. */ import type { Job } from "pg-boss"; import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan"; @@ -38,12 +45,28 @@ import { type S3Like, } from "@/lib/jobs/offhost-backup"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; export const RESTORE_DRILL_QUEUE = "data-restore-drill"; export const RESTORE_DRILL_CRON = "11 4 1 * *"; +/** + * Four hours, the off-host upload's own window: the drill downloads and + * stream-parses every object that upload wrote in one night. + */ +export const RESTORE_DRILL_EXPIRE_SECONDS = 4 * 60 * 60; + +/** + * What the monthly schedule sends with: the expiry above and no retry + * (`retryLimit: 0`, the maintenance schedules' `cronIsTheRetry`). + */ +export const RESTORE_DRILL_SEND_OPTIONS = { + expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS, + retryLimit: 0, +} as const; + /** * A drill run that finds the newest backup older than this is reported * as a failure even when decrypt + parse succeed: the nightly uploader @@ -80,6 +103,8 @@ export interface RestoreDrillReport { accounts: RestoreDrillAccount[]; /** The accounts whose object did not pass. */ failed: RestoreDrillAccount[]; + /** Objects of the newest date the run stopped before, on its budget. */ + unchecked: number; } function countArray(value: unknown): number { @@ -178,6 +203,7 @@ async function drillObject( export async function runRestoreDrill( s3Override?: S3Like, now: Date = new Date(), + shouldStop: () => boolean = () => false, ): Promise { const cfg = loadOffhostConfig(); if (!cfg) { @@ -208,6 +234,8 @@ export async function runRestoreDrill( const accounts: RestoreDrillAccount[] = []; for (const objectKey of newest) { + // Between accounts, never inside one: the job's budget is spent. + if (shouldStop()) break; const account: RestoreDrillAccount = { objectKey, ok: true, @@ -239,6 +267,7 @@ export async function runRestoreDrill( stale: ageDays > MAX_BACKUP_AGE_DAYS, accounts, failed: accounts.filter((a) => !a.ok), + unchecked: newest.length - accounts.length, }; } @@ -252,10 +281,10 @@ export async function runRestoreDrill( export async function handleRestoreDrill( jobs: Job[], ): Promise { - void jobs; + const shouldStop = jobBudget(jobs); return withBackgroundEvent("job.restore_drill", async (evt) => { try { - const report = await runRestoreDrill(); + const report = await runRestoreDrill(undefined, new Date(), shouldStop); const total = (pick: (a: RestoreDrillAccount) => number) => report.accounts.reduce((sum, a) => sum + pick(a), 0); const meta = { @@ -270,6 +299,7 @@ export async function handleRestoreDrill( restore_drill_intake_events: total((a) => a.recordCounts.intakeEvents), restore_drill_mood_entries: total((a) => a.recordCounts.moodEntries), restore_drill_stale: report.stale, + restore_drill_unchecked: report.unchecked, }; for (const [key, value] of Object.entries(meta)) evt.addMeta(key, value); evt.addMeta( @@ -301,9 +331,18 @@ export async function handleRestoreDrill( { dateKey: report.dateKey, ageDays: report.ageDays }, ); } + if (report.unchecked > 0) { + await reportWorkerError( + RESTORE_DRILL_QUEUE, + new Error( + `Restore drill stopped on its time budget with ${report.unchecked} of ${report.accounts.length + report.unchecked} backup objects unchecked.`, + ), + { dateKey: report.dateKey, unchecked: report.unchecked }, + ); + } if (report.failed.length > 0) { - // Already paged above, per account. A retry reads the same objects - // and cannot change the verdict before the next upload. + // Already paged above, per account. The queue does not retry: a + // retry reads the same objects and would page the same accounts again. return jobFailed( `restore drill: ${report.failed.length} of ${report.accounts.length} accounts failed`, new Error(report.failed.map((a) => a.objectKey).join(", ")), From da56177290c198a02cbd86c0fe01895adacccdb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 21:21:32 +0200 Subject: [PATCH 12/17] fix(backup): take an interrupted account last for one run only An account whose last backup attempt never came back was kept behind every other account for as long as its attempt stayed open. A pass that regularly stops on its time budget never reached it, so it never got a copy again. It is now held back only until a later run has started on another account; if its record kills the process again, its attempt is the newest once more and it goes last again. A crash loop still leaves the other accounts their copies, and the account itself is attempted at least every second run. --- .../__tests__/backup-pass-attempts.test.ts | 118 ++++++++++++++++++ src/lib/jobs/backup-pass-attempts.ts | 56 ++++++++- src/lib/jobs/offhost-backup.ts | 9 +- src/lib/jobs/reminder/backup-handlers.ts | 8 +- 4 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/lib/jobs/__tests__/backup-pass-attempts.test.ts 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/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> { + try { + const rows = await prisma.backupPassAttempt.findMany({ + where: { pass }, + select: { userId: true, startedAt: true, finishedAt: true }, + }); + return heldBackAccounts(rows); + } catch { + return new Map(); + } +} + +/** + * `accounts` in their order, with every held-back one moved behind the * rest; among those, the one interrupted longest ago first. */ export function orderInterruptedLast( diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index ad9cf04dd..05a9f23b5 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -39,7 +39,7 @@ import { markBackupAttemptFinished, markBackupAttemptStarted, orderInterruptedLast, - readInterruptedBackupAttempts, + readAccountsToTakeLast, } from "@/lib/jobs/backup-pass-attempts"; import { BACKUP_HEARTBEAT_SECONDS } from "@/lib/jobs/data-backup-policy"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -930,14 +930,15 @@ export async function runOffhostBackup( // By id, with an account whose last walk never came back behind the rest: // a record that killed the process once would otherwise lead every retry - // and keep every account after it out of the bucket - // (`backup-pass-attempts.ts`). + // and keep every account after it out of the bucket. Behind them for one + // run only, so a pass that stops on its budget still reaches it the run + // after (`backup-pass-attempts.ts`). const users = orderInterruptedLast( await prisma.user.findMany({ select: { id: true }, orderBy: { id: "asc" }, }), - await readInterruptedBackupAttempts(prisma, OFFHOST_BACKUP_QUEUE), + await readAccountsToTakeLast(prisma, OFFHOST_BACKUP_QUEUE), ); const doneThisRun = new Set(); if (runStartedAt) { diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index d4bb4b1a4..62e1adeda 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -27,7 +27,7 @@ import { markBackupAttemptFinished, markBackupAttemptStarted, orderInterruptedLast, - readInterruptedBackupAttempts, + readAccountsToTakeLast, } from "@/lib/jobs/backup-pass-attempts"; import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; @@ -154,7 +154,7 @@ export async function handleDataBackup( where: { type: "WEEKLY_AUTO" }, select: { userId: true, createdAt: true }, }), - readInterruptedBackupAttempts(prisma, DATA_BACKUP_QUEUE), + readAccountsToTakeLast(prisma, DATA_BACKUP_QUEUE), ]); const copiedAt = new Map( copies.map((copy) => [copy.userId, copy.createdAt.getTime()]), @@ -163,7 +163,9 @@ export async function handleDataBackup( // attempt never came back goes behind all of them: it is the one with // the oldest copy, so it would lead every retry, and a record that // killed the process once would keep every account after it from a - // copy (`backup-pass-attempts.ts`). + // copy. Only until a later run has started on another account, so a + // pass that stops on its budget still reaches it the run after + // (`backup-pass-attempts.ts`). const users = orderInterruptedLast( [...accounts].sort( (a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1), From 2bbcce73e4fd901364af30d5a5f4f3c0ce0a6e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 21:25:28 +0200 Subject: [PATCH 13/17] docs: name the memory cap on small hosts and the shared connection pool Getting started names the APP_MEMORY_LIMIT default and how to lower it on a 2 GB host; the scaling guide says that web and worker share one pool in the bundled single-container setup. The rollup comment names migration 0372, which carries the hourly-mean statistic. --- docs/self-hosting/getting-started.md | 7 ++++++- docs/self-hosting/scaling.md | 6 +++++- src/lib/rollups/measurement-rollups.ts | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) 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/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index df8e52bb2..8f6643007 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -597,7 +597,7 @@ async function runRollupAggregate( * every reading, so the band and the windowed slope are untouched. * * Hours and days are UTC, matching the UTC day this tier buckets by; migration - * 0373 applies the same statistic to rows stored before it. + * 0372 applies the same statistic to rows stored before it. */ async function applyHourlyMeanDay( db: Pick, From 3681b23d30fbe591cd11ccd0b0ee189dcd48210d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 21:36:20 +0200 Subject: [PATCH 14/17] test(e2e): mount every overview section before asserting none paints The AI-optional overview and account journeys asserted zero error cards, Coach hand-offs and briefings without scrolling the deferred sections into view, so the sections below the first three never mounted and the zero counts held whatever they would paint. Both journeys now reveal every section and wait for the reads those sections fire to land; waiting on networkidle again resolves at once, because the page reached that state before. --- e2e/ai-optional-account.spec.ts | 17 ++++++++++----- e2e/ai-optional-overview.spec.ts | 13 +++++++++++ e2e/utils/deferred-sections.ts | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) 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); + } +} From 090e3d9a7ee159cb74665f06b7a8bf0102d85b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 22:14:14 +0200 Subject: [PATCH 15/17] perf(measurements): weigh pulse days with window functions over one scan The day weight joined its source to an hour grouping of itself. With stale or missing planner statistics, as after a new account imports a year of pulse below the autoanalyze threshold, the planner ran that join as a nested loop and the all-time pulse aggregate ran into the 60 s statement timeout. The weight is now two window passes over one scan: the readings in a row's hour, and one marker per hour summed over the day for the hour count. The weights are unchanged. A new integration test seeds over 100k pulse rows with autovacuum off and no ANALYZE, bounds the all-time reader and a zoned per-source mean well under the timeout, and compares every row's weight against the earlier self-join after ANALYZE. The ANALYZE the metric-series snapshot test needed for the same trap is gone. --- src/lib/measurements/day-mean.ts | 46 ++-- .../day-weight-unanalysed-scale.test.ts | 232 ++++++++++++++++++ .../mcp-metric-series-source-snapshot.test.ts | 11 +- 3 files changed, 258 insertions(+), 31 deletions(-) create mode 100644 tests/integration/day-weight-unanalysed-scale.test.ts diff --git a/src/lib/measurements/day-mean.ts b/src/lib/measurements/day-mean.ts index 38b2e2970..9d90908bc 100644 --- a/src/lib/measurements/day-mean.ts +++ b/src/lib/measurements/day-mean.ts @@ -245,34 +245,32 @@ function weightedRowsSql( if (!/^[a-z_][a-z0-9_]*$/.test(source)) { throw new Error(`source must be a CTE name: ${source}`); } - const hourOf = (alias: string) => - `date_trunc('hour', ${frameTs(frame, `${alias}."measured_at"`)})`; - const dayOf = (alias: string) => - `date_trunc('day', ${frameTs(frame, `${alias}."measured_at"`)})`; - const src = bySource ? `, t."source"` : ""; - const part = bySource ? `, x."source"` : ""; - const join = bySource ? ` AND hw."source" = s."source"` : ""; + // One scan of the CTE and window functions only. The earlier shape joined + // the CTE to an hour grouping of itself; with stale or missing statistics + // (a fresh import below the autoanalyze threshold) the planner ran that + // join as a nested loop and a year of pulse ran into the statement timeout. + // + // `dm_hour_n` counts the readings in a row's hour and `dm_hour_first` marks + // one row per hour, so the sum of `dm_hour_first` over a day is the day's + // hour count. Both columns ride along on the row; every caller aggregates. + const partition = (alias: string, unit: "hour" | "day") => { + const keys = bySource + ? `${alias}."type", ${alias}."source"` + : `${alias}."type"`; + const ts = frameTs(frame, `${alias}."measured_at"`); + return `PARTITION BY ${keys}, date_trunc('${unit}', ${ts})`; + }; return `( SELECT s.*, (CASE WHEN ${isHourlyMeanTypeSql('s."type"')} - THEN 1.0 / (hw.n * hw.hours) + THEN 1.0 / (s.dm_hour_n * SUM(s.dm_hour_first) OVER (${partition("s", "day")})) ELSE 1.0 END)::double precision AS day_weight - FROM ${source} s - LEFT JOIN ( - SELECT x."type"${part}, x.local_hour, x.n, - COUNT(*) OVER (PARTITION BY x."type"${part}, x.local_day) AS hours - FROM ( - SELECT t."type"${src}, - ${hourOf("t")} AS local_hour, - ${dayOf("t")} AS local_day, - COUNT(*) AS n - FROM ${source} t - WHERE ${isHourlyMeanTypeSql('t."type"')} - GROUP BY ${bySource ? "1, 2, 3, 4" : "1, 2, 3"} - ) x - ) hw - ON hw."type" = s."type"${join} - AND hw.local_hour = ${hourOf("s")} + FROM ( + SELECT t.*, + COUNT(*) OVER (${partition("t", "hour")}) AS dm_hour_n, + (ROW_NUMBER() OVER (${partition("t", "hour")}) = 1)::int AS dm_hour_first + FROM ${source} t + ) s )`; } diff --git a/tests/integration/day-weight-unanalysed-scale.test.ts b/tests/integration/day-weight-unanalysed-scale.test.ts new file mode 100644 index 000000000..977f32575 --- /dev/null +++ b/tests/integration/day-weight-unanalysed-scale.test.ts @@ -0,0 +1,232 @@ +/** + * The shared pulse day weight (`dayWeightedRows`) must not depend on planner + * statistics. A new account importing a year of pulse lands below the + * autoanalyze threshold, so the first reads run against a table the planner + * believes is empty. The earlier shape joined the CTE to an hour grouping of + * itself; on such a table the planner chose a nested loop and the all-time + * pulse aggregate ran into the 60 s production statement timeout. + * + * This file seeds a year of dense pulse (well over 100k rows) with autovacuum + * off for the table and no ANALYZE, then: + * + * - times the real all-time reader (`readAllTimeExtremes`) and a bare + * day-weighted mean, each well under the timeout; + * - after ANALYZE, compares the weights per day, per frame and per source + * against the earlier self-join SQL kept below as the oracle. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import type { Prisma } from "@/generated/prisma/client"; +import { readAllTimeExtremes } from "@/lib/insights/feature-blocks"; +import { + SESSION_DAY_FRAME, + dayWeightedRows, + zoneDayFrame, + type DayFrame, +} from "@/lib/measurements/day-mean"; + +const prisma = getPrismaClient(); +const USER = "day-weight-scale"; +const DAY = 86_400_000; +const MINUTE = 60_000; +const DAYS = 365; +/** Generous against a 60 s production timeout, far above what one scan takes. */ +const BOUND_MS = 15_000; + +const start = + Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate(), + ) - + DAYS * DAY; + +/** + * About 330 readings a day, unevenly spread so hours carry different counts, + * plus a second source on every fifth day so the per-source weights differ. + */ +function seedRows(): Prisma.MeasurementCreateManyInput[] { + const rows: Prisma.MeasurementCreateManyInput[] = []; + let i = 0; + for (let d = 0; d < DAYS; d += 1) { + for (let m = 0; m < 24 * 60; m += 4) { + if ((m + d) % 13 === 0) continue; + const hour = Math.floor(m / 60); + // Night hours sparse, day hours dense. + if (hour < 6 && m % 12 !== 0) continue; + rows.push({ + id: `${USER}-a-${i}`, + userId: USER, + type: "PULSE", + unit: "bpm", + source: "APPLE_HEALTH", + value: 55 + ((i * 7 + d) % 50), + measuredAt: new Date(start + d * DAY + m * MINUTE + (i % 3) * 1000), + }); + i += 1; + } + if (d % 5 === 0) { + for (const h of [7, 7, 13, 22]) { + rows.push({ + id: `${USER}-w-${i}`, + userId: USER, + type: "PULSE", + unit: "bpm", + source: "WITHINGS", + value: 60 + (i % 20), + measuredAt: new Date( + start + d * DAY + h * 3_600_000 + (i % 50) * MINUTE, + ), + }); + i += 1; + } + } + } + return rows; +} + +/** The earlier self-join, kept verbatim as the oracle for the weights. */ +function oracleWeightedRows( + source: string, + frame: DayFrame, + bySource: boolean, +): string { + const ts = (column: string) => + frame.kind === "session" + ? column + : `((${column} AT TIME ZONE 'UTC') AT TIME ZONE ${frame.tzSql})`; + const hourOf = (alias: string) => + `date_trunc('hour', ${ts(`${alias}."measured_at"`)})`; + const dayOf = (alias: string) => + `date_trunc('day', ${ts(`${alias}."measured_at"`)})`; + const src = bySource ? `, t."source"` : ""; + const part = bySource ? `, x."source"` : ""; + const join = bySource ? ` AND hw."source" = s."source"` : ""; + return `( + SELECT s.*, + (CASE WHEN (s."type")::text IN ('PULSE') + THEN 1.0 / (hw.n * hw.hours) + ELSE 1.0 END)::double precision AS day_weight + FROM ${source} s + LEFT JOIN ( + SELECT x."type"${part}, x.local_hour, x.n, + COUNT(*) OVER (PARTITION BY x."type"${part}, x.local_day) AS hours + FROM ( + SELECT t."type"${src}, + ${hourOf("t")} AS local_hour, + ${dayOf("t")} AS local_day, + COUNT(*) AS n + FROM ${source} t + WHERE (t."type")::text IN ('PULSE') + GROUP BY ${bySource ? "1, 2, 3, 4" : "1, 2, 3"} + ) x + ) hw + ON hw."type" = s."type"${join} + AND hw.local_hour = ${hourOf("s")} + )`; +} + +const SRC_CTE = `WITH src AS ( + SELECT m."id", m."type", m."source", m."measured_at", m."value" + FROM measurements m + WHERE m."user_id" = $1 AND m."deleted_at" IS NULL AND m."type" = 'PULSE' +)`; + +async function timed(fn: () => Promise): Promise<[T, number]> { + const t0 = performance.now(); + const out = await fn(); + return [out, performance.now() - t0]; +} + +let rowCount = 0; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.$executeRawUnsafe( + "ALTER TABLE measurements SET (autovacuum_enabled = false)", + ); + await prisma.user.create({ data: { id: USER, username: USER } }); + const rows = seedRows(); + rowCount = rows.length; + for (let k = 0; k < rows.length; k += 20_000) { + await prisma.measurement.createMany({ data: rows.slice(k, k + 20_000) }); + } +}, 180_000); + +afterAll(async () => { + await prisma.$executeRawUnsafe( + "ALTER TABLE measurements RESET (autovacuum_enabled)", + ); +}); + +describe("day weight on an unanalysed table", () => { + it("seeds a year of dense pulse", () => { + expect(rowCount).toBeGreaterThan(100_000); + }); + + it("answers the all-time pulse aggregate well under the timeout", async () => { + const [out, ms] = await timed(() => readAllTimeExtremes(USER, ["PULSE"])); + console.info(`[day-weight] readAllTimeExtremes ${ms.toFixed(0)} ms`); + expect(out.get("PULSE")?.mean).toBeGreaterThan(55); + expect(ms).toBeLessThan(BOUND_MS); + }); + + it("answers a bare day-weighted mean well under the timeout", async () => { + const [rows, ms] = await timed(() => + prisma.$queryRawUnsafe>( + `${SRC_CTE} + SELECT (SUM(w."value" * w.day_weight) / SUM(w.day_weight))::double precision AS mean + FROM ${dayWeightedRows("src", zoneDayFrame("$2"), { bySource: true })} w`, + USER, + "Europe/Berlin", + ), + ); + console.info(`[day-weight] zone + bySource mean ${ms.toFixed(0)} ms`); + expect(rows[0].mean).toBeGreaterThan(55); + expect(ms).toBeLessThan(BOUND_MS); + }); +}); + +describe("day weight against the earlier self-join", () => { + beforeAll(async () => { + // Statistics now, so the oracle plans the way it did in production. + await prisma.$executeRawUnsafe("ANALYZE measurements"); + }, 120_000); + + const cases: Array<[string, DayFrame, boolean, unknown[]]> = [ + ["session frame", SESSION_DAY_FRAME, false, []], + ["session frame by source", SESSION_DAY_FRAME, true, []], + ["Berlin frame", zoneDayFrame("$2"), false, ["Europe/Berlin"]], + ["Kolkata frame by source", zoneDayFrame("$2"), true, ["Asia/Kolkata"]], + ]; + + it.each(cases)( + "%s gives every row the same weight", + async (_label, frame, bySource, extra) => { + const query = (sub: string) => + prisma.$queryRawUnsafe>( + `${SRC_CTE} + SELECT w."id" AS id, w.day_weight AS w + FROM ${sub} w + ORDER BY w."id"`, + USER, + ...extra, + ); + const fresh = await query(dayWeightedRows("src", frame, { bySource })); + const oracle = await query(oracleWeightedRows("src", frame, bySource)); + expect(fresh.length).toBe(rowCount); + expect(fresh.length).toBe(oracle.length); + let mismatches = 0; + for (let k = 0; k < fresh.length; k += 1) { + if ( + fresh[k].id !== oracle[k].id || + Math.abs(fresh[k].w - oracle[k].w) > 1e-12 + ) { + mismatches += 1; + } + } + expect(mismatches).toBe(0); + }, + ); +}); diff --git a/tests/integration/mcp-metric-series-source-snapshot.test.ts b/tests/integration/mcp-metric-series-source-snapshot.test.ts index 3759189dc..ab95bc21a 100644 --- a/tests/integration/mcp-metric-series-source-snapshot.test.ts +++ b/tests/integration/mcp-metric-series-source-snapshot.test.ts @@ -218,13 +218,10 @@ beforeAll(async () => { // The mood feature warms its rollup tier fire-and-forget on first read; // settle it now so both builds read the same tier. await ensureUserMoodRollupsFresh(userId); - // Planner statistics for the rows just written. Without them the - // all-time pulse aggregate (`readAllTimeExtremes`, day-weighted for pulse) - // plans against an empty table and runs for ~14 s per call until - // autovacuum's analyze happens to land, which on a loaded runner took the - // first case past its 60 s budget. A production table always has - // statistics, so this measures what production runs. - await prisma.$executeRawUnsafe("ANALYZE measurements, measurement_rollups"); + // No ANALYZE here, on purpose: a fresh import below the autoanalyze + // threshold is what production runs too, and the all-time pulse aggregate + // must not depend on planner statistics + // (`day-weight-unanalysed-scale.test.ts`). }, 120_000); describe("get_metric_series built from one source's rows", () => { From 48ed149ef0229a6d742c226f157853d89e0b6e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 22:15:48 +0200 Subject: [PATCH 16/17] fix(import): leave out a marked Apple Health sample only when this account holds it A sample carrying the HealthLog origin marker was always dropped from an export import. After a move to a new instance without a backup, the export is how the mirrored values come back, and every one of them was silently left out. A marked sample now counts as written by HealthLog only when its HKExternalUUID names a row of this account of the same kind, deleted rows included; a marked cycle sample only when the account holds a day-log on its day. Every other marked sample is imported, and still meets the manual-value match. A correlation's own HKExternalUUID now covers records that carry none. --- src/lib/apple-health/own-origin.ts | 10 +- .../import-apple-health-export.ts | 243 +++++++++++++----- .../export-own-origin.synthetic.xml | 5 +- .../apple-health-import-own-samples.test.ts | 193 ++++++++++++-- 4 files changed, 367 insertions(+), 84 deletions(-) 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/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index dc280d285..f75e2a53e 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -122,11 +122,14 @@ export interface ImportJobResult { clinical: { skipped: number }; /** * Records left out because HealthLog wrote them into Apple Health itself and - * already holds them under their own source, each counted once however often - * the export repeats it: `byMarker` carry the app's origin marker, - * `byExternalId` carry a HealthLog row id as `HKExternalUUID` (samples from - * before the marker existed), `matchedManual` match a manual entry (same type - * and value, within 2 s) without either. + * this account already holds them under their own source, each counted once + * however often the export repeats it: `byMarker` carry the app's origin + * marker and a HealthLog row id of this account (a cycle sample: a day-log of + * this account on its day), `byExternalId` carry such a row id without the + * marker (samples from before the marker existed), `matchedManual` match a + * manual entry (same type and value, within 2 s). A marked sample this + * account does not hold (a move to a new instance without a backup) is + * imported like any other. */ writtenByHealthLog: { byMarker: number; @@ -186,8 +189,11 @@ interface PreparedMeasurement { * at the close tag, once its `` children have been read. */ interface PendingRecord { - /** Queue the record; `healthLogId` is the `HKExternalUUID` it carried. */ - commit: (healthLogId: string | null) => void; + /** + * Queue the record; `healthLogId` is the `HKExternalUUID` it carried and + * `marked` whether it (or its correlation) carried the origin marker. + */ + commit: (healthLogId: string | null, marked: boolean) => void; ownOrigin: boolean; healthLogId: string | null; /** The record's sample identity, computed only when it is left out. */ @@ -369,7 +375,11 @@ export interface StreamParseInput { /** Prisma client to flush rows through. */ prisma: Pick< PrismaClient, - "measurement" | "workout" | "$transaction" | "$queryRawUnsafe" + | "measurement" + | "workout" + | "cycleDayLog" + | "$transaction" + | "$queryRawUnsafe" >; /** * Live progress hook. Called every `PROGRESS_TICK_RECORDS` records @@ -449,6 +459,15 @@ export async function streamParseExportXml( sampleKey: string; ownOrigin: boolean; } | null = null; + // Marked cycle samples, held to the end of the parse: one counts as written + // by HealthLog only when this account holds a day-log on its day. + const markedCycle: Array<{ + hkType: string; + dayKey: string; + rawValue: string | undefined; + protectionUsed?: boolean; + sampleKey: string; + }> = []; // Cumulative fold: type -> local day -> hashed source identity -> subtotal. const cumulativeBucket = new Map< @@ -471,13 +490,25 @@ export async function streamParseExportXml( countedOwn.add(sampleKey); writtenByHealthLog[reason] += 1; }; - // The `HKExternalUUID` a queued spot row carried, checked at flush. + // The `HKExternalUUID` a queued spot row carried, checked at flush, and the + // queued rows that carried the origin marker. const healthLogIdOf = new Map(); + const markedRows = new Set(); + // Marked cumulative samples that named a HealthLog row id, held to the end + // of the parse and added to their day only when the id is not this + // account's. Small: one per mirrored total. + const markedCumulative: Array<{ + type: MeasurementType; + healthLogId: string; + sampleKey: string; + add: () => void; + }> = []; // A `` (a blood pressure reading) wraps its records, and the // marker may sit on the correlation itself, before or after them. Its records // are held until it closes, then all kept or all left out together. let currentCorrelation: { ownOrigin: boolean; + healthLogId: string | null; held: PendingRecord[]; } | null = null; // Spot-row batch awaiting flush. @@ -536,12 +567,29 @@ export async function streamParseExportXml( }; // ── Flush helpers ────────────────────────────────────────── - // A sample the app wrote before it stamped its origin marker still carries - // the HealthLog row id as `HKExternalUUID`. A hit on one of this account's - // rows (deleted ones included: the sample is ours either way) of the same - // kind is an exact match. Blood pressure is one row per half, while the app - // stamps both samples of a reading with one id (the systolic row's, the - // diastolic row's in older builds), so either half matches either id. + // A sample the app wrote carries the HealthLog row id as `HKExternalUUID`, + // with the origin marker or (before the marker existed) without it. A hit on + // one of this account's rows (deleted ones included: the sample is ours + // either way) of the same kind is an exact match. The marker alone proves + // nothing about THIS account: after a move to a new instance without a + // backup, re-importing the export must bring the mirrored values back. + // Blood pressure is one row per half, while the app stamps both samples of a + // reading with one id (the systolic row's, the diastolic row's in older + // builds), so either half matches either id. + const ownRowKinds = async ( + ids: ReadonlySet, + ): Promise> => { + const out = new Map(); + const list = [...ids]; + for (let i = 0; i < list.length; i += 1000) { + const known = await prisma.measurement.findMany({ + where: { userId, id: { in: list.slice(i, i + 1000) } }, + select: { id: true, type: true }, + }); + for (const row of known) out.set(row.id, kindOf(row.type)); + } + return out; + }; const withoutHealthLogIds = async ( rows: PreparedMeasurement[], ): Promise => { @@ -551,17 +599,16 @@ export async function streamParseExportXml( if (id) ids.add(id); } if (ids.size === 0) return rows; - const known = await prisma.measurement.findMany({ - where: { userId, id: { in: [...ids] } }, - select: { id: true, type: true }, - }); - if (known.length === 0) return rows; - const typeOf = new Map(known.map((row) => [row.id, row.type])); + const kindOfId = await ownRowKinds(ids); + if (kindOfId.size === 0) return rows; return rows.filter((row) => { const id = healthLogIdOf.get(row); - const rowType = id ? typeOf.get(id) : undefined; - if (!rowType || kindOf(rowType) !== kindOf(row.type)) return true; - countOwn("byExternalId", row.externalId); + const rowKind = id ? kindOfId.get(id) : undefined; + if (!rowKind || rowKind !== kindOf(row.type)) return true; + countOwn( + markedRows.has(row) ? "byMarker" : "byExternalId", + row.externalId, + ); return false; }); }; @@ -663,7 +710,10 @@ export async function streamParseExportXml( const chunk = await withoutManualMirrors( await withoutHealthLogIds(incoming), ); - for (const row of incoming) healthLogIdOf.delete(row); + for (const row of incoming) { + healthLogIdOf.delete(row); + markedRows.delete(row); + } for (const row of chunk) widenSpan(row.measuredAt); const insertedArrivals: Array<{ id: string; @@ -993,6 +1043,61 @@ export async function streamParseExportXml( } }; + const consumeCycle = (rec: { + hkType: string; + dayKey: string; + rawValue: string | undefined; + protectionUsed?: boolean; + }): void => { + const consumed = cycleAccumulator.consume( + rec.hkType, + rec.dayKey, + rec.rawValue, + rec.protectionUsed, + ); + if (!consumed) { + // Recognised identifier but unrecognised value — count it under + // `unknown` with the reason tag so operators can spot it. + unknown[`${rec.hkType}::cycle_unmapped`] = + (unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1; + } + }; + + // Marked cumulative samples: left out when their id is a row of this + // account of the same kind, added to their day otherwise. + const settleMarkedCumulative = async (): Promise => { + if (markedCumulative.length === 0) return; + const kindOfId = await ownRowKinds( + new Set(markedCumulative.map((m) => m.healthLogId)), + ); + for (const m of markedCumulative.splice(0, markedCumulative.length)) { + if (kindOfId.get(m.healthLogId) === kindOf(m.type)) { + countOwn("byMarker", m.sampleKey); + } else { + m.add(); + } + } + }; + + // Marked cycle samples: left out when this account holds a day-log on + // their day (deleted ones included), folded in otherwise. + const settleMarkedCycle = async (): Promise => { + if (markedCycle.length === 0) return; + const days = [...new Set(markedCycle.map((rec) => rec.dayKey))]; + const held = new Set(); + for (let i = 0; i < days.length; i += 1000) { + const logs = await prisma.cycleDayLog.findMany({ + where: { userId, date: { in: days.slice(i, i + 1000) } }, + select: { date: true }, + }); + for (const log of logs) held.add(log.date); + } + for (const rec of markedCycle.splice(0, markedCycle.length)) { + if (held.has(rec.dayKey)) countOwn("byMarker", rec.sampleKey); + else consumeCycle(rec); + } + }; + // ── SAX parser configuration ──────────────────────────────── const parser = sax.parser(true, { trim: true }); let pendingError: Error | null = null; @@ -1121,27 +1226,44 @@ export async function streamParseExportXml( attrs.startDate, attrs.endDate, ), - commit: (healthLogId) => { + commit: (healthLogId, marked) => { if (CUMULATIVE_HK_TYPES.has(mapped.type)) { const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone); const sourceHash = hashCumulativeSourceIdentity( attrs.sourceName, attrs.device, ); - let byDay = cumulativeBucket.get(mapped.type); - if (!byDay) { - byDay = new Map(); - cumulativeBucket.set(mapped.type, byDay); + const add = () => { + let byDay = cumulativeBucket.get(mapped.type); + if (!byDay) { + byDay = new Map(); + cumulativeBucket.set(mapped.type, byDay); + } + let bySource = byDay.get(dayKey); + if (!bySource) { + bySource = new Map(); + byDay.set(dayKey, bySource); + } + bySource.set( + sourceHash, + (bySource.get(sourceHash) ?? 0) + mapped.value, + ); + }; + if (marked && healthLogId) { + markedCumulative.push({ + type: mapped.type, + healthLogId, + sampleKey: hashSampleKey( + hkType, + attrs.value ?? "", + attrs.startDate, + attrs.endDate, + ), + add, + }); + } else { + add(); } - let bySource = byDay.get(dayKey); - if (!bySource) { - bySource = new Map(); - byDay.set(dayKey, bySource); - } - bySource.set( - sourceHash, - (bySource.get(sourceHash) ?? 0) + mapped.value, - ); } else { // Spot row: derive a stable externalId, queue for flush. const externalId = hashSampleKey( @@ -1163,6 +1285,7 @@ export async function streamParseExportXml( }; spotBatch.push(row); if (healthLogId) healthLogIdOf.set(row, healthLogId); + if (marked) markedRows.add(row); } }, }; @@ -1244,7 +1367,7 @@ export async function streamParseExportXml( } if (name === "Correlation") { - currentCorrelation = { ownOrigin: false, held: [] }; + currentCorrelation = { ownOrigin: false, healthLogId: null, held: [] }; return; } @@ -1255,8 +1378,12 @@ export async function streamParseExportXml( else if (currentCycleRecord) currentCycleRecord.ownOrigin = true; else if (currentCorrelation) currentCorrelation.ownOrigin = true; } - if (pendingRecord && attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) { - pendingRecord.healthLogId = attrs.value || null; + if (attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) { + // On a record, or on the correlation that wraps it. + if (pendingRecord) pendingRecord.healthLogId = attrs.value || null; + else if (currentCorrelation && !currentCycleRecord) { + currentCorrelation.healthLogId = attrs.value || null; + } } // Attach the SexualActivity protection flag to the open cycle record. // Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a @@ -1306,18 +1433,16 @@ export async function streamParseExportXml( const rec = pendingRecord; pendingRecord = null; if (currentCorrelation) currentCorrelation.held.push(rec); - else if (rec.ownOrigin) countOwn("byMarker", rec.sampleKey()); - else rec.commit(rec.healthLogId); + else rec.commit(rec.healthLogId, rec.ownOrigin); } if (tagName === "Correlation" && currentCorrelation) { const correlation = currentCorrelation; currentCorrelation = null; for (const rec of correlation.held) { - if (correlation.ownOrigin || rec.ownOrigin) { - countOwn("byMarker", rec.sampleKey()); - } else { - rec.commit(rec.healthLogId); - } + rec.commit( + rec.healthLogId ?? correlation.healthLogId, + correlation.ownOrigin || rec.ownOrigin, + ); } } if (tagName === "Workout" && currentWorkout) { @@ -1328,22 +1453,12 @@ export async function streamParseExportXml( const rec = currentCycleRecord; currentCycleRecord = null; if (rec.ownOrigin) { - // A day HealthLog mirrored into Apple Health from its own day-log. - countOwn("byMarker", rec.sampleKey); + // A day HealthLog mirrored into Apple Health from a day-log; decided + // at the end of the parse against this account's day-logs. + markedCycle.push(rec); return; } - const consumed = cycleAccumulator.consume( - rec.hkType, - rec.dayKey, - rec.rawValue, - rec.protectionUsed, - ); - if (!consumed) { - // Recognised identifier but unrecognised value — count it under - // `unknown` with the reason tag so operators can spot it. - unknown[`${rec.hkType}::cycle_unmapped`] = - (unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1; - } + consumeCycle(rec); } }; @@ -1421,7 +1536,9 @@ export async function streamParseExportXml( // threshold during the parse. await flushSpotBatch(); await flushWorkoutBatch(); + await settleMarkedCumulative(); await flushCumulativeBuckets(); + await settleMarkedCycle(); // v1.15.0 — fold the accumulated reproductive samples into CYCLE // day-logs. Gated on cycle-tracking being enabled for the account so a diff --git a/tests/fixtures/apple-health/export-own-origin.synthetic.xml b/tests/fixtures/apple-health/export-own-origin.synthetic.xml index aff5f87ac..fa9491018 100644 --- a/tests/fixtures/apple-health/export-own-origin.synthetic.xml +++ b/tests/fixtures/apple-health/export-own-origin.synthetic.xml @@ -9,9 +9,10 @@ verbatim; that has to be checked against a real export (see #1109). Records, in order: - 1. weight 80.1 carries the HealthLog origin marker -> left out + 1. weight 80.1 origin marker and a HealthLog row id -> left out + when the importing account holds that row 2. weight 80.2 user-entered, no marker -> imported - 3. weight 80.3 marker among other metadata entries -> left out + 3. weight 80.3 marker among other metadata entries, no id -> imported 4. weight 80.4 no metadata at all -> imported 5. weight 80.5 origin key with another value -> imported --> diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts index a24b663de..e252a6fea 100644 --- a/tests/integration/apple-health-import-own-samples.test.ts +++ b/tests/integration/apple-health-import-own-samples.test.ts @@ -9,7 +9,12 @@ * twice and the rollups count it twice. * * Asserted against a real Postgres: - * - a record carrying the marker is left out, and counted; + * - a record carrying the marker and a row id of this account is left out, + * and counted; the marker alone proves nothing about this account (a move + * to a new instance without a backup), so a marked record whose id is + * missing, unknown or another account's is imported, a deleted row of this + * account still counts, and a marked cycle day counts only when the + * account holds a day-log on it; * - a record without it lands as before; * - the marker is read from a child `` that follows the * record's open tag, so the record is committed at its close tag; @@ -56,13 +61,46 @@ ${records} const weight = (value: number, end: string, children = "") => ` ${children}`; -const OWN = ``; +const MARKER = ``; +const ext = (id: string) => + ``; +/** The row the app mirrored: a weight of this account, its id on the sample. */ +const OWN_WEIGHT_ID = "own-weight-row"; +const OWN_SYS_ID = "own-sys-row"; +const OWN = `${MARKER}${ext(OWN_WEIGHT_ID)}`; +const OWN_BP = `${MARKER}${ext(OWN_SYS_ID)}`; const OTHER = ``; +/** + * A row of this account the samples point at. Withings and ten minutes off the + * samples, so neither the series under test nor the manual-value match sees it. + */ +async function seedOwnRow( + userId: string, + id: string, + type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "ACTIVITY_STEPS", + deletedAt: Date | null = null, +) { + await prisma.measurement.create({ + data: { + id, + userId, + type, + value: type === "ACTIVITY_STEPS" ? 1000 : 70, + unit: + type === "WEIGHT" ? "kg" : type === "ACTIVITY_STEPS" ? "steps" : "mmHg", + source: "WITHINGS", + measuredAt: new Date("2026-05-14T06:10:00.000Z"), + deletedAt, + }, + }); +} + async function run(records: string) { const user = await prisma.user.create({ data: { username: "own-samples", email: "own@example.test", role: "USER" }, }); + await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT"); const result = await streamParseExportXml({ xmlPath: writeXml(records), userId: user.id, @@ -139,6 +177,7 @@ describe("blood pressure correlations and self-closing records", () => { const user = await prisma.user.create({ data: { username: "own-bp", email: "bp@example.test", role: "USER" }, }); + await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS"); const result = await streamParseExportXml({ xmlPath: writeXml(records), userId: user.id, @@ -148,6 +187,7 @@ describe("blood pressure correlations and self-closing records", () => { const rows = await prisma.measurement.findMany({ where: { userId: user.id, + source: "APPLE_HEALTH", type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, }, orderBy: [{ type: "asc" }], @@ -173,7 +213,7 @@ describe("blood pressure correlations and self-closing records", () => { [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( "\n", ), - `\n ${OWN}`, + `\n ${OWN_BP}`, ), ); expect(values).toEqual([]); @@ -186,7 +226,7 @@ describe("blood pressure correlations and self-closing records", () => { bpRecord("Diastolic", 79, AT), ].join("\n"); const { values } = await runBp( - ` \n${records}\n ${OWN}\n `, + ` \n${records}\n ${OWN_BP}\n `, ); expect(values).toEqual([]); }); @@ -195,7 +235,7 @@ describe("blood pressure correlations and self-closing records", () => { const { values } = await runBp( correlation( [ - bpRecord("Systolic", 121, AT, `\n ${OWN}\n `), + bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `), bpRecord("Diastolic", 79, AT), ].join("\n"), ), @@ -210,7 +250,7 @@ describe("blood pressure correlations and self-closing records", () => { [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( "\n", ), - `\n ${OWN}`, + `\n ${OWN_BP}`, ), bpRecord("Systolic", 130, "2026-05-14 09:00:00 +0200"), ].join("\n"), @@ -230,10 +270,11 @@ describe("the synthetic export fixture", () => { // `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD // documents. It shows the parser reads a file laid out like an export; it // cannot show that Apple writes a custom metadata key verbatim. - it("leaves out the two marked records and imports the other three", async () => { + it("leaves out the marked record this account holds and imports the other four", async () => { const user = await prisma.user.create({ data: { username: "own-fixture", email: "fx@example.test", role: "USER" }, }); + await seedOwnRow(user.id, "00000000-0000-0000-0000-000000000001", "WEIGHT"); const result = await streamParseExportXml({ xmlPath: join( process.cwd(), @@ -247,9 +288,9 @@ describe("the synthetic export fixture", () => { where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" }, orderBy: { measuredAt: "asc" }, }); - expect(rows.map((r) => r.value)).toEqual([80.2, 80.4, 80.5]); + expect(rows.map((r) => r.value)).toEqual([80.2, 80.3, 80.4, 80.5]); expect(result.writtenByHealthLog).toEqual({ - byMarker: 2, + byMarker: 1, byExternalId: 0, matchedManual: 0, }); @@ -421,8 +462,6 @@ describe("the manual-mirror lookup stays off the hot path", () => { describe("a sample whose HKExternalUUID is a HealthLog row id", () => { const AT = "2026-05-14 08:00:00 +0200"; - const ext = (id: string) => - ``; async function runWith( seed: (userId: string) => Promise>, @@ -524,11 +563,12 @@ describe("a sample whose HKExternalUUID is a HealthLog row id", () => { describe("each sample left out is counted once", () => { it("does not count a marked record twice when it appears top level and inside its correlation", async () => { const AT = "2026-05-14 08:00:00 +0200"; - const sys = bpRecord("Systolic", 121, AT, `\n ${OWN}\n `); - const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN}\n `); + const sys = bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `); + const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN_BP}\n `); const user = await prisma.user.create({ data: { username: "own-once", email: "once@example.test", role: "USER" }, }); + await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS"); const result = await streamParseExportXml({ xmlPath: writeXml( [sys, dia, correlation([sys, dia].join("\n"))].join("\n"), @@ -542,7 +582,7 @@ describe("each sample left out is counted once", () => { }); describe("a cycle sample HealthLog wrote itself", () => { - it("is left out of the day-log import", async () => { + it("is left out on a day this account holds, imported on a day it does not", async () => { const user = await prisma.user.create({ data: { username: "own-cycle", @@ -552,11 +592,20 @@ describe("a cycle sample HealthLog wrote itself", () => { timezone: "Europe/Berlin", }, }); + // The day the app mirrored from: a day-log of this account. + await prisma.cycleDayLog.create({ + data: { userId: user.id, date: "2026-03-02", flow: "MEDIUM" }, + }); const flow = (day: string, children = "") => ` ${children}`; const result = await streamParseExportXml({ xmlPath: writeXml( - [flow("2026-03-02", OWN), flow("2026-03-03")].join("\n"), + [ + flow("2026-03-02", MARKER), + flow("2026-03-03"), + // Marked, but no day-log here: a move to a new instance. + flow("2026-03-04", MARKER), + ].join("\n"), ), userId: user.id, userTimezone: "Europe/Berlin", @@ -566,7 +615,119 @@ describe("a cycle sample HealthLog wrote itself", () => { where: { userId: user.id }, orderBy: { date: "asc" }, }); - expect(days.map((d) => d.date)).toEqual(["2026-03-03"]); + expect(days.map((d) => d.date)).toEqual([ + "2026-03-02", + "2026-03-03", + "2026-03-04", + ]); + // The held day stays the account's own: the marked sample did not + // re-import it as an Apple Health day. + expect(days[0].source).toBe("MANUAL"); + expect(result.writtenByHealthLog.byMarker).toBe(1); + }); +}); + +describe("a marked record this account does not hold", () => { + // A move to a new instance without a backup: the export still carries the + // marker on every value the app mirrored, but none of the rows exist here. + it("is imported when the id is missing, unknown or another account's", async () => { + const other = await prisma.user.create({ + data: { username: "own-move-2", email: "mv2@example.test", role: "USER" }, + }); + await seedOwnRow(other.id, "foreign-weight-row", "WEIGHT"); + const user = await prisma.user.create({ + data: { username: "own-move", email: "mv@example.test", role: "USER" }, + }); + const result = await streamParseExportXml({ + xmlPath: writeXml( + [ + weight(80.1, "2026-05-14 08:00:00 +0200", MARKER), + weight( + 80.2, + "2026-05-14 09:00:00 +0200", + `${MARKER}${ext("00000000-0000-4000-8000-000000000009")}`, + ), + weight( + 80.3, + "2026-05-14 10:00:00 +0200", + `${MARKER}${ext("foreign-weight-row")}`, + ), + ].join("\n"), + ), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" }, + orderBy: { measuredAt: "asc" }, + }); + expect(rows.map((r) => r.value)).toEqual([80.1, 80.2, 80.3]); + expect(result.writtenByHealthLog).toEqual({ + byMarker: 0, + byExternalId: 0, + matchedManual: 0, + }); + }); + + it("is still left out when its id names a deleted row of this account", async () => { + const user = await prisma.user.create({ + data: { + username: "own-deleted", + email: "del@example.test", + role: "USER", + }, + }); + await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT", new Date()); + const result = await streamParseExportXml({ + xmlPath: writeXml(weight(80.1, "2026-05-14 08:00:00 +0200", OWN)), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" }, + }); + expect(rows).toHaveLength(0); + expect(result.writtenByHealthLog.byMarker).toBe(1); + }); + + it("adds a marked cumulative sample to its day unless its id is this account's", async () => { + const user = await prisma.user.create({ + data: { username: "own-steps", email: "st@example.test", role: "USER" }, + }); + await seedOwnRow(user.id, "own-steps-row", "ACTIVITY_STEPS"); + const steps = (value: number, at: string, children: string) => + ` ${children}`; + const result = await streamParseExportXml({ + xmlPath: writeXml( + [ + steps( + 700, + "2026-05-14 08:00:00 +0200", + `${MARKER}${ext("own-steps-row")}`, + ), + steps( + 300, + "2026-05-14 09:00:00 +0200", + `${MARKER}${ext("not-a-row")}`, + ), + steps(200, "2026-05-14 10:00:00 +0200", ""), + ].join("\n"), + ), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + }, + }); + // 300 + 200: the 700 the app mirrored from this account's row stays out. + expect(rows.map((r) => r.value)).toEqual([500]); expect(result.writtenByHealthLog.byMarker).toBe(1); }); }); From 4769502a4b70b19dffd9fbf8fdbab07ffcd421d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 22:17:27 +0200 Subject: [PATCH 17/17] fix(backup): check the weekly copy's preview against the schema section by section Taking the weekly preview from the writer dropped the schema check the old scanner ran at the end, so a copy that violates the schema was described as restorable. The collector now checks each section in its JSON form as the writer shows it, an array section and a bulk table one element at a time, plus the schema's one whole-document rule (a canonical copy names every measurement by id). A copy that fails gets no stored preview, and the preview route reads the copy itself, as it did before. Nothing is kept beyond one element. Measured on the preview memory test under --max-old-space-size=524: held after the producer 3 MB with and without the check, raw peak heapUsed 293 MB with it (335 MB without). The test now loads the backup schema before its baseline: a module loaded once per process, not memory that grows with the record. --- .../export/__tests__/backup-preview.test.ts | 66 ++++++++++++++++-- src/lib/export/backup-preview.ts | 68 +++++++++++++++++-- src/lib/jobs/reminder/backup-handlers.ts | 2 +- .../integration/backup-preview-memory.test.ts | 7 +- tests/integration/backup-round-trip.test.ts | 4 +- 5 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/lib/export/__tests__/backup-preview.test.ts b/src/lib/export/__tests__/backup-preview.test.ts index 8016c6c4c..b82ac0b6f 100644 --- a/src/lib/export/__tests__/backup-preview.test.ts +++ b/src/lib/export/__tests__/backup-preview.test.ts @@ -101,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(); @@ -112,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); @@ -121,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/backup-preview.ts b/src/lib/export/backup-preview.ts index 91eebab21..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-summary"; +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/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 62e1adeda..c8bc8fa83 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -207,7 +207,7 @@ export async function handleDataBackup( // so its size does not depend on this process's memory (#1031). // The restore preview is taken from the writer's own counts and // the sections as they are written, never by parsing the copy. - const preview = createBackupPreviewCollector(); + const preview = await createBackupPreviewCollector(); let counts: FullBackupCounts | undefined; const { bytes: storedBytes } = await storeBackupBlob( prisma, diff --git a/tests/integration/backup-preview-memory.test.ts b/tests/integration/backup-preview-memory.test.ts index fbe8eb6f6..569ec6e7b 100644 --- a/tests/integration/backup-preview-memory.test.ts +++ b/tests/integration/backup-preview-memory.test.ts @@ -118,6 +118,11 @@ describe("weekly copy preview under a memory budget", () => { }); it("holds nothing of the record once the producer is done", async () => { + // The backup schema the preview checks each section against is a module + // loaded once per process (about 12 MB, `backup-summary.ts`), not memory + // that grows with the record: load it before the baseline, as a running + // server has long since done. + await import("@/lib/validations/backup"); const baseline = liveHeapBytes(); let peakHeld = 0; let heldAtEnd = 0; @@ -127,7 +132,7 @@ describe("weekly copy preview under a memory budget", () => { rawPeak = Math.max(rawPeak, process.memoryUsage().heapUsed); }, 5); // Wired exactly as the weekly job wires it (`backup-handlers.ts`). - const preview = createBackupPreviewCollector(); + const preview = await createBackupPreviewCollector(); let counts: FullBackupCounts | undefined; try { await storeBackupBlob( diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 045447121..28e9b83bc 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1495,7 +1495,7 @@ describe("every model the plan claims two-ended survives a real restore", () => // below then covers BOTH, since the row that gets restored comes from the // streamed bytes. let streamedJson = ""; - const previewCollector = createBackupPreviewCollector(); + const previewCollector = await createBackupPreviewCollector(); const streamedCounts = await streamFullBackupJson( prisma, OWNER_ID, @@ -3779,7 +3779,7 @@ describe("every column of every two-ended model survives a real restore", () => const exportedAt = new Date("2026-08-01T00:00:00.000Z"); let json = ""; - const previewCollector = createBackupPreviewCollector(); + const previewCollector = await createBackupPreviewCollector(); const counts = await streamFullBackupJson( prisma, OWNER_ID,