diff --git a/.github/ISSUE_TEMPLATE/translation.yml b/.github/ISSUE_TEMPLATE/translation.yml index 65b56c608..4e481ce85 100644 --- a/.github/ISSUE_TEMPLATE/translation.yml +++ b/.github/ISSUE_TEMPLATE/translation.yml @@ -1,12 +1,12 @@ name: Translation feedback -description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL). +description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL). title: "[Translation] " labels: ["i18n", "translation-feedback"] body: - type: markdown attributes: value: | - HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, and Polish ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary. + HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary. This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle. @@ -20,6 +20,8 @@ body: - Spanish (es) - Italian (it) - Polish (pl) + - Korean (ko) + - Dutch (nl) - Other (specify under "Surface") validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d1d33cd..4a6df7858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,151 @@ # Changelog +## [1.43.7] — 2026-10-20 + +### Added + +- **Dutch.** HealthLog speaks Dutch now, the eighth language, translated and + contributed by @robdegraaf (#1246). Friends in the Netherlands are always + welcome here. Cheers with a Grolsch! 🍺 +- **Readings you enter here count for Apple Health freshness.** Weight, blood + pressure and glucose are no longer called quiet while you keep entering them + in HealthLog for the phone to pick up, and the card shows when you last did + (`lastEnteredHereAt`, Refs #1247). + +### Fixed + +- **Error states say what happened.** When a session has ended, a card offers + to sign in again instead of a retry that cannot work; when there were too + many requests just now, it says so without a pointless retry; a section you + are not allowed to see on a shared record stays quiet. Some thirty cards that + used to show a bare red line, a "still learning" note, an endless skeleton + or nothing at all when their data failed to load now say so and offer a + retry, and charts follow the same rule. Every read failure goes through the + same row, card and chart state, deciding by the error code first and the + status second. +- **A short database outage no longer signs you out.** A session that could not + be checked because the database was unreachable, out of connections or + otherwise failing answers 503 (`auth.session.unavailable`) and the client + keeps its credential and tries again. A malformed session cookie is treated + as signed out (401) without a database lookup. +- **A nap is not a night, but it is sleep.** A day whose only sleep is a short + daytime nap has no main night (`main: null` on `/api/sleep/night`). A day's + sleep in the list, the export and the correlations counts its naps, the same + total the chart and the sleep score show; the main night alone is + `mainAsleepMinutes`. The night view opened without a date shows the last + night with main sleep. The sleep list, the night view, the chart and the + sleep score name the same day for every night. A REM segment of zero minutes + no longer drops the night around it. +- **Deleted entries stay deleted for the AI.** Deleted mood entries and + readings no longer shape the status texts written about you, and the general + overview looks at every metric again instead of the newest few thousand rows + of the densest one. +- **Days logged for a chronic condition count** in the symptom correlations. +- **Inviting someone whose name or e-mail matches no account** says so + (`sharing.invite.no_account`) and is never confused with a profile you no + longer look after. +- **Old nights open, and "All" means all.** The night view answers any date, + however old, and the sleep series reaches back to the first night. The sleep + status read the last few weeks of stages; it now reads the last year of + nights. +- **Days are your days.** The dashboard streak and the day a dose counts for + were placed on the wrong side of midnight for readings close to it; both + follow the profile timezone now. Skipping a dose from Telegram snoozes until + the end of your own day, not the server's. +- **One number for one thing.** The spread of a series is the same in the app, + the API, MCP and AI tools. Correlations on the insights overview and in AI + data appear only when they are statistically sound (at least 20 pairs, + p < 0.05), as everywhere else; below that the value is `null`. A chronic + condition no longer fills the symptom series with zeros. +- **Medication adherence on the cadence chips** counts pauses, courses and + missed doses like the detail page; a week's pause read 77 % instead of 100 %. +- **A workout Apple Health sends twice is stored once,** and every workout + reader uses the same source order. +- **Provider histories do not end early without saying so.** A WHOOP, Oura, + Withings, Fitbit or Strava history that stops at its page limit is no longer + marked as fully imported. A Strava history of more than about 4,000 + activities imports in stages from where the last stage stopped, one stage per + rate window, instead of re-reading the newest activities after every + restart. WHOOP and Oura no longer read the first page twice when a list ends + with an empty page token. Imported values from every provider and from + Health Connect are rounded the same way, with halves rounded up (a weight of + 72.345 kg now stores as 72.35, not 72.34). +- **A long Google Health backfill keeps its place.** Only attempts that end at + the same edge count toward the final end, and a restart during the 24-hour + wait no longer starts a second chain. +- **The recovery page** says in one sentence why it is still empty, decided on + the server (`recoveryEmpty`), and always links to the integrations. +- **Lists that stop at a limit say so** (`meta.truncated`, `meta.limit`) instead + of passing the oldest loaded entry off as the beginning, and the web shows a + note under each such list. The activity feed keeps its `data.truncated` for + existing clients; it is deprecated in favour of `meta.truncated`. +- Reconnecting Strava, even to a different account, reads that account's full + history again instead of continuing where the previous one stopped. +- The day view's "highest since" notes pick up a new or corrected reading right + away. +- **Old days and long histories are read whole.** The sleep summary covers the + whole history, the pulse assessment reads at least a month of pulse instead + of a few hours of a dense stream, and the mood fallback reads the same five + years as the rollup path. +- The coach's daily limit on follow-up questions resets at your own midnight, + and the weather and air-quality refresh includes today east of UTC. +- **Deleting a large account** no longer aborts after five seconds. +- Workout dates follow your date format in the list, the detail and the + dashboard tile. "Not enough nights yet" names the number actually needed. + `groupBy=day` together with `valueMin`/`valueMax` answers 422 instead of + ignoring the filter. + +### Changed + +- **Faster reads.** The latest value per type is read through one bounded query + instead of a sort over every reading (about 60 ms down to 1 to 2 ms on a large + account), the dashboard summaries share one cache cell, and paging through the + day view computes the two-year comparison once. +- **One heavy job at a time per process.** Imports, restores, backups, rollup + backfills, provider backfills and the nightly runs queue behind each other + instead of stacking up in one heap. A job that finds the lane busy goes back + to the queue and starts once it is free, so a backup waiting behind a large + import no longer runs out of time while it waits. A long Google Health + backfill makes room at its next slice boundary for a job waiting behind it, + and a job that waits more than two hours is reported as a warning + (`job.heavy_lane.waiting_long`). Building rollups for a large account no + longer runs a second time when it takes longer than fifteen minutes. The six per-metric status + crons are gone (the nightly batch already covers them), and a queue fed only + by its cron polls every 30 seconds. +- **`measurements` stays small.** An update that changes nothing keeps its + timestamps, so it stays on its page and is not sent to the phone again; the + table leaves room on each page and autovacuum starts at 5 % dead rows. A + nightly check warns when dead rows or bloat grow past that (see + `docs/ops/measurement-maintenance.md`). +- **Migrations that wait for a lock no longer hold writers back or stop the + boot.** `prisma migrate deploy` runs a migration file statement by + statement, not in one transaction, so the lock-sensitive part of migration + 0412 runs as one block that gives up after ten seconds when another process + holds `measurements`. The container then retries a migration marked safe to + re-run, with a growing pause, instead of stopping at boot; any other + migration failure still stops the boot. See "A migration that timed out on a + lock" in `docs/ops/deploy.md`. +- The day view's "highest since" notes cache fixed 90-day blocks and use less + memory when paging back through older days. +- An insights card whose refresh keeps failing stops polling after its usual + number of attempts. +- Failed provider syncs and refused uploads are logged as signals instead of + audit entries; a provider's error text no longer lands in the audit log in + plain text. +- **The cached AI health report is encrypted at rest,** existing rows included. +- Every API error carries a stable `meta.errorCode`; a route without its own + code gets an `http.*` code from the status. +- Logs group requests by route template (`/api/medications/[id]`) instead of by + concrete path. +- Failed inputs no longer fill the audit log; only attempts by someone with a + grant appear there. +- The browser bundle no longer pulls in server modules, and a test keeps it + that way. +- The Coach stops polling a document that will never be indexed. +- On an out-of-memory crash the process writes a diagnostic report to stderr + (`--report-on-fatalerror`, without environment or network details), and the + heap monitor adds a 95 % tier. + ## [1.43.6] — 2026-10-19 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c00ba213f..4424d2695 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`. ## Translations -HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them. +HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them. ### Improving an existing locale @@ -136,7 +136,7 @@ To suggest a fix or improvement: ### Adding a new locale -If you want to introduce a locale that isn't shipped (e.g. `nl`, `pt-BR`, `cs`): +If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`): 1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier. 2. Copy `messages/en.json` to `messages/.json`. diff --git a/Dockerfile b/Dockerfile index a06be1896..625feff6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,15 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV TZ=Europe/Berlin +# When the heap runs out, Node writes a diagnostic report (the JavaScript +# and native stacks, heap spaces, libuv handles) to stderr, where +# `docker compose logs` and Loki keep it; without it the log holds one +# line. Environment variables and network interfaces are left out, so no +# key, password or address from the environment reaches the log. An +# operator who sets NODE_OPTIONS replaces this value and should carry these +# flags over (docs/ops/heap-diagnostics.md). +ENV NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network" + # v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage # into the runner so /api/version reads the build-arg value instead of # the package.json fallback. The variable is keyed to the CI tag ref @@ -203,11 +212,13 @@ COPY --from=builder /app/src/generated ./src/generated # application graph (`@/…`, the generated Prisma client) run from a source # checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds # the runbooks and the entrypoint against this list. `pending-migrations.mjs` -# is the entrypoint's own: it logs the pending migration count before -# `migrate deploy`. Root-owned on purpose: the app user can run them but not +# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the +# pending migration count, the second runs `migrate deploy` with its +# lock-timeout retry. Root-owned on purpose: the app user can run them but not # rewrite them. COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs +COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts # Prove the reset CLI's imports resolve in this tree: with every import found @@ -215,7 +226,8 @@ COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-int RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:" # The boot notice parses, and the `pg` it loads on a real boot resolves here. RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \ - node --input-type=module -e "await import('pg')" + node --input-type=module -e "await import('pg')" && \ + node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')" # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma diff --git a/README.md b/README.md index ecc15faeb..64b04c63e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig - **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network. - **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit. - **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere. -- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end. +- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end. The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev). diff --git a/bundle-budget.json b/bundle-budget.json index a3ad865fc..0f96999b5 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard are unchanged.", "routeBaselineKbGz": { "/page": 355, "/insights/page": 304, @@ -8,6 +8,7 @@ "/settings/[section]/page": 307 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 5000, - "maxRechartsChunks": 1 + "totalClientKbGz": 3700, + "maxRechartsChunks": 1, + "maxCatalogKbGz": 210 } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8eff71a6b..b33d52002 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -88,9 +88,12 @@ echo "HealthLog: Database is reachable." # Say how many migrations this image is about to apply, with a reminder to # back up first. Reporting only: it never stops the boot. node scripts/pending-migrations.mjs || true +# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a +# migration that timed out on a lock and is marked safe to re-run; any other +# failure stops the boot with Prisma's exit code (docs/ops/deploy.md). echo "HealthLog: Running database migrations..." NODE_PATH=/opt/prisma-cli/node_modules \ - node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy + node scripts/migrate-deploy.mjs echo "HealthLog: Migrations complete." # ── Start application ──────────────────────────────────────── diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index af613a5d8..89c03a66b 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.43.6 + version: 1.43.7 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -80,8 +80,10 @@ paths: 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a - password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the - route refused the credential it was handed, not that the response is malformed." + password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route + refused the credential it was handed. A session that could not be checked because the session store was + unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its + credential and retries." content: application/json: schema: @@ -3514,9 +3516,10 @@ paths: $ref: "#/components/schemas/ApiTokenRevokeEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing - was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is - off, so its owner can still see it here even though this call will not kill it. + description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the + status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way — + a token stays live while the switch is off, so its owner can still see it here even though this call will + not kill it. content: application/json: schema: @@ -4203,8 +4206,13 @@ paths: With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over - those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way - over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes + those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) + are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way + over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its + naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone + in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps + carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=` + answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied. parameters: @@ -4727,16 +4735,18 @@ paths: source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` - omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, - no new measurement type." + omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over + and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is + answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new + measurement type." parameters: - in: query name: date schema: - description: Wake-day key (YYYY-MM-DD). Omit for the most recent night. + description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep. type: string pattern: ^\d{4}-\d{2}-\d{2}$ - description: Wake-day key (YYYY-MM-DD). Omit for the most recent night. + description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep. responses: "200": description: Reconstructed night. @@ -7701,7 +7711,7 @@ paths: `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message - `health-score-config.body.invalid_json` with no errorCode." + `health-score-config.body.invalid_json` with the status default `meta.errorCode: http.invalid`." content: application/json: schema: @@ -21427,7 +21437,9 @@ paths: per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day - default. Not delegable: a grant is not an introduction to the other people in the household." + default. Whether the list stopped at its row ceiling is `meta.truncated`; the `truncated` field in `data` + carries the same value and is deprecated. Not delegable: a grant is not an introduction to the other people in + the household." responses: "200": description: The activity, and how far back it can see. @@ -21514,7 +21526,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: No account on this instance carries that identifier. + description: "No account on this instance carries that identifier (`meta.errorCode: sharing.invite.no_account`)." content: application/json: schema: @@ -21705,8 +21717,8 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": - description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none - anywhere in this API; branch on the status. Nothing was created. + description: "More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status + default; a 429 has one meaning here, so the status alone is enough to branch on). Nothing was created." content: application/json: schema: @@ -21789,8 +21801,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": - description: More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch - on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at. + description: "More than ten edits from one caller within the hour — the ceiling creation carries. `meta.errorCode: + http.rate_limited`, the status default. Nothing was changed, and the refusal lands before the record named + in the path is looked at." content: application/json: schema: @@ -21875,11 +21888,10 @@ paths: summary: Invite another Guardian description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on - `meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route - answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account - carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A - client branching on the status first would tell somebody to check a spelling when the real answer is that the - record is no longer theirs to administer." + `meta.errorCode`, not on the status**, because this route answers 404 twice and the two need different next + steps: `sharing.invite.no_account` means no account carries that identifier, while `managed_profile.not_found` + means the caller is not a Guardian of that profile at all. A client branching on the status would tell somebody + to check a spelling when the real answer is that the record is no longer theirs to administer." parameters: - in: path name: id @@ -21906,11 +21918,11 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this - instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST - /api/account/grants` makes, and codeless there too, so this family does not answer one way here and another - way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is - not one of its Guardians." + description: "Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no + account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller + that `POST /api/account/grants` makes, with the same code, so this family does not answer one way here and + another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the + caller is not one of its Guardians." content: application/json: schema: @@ -21933,7 +21945,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": - description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status. + description: "More than ten invitations from one caller within the hour (`meta.errorCode: http.rate_limited`, the status + default)." content: application/json: schema: @@ -25902,6 +25915,7 @@ components: - it - pl - ko + - nl practiceName: type: string maxLength: 120 @@ -28360,6 +28374,7 @@ components: - it - pl - ko + - nl today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." @@ -29310,6 +29325,7 @@ components: - it - pl - ko + - nl description: One of the interface locales this build ships. required: - locale @@ -34599,6 +34615,7 @@ components: - it - pl - ko + - nl timezone: type: string minLength: 1 @@ -34653,6 +34670,7 @@ components: - it - pl - ko + - nl timezone: type: string minLength: 1 @@ -34870,6 +34888,7 @@ components: - it - pl - ko + - nl - type: "null" timezone: type: string @@ -36095,15 +36114,19 @@ components: description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped - client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a - refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. - Three families are outside it on purpose: `assistant.disabled.` is built from a template so the - last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, - and `assistant.disabled.coach` is the one the native client names; the integration-probe classes - (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the - per-provider additions) are enumerated in each `/test` operation instead, where the differences can be - stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai — - ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, + client branches on. Every error envelope carries a code: when the route has no reason of its own to + name, the code is the status's generic one from the `http` family (`http.not_found` for a 404, + `http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own + entry), so `http.*` says which kind of refusal it was and never why. Treat an unlisted code the way you + would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — + since the list grows with the surfaces. Three families are outside it on purpose: + `assistant.disabled.` is built from a template so the last segment is open — it is one of + `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the + one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, + `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each + `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a + credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded, + ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — @@ -36112,35 +36135,35 @@ components: anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, - auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required, - auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, - backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; - biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, - coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, - coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, - coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, - coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; - consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid, - cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, - cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; dawarich — - dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, - dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, - documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, + auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, + backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — + body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, + consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, + dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — + day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound, + documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -36150,18 +36173,22 @@ components: environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found; export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, - health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, - illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, - illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy, - import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, - insights.generate.outboundScreened, insights.generate.profileScopeChanged, - insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, - labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, - labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, - labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, - labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, - labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error, + http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid, + http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error, + http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid, + illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, + illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — + import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — + insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, + labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, + labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, @@ -36208,9 +36235,9 @@ components: share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, - sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, - sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; @@ -36258,10 +36285,11 @@ components: - data - error additionalProperties: false - description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable - machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue - validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in - one round-trip." + description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable + machine code — the route's own where it names one, otherwise the status's generic `http.*` code — + `meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context. + A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, + so a client can fix them in one round-trip." ValidationIssue: type: object properties: @@ -37246,15 +37274,32 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false + ListCapMeta: + type: object + properties: + requestId: + type: string + truncated: + type: boolean + description: "True when more rows exist than this response carries: the list was cut at `limit`, newest or most relevant + first as the operation states. False means the list is complete. Show the person that the list is partial + rather than letting the last row read as the oldest there is." + limit: + type: integer + minimum: 1 + maximum: 10000 + description: The most rows this response could carry. + required: + - truncated + - limit + additionalProperties: false + description: How a capped list was cut. Present on every response of an operation that declares it. CycleInsightsEnvelope: type: object properties: @@ -45167,14 +45212,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false SecurityActivityResponse: type: object @@ -46250,6 +46292,23 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + mainAsleepMinutes: + description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the + day's total, the main night plus its naps. + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" + napCount: + description: Naps on the same wake day. + type: integer + minimum: 0 + maximum: 9007199254740991 + napAsleepMinutes: + description: Time asleep across those naps, in minutes. + type: number + minimum: 0 required: - id - type @@ -47074,6 +47133,23 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + mainAsleepMinutes: + description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the + day's total, the main night plus its naps. + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" + napCount: + description: Naps on the same wake day. + type: integer + minimum: 0 + maximum: 9007199254740991 + napAsleepMinutes: + description: Time asleep across those naps, in minutes. + type: number + minimum: 0 required: - id - type @@ -47255,6 +47331,23 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + mainAsleepMinutes: + description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the + day's total, the main night plus its naps. + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" + napCount: + description: Naps on the same wake day. + type: integer + minimum: 0 + maximum: 9007199254740991 + napAsleepMinutes: + description: Time asleep across those naps, in minutes. + type: number + minimum: 0 status: type: string enum: @@ -47890,7 +47983,9 @@ components: - naps additionalProperties: false description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps - surfaced separately." + surfaced separately. A session is a nap when it holds under three hours asleep centred between 10:00 and 20:00 + local time; the main session is the longest session that is not a nap. A day whose only sleep is naps has `main: + null` and its naps." SleepRhythmResponse: type: object properties: @@ -49447,14 +49542,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false MedicationSideEffect: type: object @@ -55782,6 +55874,7 @@ components: - it - pl - ko + - nl required: - locale additionalProperties: false @@ -62980,14 +63073,30 @@ components: - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session. Observational only — the served summary stays the winning writer's totals. + recoveryEmpty: + anyOf: + - type: string + enum: + - general + - oura_permission + - oura_no_resilience + - type: "null" + description: "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type + (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, + ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves + out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected + and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and + never work it out from the connection status." required: - summaries - bmi - lastSeenByType - sleepSourceDiscrepancy + - recoveryEmpty additionalProperties: false - description: "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys, - and the caller gets them from two SQL passes rather than the default slice's chain." + description: "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty + reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default + slice's chain." InsightsGenerateReadEnvelope: type: object properties: @@ -64822,14 +64931,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false EcgListResponse: type: object @@ -65092,14 +65198,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false RhythmEventsResponse: type: object @@ -72808,14 +72911,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false IllnessEpisode: type: object @@ -73495,14 +73595,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false ListMentalHealthAssessmentsResponse: type: object @@ -73721,14 +73818,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false Allergy: type: object @@ -73879,14 +73973,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false FamilyHistoryEntry: type: object @@ -76528,6 +76619,17 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + lastEnteredHereAt: + description: "v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood + glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the + type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the + phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both + this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - type - lastSeenAt @@ -81272,14 +81374,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false RecordActivity: type: object @@ -81297,9 +81396,9 @@ components: bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows. truncated: type: boolean - description: True when the read reached its row ceiling, so `entries` is the most recent activity inside the window - rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest - row read as the beginning. False means the list is everything inside the window. + deprecated: true + description: "Deprecated: read `meta.truncated`, which carries the same value. Whether the list stopped at its row + ceiling; kept in `data` for clients built against v1.43.6 and earlier." required: - entries - retentionDays @@ -81363,14 +81462,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false AccountGrantList: type: object @@ -82785,6 +82881,7 @@ components: - it - pl - ko + - nl - type: "null" timezone: type: string @@ -83221,14 +83318,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false PersonalRecord: type: object @@ -88238,9 +88332,9 @@ components: type: number description: Effective weight after redistributing missing sub-scores. gap: - description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the - window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had - only a daytime nap. Absent otherwise." + description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than `rhythmMinNights` + nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the + latest day had only a daytime nap. Absent otherwise." type: string enum: - few_nights @@ -88262,6 +88356,12 @@ components: maximum: 9007199254740991 description: Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. A daytime nap is not one; a short night is. + rhythmMinNights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 'How many `rhythmNights` consistency and timing need before they are scored; below it they carry `gap: + "few_nights"`.' series: type: array items: @@ -88276,6 +88376,7 @@ components: - subScores - windowNights - rhythmNights + - rhythmMinNights - series additionalProperties: false parameters: diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 50c8890d5..554f1ed1f 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -245,8 +245,10 @@ reading: upper-bound filter Google refused. - `stopReason`: why it ended early. `time_budget` and `page_budget` are the normal end of a slice, `rate_limited` means Google would not take more - requests for now, `job_budget` that the job itself ran out of time. - `null` means the slice read everything it started. + requests for now, `job_budget` that the job itself ran out of time, + `lane_wanted` that the slice made room for another heavy job (a nightly + run, a backup, an upload) that was waiting; the next slice starts two + minutes later. `null` means the slice read everything it started. - `collectionsDone` of `collectionsTotal`: data types read to the end so far. - `collections`: one entry per data type the slice touched, with its @@ -265,7 +267,10 @@ reading: nothing left but a data type waiting for its second attempt and queued the next slice for when it is due (`delaySeconds`). - `meta["googleHealth.backfill.ended"]` appears on the slice where a data - type ended short, with the same entries. A walk stopped for lack of + type ended short: the `collection`, its measurement `type`, the `reason`, + whether the end is `final`, how many data types have ended short so far + (`endedCount`) and of those for good (`finalCount`), and their `types`. + The full entries are in the slice report's `ended`. A walk stopped for lack of progress also leaves a warning: `fetchHeartRate: 20 pages in a row reached no further back than `. - `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice diff --git a/docs/ops/attempt-number-semantics.md b/docs/ops/attempt-number-semantics.md index b7b9ff5f8..9d6955b7a 100644 --- a/docs/ops/attempt-number-semantics.md +++ b/docs/ops/attempt-number-semantics.md @@ -1,18 +1,24 @@ -# `attemptNumber` audit-row vs alert-signal semantics +# `attempt_number` signal vs alert semantics Two numbers describe a failing integration sync, and they can diverge. -On-call operators reading an audit-log row alongside an admin alert -should expect the alert number to be smaller than (or equal to) the -audit number, never the other way around. +Operators reading an `integrations.sync.failed` log line alongside an admin +alert should expect the alert number to be smaller than (or equal to) the +logged number, never the other way around. + +Since v1.43.7 a failed sync is a `warn` signal in the log store, not an +audit-log row: the audit log records what a person or an admin did to an +account, and a provider answering 503 is neither. The provider's message is +not on the signal; it stays encrypted on the integration's status row and +shows in the integration's settings. ## The two numbers -`integrations.sync.failed` audit rows write -`attemptNumber = transient + reauth_required + persistent` — the **sum** +`integrations.sync.failed` signals carry +`attempt_number = transient + reauth_required + persistent` — the **sum** of all three failure buckets. This is the historical "consecutive failures" running total carried by the legacy `IntegrationStatus.consecutiveFailures` column (dropped in migration -`0077`, v1.4.47). Auditors looking at a single row see one integer +`0077`, v1.4.47). An operator looking at a single line sees one integer that captures how many failures the bucket ledger has accumulated since the last success. @@ -27,14 +33,14 @@ single-bucket threshold even if its sum looks alarming. ## When the two diverge A typed-failure-streak edge case: integration fails 2× transient, then -2× reauth_required, then 1× persistent. Audit row shows -`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below -the default threshold of 3, so no page fires. From the audit log this +2× reauth_required, then 1× persistent. The last signal shows +`attempt_number: 5`. The alert number is `max(2, 2, 1) = 2`, which is below +the default threshold of 3, so no page fires. From the log this looks like a sustained 5-failure outage; from the alert dispatcher it looks like three small bumps that never crossed a single-bucket ceiling. -Both readings are correct for their purpose. The audit log records what +Both readings are correct for their purpose. The signal records what happened end-to-end; the alert dispatcher only fires when a single failure mode persists, because mixed-bucket streaks usually indicate a transient network blip layered on top of an unrelated reauth prompt — @@ -43,7 +49,7 @@ we want to wake someone up for. ## Runbook implication -If you see an audit row with `attemptNumber >= threshold` but no alert +If you see a signal with `attempt_number >= threshold` but no alert was dispatched, check the per-bucket counts on `IntegrationStatus.consecutiveFailuresByKind` (DB column `consecutive_failures_by_kind`) for that user/integration pair. The diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 1bf418cdc..89ac3d380 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -40,6 +40,55 @@ not reachable". The entrypoint also runs the migrations on every boot, so step 2 is optional; it is there for when you want to see the migration result before the new container takes traffic. +### A migration that timed out on a lock + +Some migrations bound how long they wait for a table lock, so that a +long-running holder (most often an autovacuum that is preventing +transaction-ID wraparound, which does not step aside) cannot queue every +write to that table behind the migration. Migration `0412` waits at most ten +seconds for `measurements`. When the wait runs out, the boot log shows +Prisma's report followed by a line from the entrypoint: + +``` +Error: P3018 +... +Migration name: 0412_measurement_hot_updates + +Database error code: 55P03 +... +HealthLog: migration 0412_measurement_hot_updates timed out waiting for a lock (55P03) and changed nothing; marking it rolled back and retrying in 15s (retry 1 of 5). +``` + +What happens on its own: such a migration is written so that a timed-out +attempt changes nothing, and it says so in its file +(`-- @retry-on-lock-timeout`). The entrypoint then marks that one migration +rolled back (`prisma migrate resolve --rolled-back`) and runs +`migrate deploy` again after 15, 30, 60, 120 and 240 seconds. The app starts +as soon as an attempt gets the lock. A later boot that finds the attempt still +on record (Prisma's `P3009`) reads the cause from `_prisma_migrations` and +does the same. Nothing else is retried: any other failure, and a lock timeout +in a migration that does not carry the marker, stops the boot exactly as +before. + +If all five retries time out, the log ends with +`HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving +up.` and the container exits. Something has held the table for about eight +minutes. Find it: + +```sh +docker compose exec db psql -U healthlog -c " + SELECT pid, backend_type, state, now() - xact_start AS running, query + FROM pg_stat_activity + WHERE pid IN (SELECT pid FROM pg_locks + WHERE relation = 'measurements'::regclass)" +``` + +An `autovacuum: VACUUM … (to prevent wraparound)` row finishes on its own; +wait for it to disappear and start the container again (a restart policy does +that for you, and each boot retries). Anything else is a session you can +judge: a stuck client may be ended with `SELECT pg_terminate_backend()`. +You never need to run `migrate resolve` by hand for this case. + ### v1.11.0 notes Migrations `0110`–`0114` are additive (new tables / columns for the diff --git a/docs/ops/heap-diagnostics.md b/docs/ops/heap-diagnostics.md index f7a1f02c5..87b12052f 100644 --- a/docs/ops/heap-diagnostics.md +++ b/docs/ops/heap-diagnostics.md @@ -7,7 +7,7 @@ A Node process that reaches its heap limit stops with `JavaScript heap out of me Two background wide events, no configuration needed: - `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, `old_space_mb`, `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`. -- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. +- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. One exception: when the heap reaches 95 %, the same report is written again at once, with `pressure_ratio: 0.95`, whatever the cooldown; that tier re-arms as soon as the heap is back under 85 %. A heap that climbs to its limit shortly after an 85 % report therefore still leaves a report naming the work that pushed it over. The heap is read every five seconds, a single call that takes microseconds. Tracking requests costs one map insert and one delete per request. @@ -17,6 +17,16 @@ To find the last lines before a crash: docker compose logs app --since 2h | grep '"runtime.heap.' ``` +## The report Node writes when the heap runs out + +The image sets `NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"`. When the process runs out of heap, Node writes a diagnostic report, about 10 KB of JSON with `"trigger": "OOMError"`, to stderr just before it exits: the JavaScript stack at the failed allocation, the native stack, the heap spaces and the open handles. It lands in the container log beside the `runtime.heap.*` lines: + +```sh +docker compose logs app --since 2h | grep -A400 '"trigger": "OOMError"' +``` + +The two exclude flags keep environment variables (keys, passwords, the database URL) and network interfaces out of the report. If you set `NODE_OPTIONS` yourself, for example to raise `--max-old-space-size`, your value replaces the image's; add the four flags to it to keep the report. + ## Optional heap snapshot A snapshot shows what the heap is made of, which the events above cannot. Turn it on only while you are chasing a specific crash: diff --git a/docs/ops/measurement-maintenance.md b/docs/ops/measurement-maintenance.md index 2576b3fcd..d6a7865eb 100644 --- a/docs/ops/measurement-maintenance.md +++ b/docs/ops/measurement-maintenance.md @@ -137,7 +137,8 @@ it. ## Reading the result The run's wide event `job.measurement_maintenance` carries -`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the +`maintenance_outcome` (`completed`, `nothing_due` for the nightly check, +`refused_purge_running`, `failed`), the live and dead rows the run started from, the table and index sizes before and after, and `maintenance_steps`, one entry per step with its target, size before and after, and duration. The same sizes from `psql`: @@ -147,6 +148,43 @@ SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total, pg_size_pretty(pg_indexes_size('measurements')) AS indexes; ``` +## The nightly check + +Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin). +That run names no steps and changes nothing: vacuuming the table is +autovacuum's job, which migration 0412 starts at 5 % dead rows instead of +Postgres's 20 %. The check reads the table's live and dead rows and warns +(`maintenance_autovacuum_behind` on the wide event) when there are at least +10,000 dead rows and more than one for every five live ones, four times the +point at which autovacuum should already have run. That means autovacuum is +not keeping up: look for a transaction left open for hours +(`pg_stat_activity`, `state = 'idle in transaction'`) or for autovacuum +workers busy elsewhere. The check never rebuilds an index. It shares the +singleton key with the run you start, so the two never overlap. The admin +card keeps showing only the runs you started. + +The check also measures how far the table file has grown past what its live +rows need (`maintenance_bloat_factor` on the wide event, `bloat_factor` in the +job result). Past 3 it adds a warning to the event: a vacuum makes the space +reusable but does not shrink the file, and only `VACUUM FULL` does (see +below). + +Why the table grew in the first place: until v1.43.7 an update that changed +nothing still moved `updated_at`, which sits in an index, so no update could +stay on its page. Migration 0412 keeps `updated_at` and `sync_version` on such +an update and gives the table `fillfactor = 85` and a 5 % autovacuum +threshold. To check that updates stay on their page: + +```sql +SELECT n_tup_upd, n_tup_hot_upd, n_live_tup, n_dead_tup, + pg_size_pretty(pg_relation_size('measurements')) AS heap +FROM pg_stat_user_tables WHERE relname = 'measurements'; +``` + +`n_tup_hot_upd` should now grow along with `n_tup_upd`. The fill factor +applies to pages written after the migration; a `VACUUM FULL` after the +upgrade applies it to the whole table. + ## "could not resize shared memory segment" Up to v1.42.0 the run could stop at once with `could not resize shared memory @@ -160,6 +198,6 @@ runs single-process and needs no shared segment, and the bundled ## Not part of this `VACUUM FULL` would shrink the table file as well, but it locks the table for -reads and writes while it runs. After the purge the remaining table is small -and the space inside it is reused by new rows, so it is not needed. If you -want it anyway, run it yourself in a maintenance window with the app stopped. +reads and writes while it runs, so no job starts it. When the nightly check +warns about the bloat factor, run it yourself in a maintenance window with the +app stopped. diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index c39d30b5a..77759eeeb 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -79,9 +79,13 @@ Raise the limit on the container that runs the worker and re-upload: services: app: environment: - NODE_OPTIONS: --max-old-space-size=1024 + NODE_OPTIONS: --max-old-space-size=1024 --report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network ``` +Your `NODE_OPTIONS` replaces the image's, so carry the four `--report-*` +flags over: they make an out-of-memory exit leave a diagnostic report in +the container log (see `docs/ops/heap-diagnostics.md`). + An import interrupted by a container restart or an out-of-memory kill does not stay stuck: the worker reconciles non-terminal import jobs at boot and every 15 minutes, flipping orphaned rows to `failed` diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index f908d4c2a..4adc25d80 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -132,7 +132,8 @@ helper text explaining why. Silent disabled buttons frustrate users. ### 2.2 Inputs and forms -We use `react-hook-form` + Zod schemas from `src/lib/validations/`. +Forms keep a controlled draft in component state and validate it with the +Zod schemas from `src/lib/validations/`; the server's schema is the authority. Wrap inputs in `