mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
chore(scripts): remove one-off scripts nothing runs
Nothing in package.json, the workflows, the Dockerfile, the docs or the other scripts named these: the bench result dumps of v1.44 and v1.45 that no document cites, the version-pinned release checks, the v1.42 correlation dry run, the read-only diagnostics and their cleanup and conversion companions for defects fixed long ago, the one-off locale builder from v1.4.25, and the four June seeders for the demo account. Kept: the two bench results the benchmarks doc cites, the Google Health sleep repair (the duplicate-night issue is still open), the demo migration its password guard reads, the column-reader sweep its test imports, and the message-bundle tools. The scripts README now lists the kept tools that it did not name before and drops a reference to a file that does not exist.
This commit is contained in:
1 parent
9227844f42
commit
655a1d65a5
33 files changed
+23
-13254
No files matched your search
+22
-18
@@ -1,6 +1,6 @@
|
||||
# `scripts/`
|
||||
|
||||
One-shot operator and build scripts. These are not part of the running application — they are run by hand during a deploy, a backfill, or a release, or wired into CI. Recurring work belongs on pg-boss (`src/lib/jobs/`), not here.
|
||||
One-shot operator and build scripts. They are not part of the running application: they are run by hand during a deploy, a backfill, or a release, or wired into CI. Recurring work belongs on pg-boss (`src/lib/jobs/`), not here.
|
||||
|
||||
## How to run them
|
||||
|
||||
@@ -18,23 +18,27 @@ typecheck.
|
||||
|
||||
## What lives here
|
||||
|
||||
- **`generate-openapi.ts`** — emits `docs/api/openapi.yaml` from the registered Zod schemas (`pnpm openapi:generate`).
|
||||
- **`check-openapi.ts`** — compares a freshly generated spec against the committed YAML; CI fails on drift (`pnpm openapi:check`).
|
||||
- **`check-env.ts`** — pre-deploy env-var sanity check against `env-manifest.json` (`pnpm check-env`); wired into CI.
|
||||
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
|
||||
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
|
||||
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
|
||||
- **`repair-apple-health-distance.ts`** — repair walking-distance rows the `export.zip` import stored 1000x too small; dry-run by default, see `docs/ops/apple-health-distance-repair.md`.
|
||||
- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
|
||||
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
|
||||
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
|
||||
- **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
|
||||
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
|
||||
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
|
||||
- **`build-pdf-fonts.py`** — rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header.
|
||||
- **`install-hooks.sh`** — install the local git hooks.
|
||||
- **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report.
|
||||
- **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above.
|
||||
- **`generate-openapi.ts`**: emits `docs/api/openapi.yaml` from the registered Zod schemas (`pnpm openapi:generate`).
|
||||
- **`check-openapi.ts`**: compares a freshly generated spec against the committed YAML; CI fails on drift (`pnpm openapi:check`).
|
||||
- **`check-env.ts`**: pre-deploy env-var sanity check against `env-manifest.json` (`pnpm check-env`); wired into CI.
|
||||
- **`assert-deploy.ts`**: post-deploy `/api/version` assertion.
|
||||
- **`rotate-encryption-key.ts`**: AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
|
||||
- **`restore-backup.ts`**: restore an off-host AES-GCM backup from an S3-compatible bucket.
|
||||
- **`repair-apple-health-distance.ts`**: repair walking-distance rows the `export.zip` import stored 1000x too small; dry-run by default, see `docs/ops/apple-health-distance-repair.md`.
|
||||
- **`repair-intake-anomalies.ts`**: repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
|
||||
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`**: historical data backfills (the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
|
||||
- **`seed-demo.ts`** + **`seed-demo-v15.sql`**: populate a demo tenant.
|
||||
- **`bench/`**: `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
|
||||
- **`test-notifications.ts`**: exercise the notification dispatcher + reminder check.
|
||||
- **`fetch-geolite2.sh`**: download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
|
||||
- **`build-pdf-fonts.py`**: rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header.
|
||||
- **`install-hooks.sh`**: install the local git hooks.
|
||||
- **`generate-sw-version.mjs`**, **`check-bundle-budget.mjs`**: service-worker version stamp and bundle-size report (`pnpm bundle-report`).
|
||||
- **`repair-google-health-sleep.ts`**: re-syncs every Google Health connection so nights stored twice before v1.28.18 collapse to one set; dry-run by default, `--run` to execute.
|
||||
- **`i18n-key-tool.mjs`**, **`split-description.mjs`**: edit one key across every `messages/*.json` (show, set, drop) and move an over-long card description's tail into its own key; **`measure-description-lengths.mjs`** measures the Settings and Admin description strings against the 120-character guideline.
|
||||
- **`audit-column-readers.ts`**: lists write-only schema columns and open `@pending-consumer` markers; a review tool, not a gate.
|
||||
- **`migrate-demo-edge01.sh`**: copies the demo account from one instance to a separate demo instance; see its README.
|
||||
- **`__tests__/`**: tests for the scripts above.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"label": "v1.44.0-baseline",
|
||||
"commit": "d640eea32",
|
||||
"date": "2026-10-22T03:54:05.746Z",
|
||||
"node": "v22.23.2",
|
||||
"postgres": "16.15",
|
||||
"cpus": 14,
|
||||
"loadAverage": [
|
||||
9.4,
|
||||
20.1,
|
||||
20
|
||||
],
|
||||
"measurements": 1266549,
|
||||
"iterations": 20,
|
||||
"warmup": 3,
|
||||
"heapMb": 792,
|
||||
"idleStatementsPerSecond": 0,
|
||||
"batch5k": {
|
||||
"totalMs": 1722,
|
||||
"totalStatements": 424
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "/api/auth/me",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/auth/me",
|
||||
"n": 20,
|
||||
"p50Ms": 12.1,
|
||||
"p95Ms": 17.4,
|
||||
"maxMs": 18.4,
|
||||
"statementsMedian": 19,
|
||||
"statementsMax": 19,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "/api/auth/me",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/auth/me",
|
||||
"n": 20,
|
||||
"p50Ms": 16.6,
|
||||
"p95Ms": 22.2,
|
||||
"maxMs": 24.4,
|
||||
"statementsMedian": 19,
|
||||
"statementsMax": 19,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard summary",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/dashboard/summary",
|
||||
"n": 20,
|
||||
"p50Ms": 205.6,
|
||||
"p95Ms": 233.2,
|
||||
"maxMs": 275.3,
|
||||
"statementsMedian": 31,
|
||||
"statementsMax": 32,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard summary",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/dashboard/summary",
|
||||
"n": 20,
|
||||
"p50Ms": 12.8,
|
||||
"p95Ms": 14.3,
|
||||
"maxMs": 27.5,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 7,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard snapshot",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/dashboard/snapshot",
|
||||
"n": 20,
|
||||
"p50Ms": 2157.8,
|
||||
"p95Ms": 2252.7,
|
||||
"maxMs": 2292.9,
|
||||
"statementsMedian": 158,
|
||||
"statementsMax": 161,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard snapshot",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/dashboard/snapshot",
|
||||
"n": 20,
|
||||
"p50Ms": 15.6,
|
||||
"p95Ms": 23.5,
|
||||
"maxMs": 25.6,
|
||||
"statementsMedian": 13,
|
||||
"statementsMax": 16,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 30 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=30",
|
||||
"n": 20,
|
||||
"p50Ms": 50.8,
|
||||
"p95Ms": 58.7,
|
||||
"maxMs": 58.9,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 9,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 30 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=30",
|
||||
"n": 20,
|
||||
"p50Ms": 52.3,
|
||||
"p95Ms": 59.5,
|
||||
"maxMs": 59.9,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 365 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=365",
|
||||
"n": 20,
|
||||
"p50Ms": 289,
|
||||
"p95Ms": 311.3,
|
||||
"maxMs": 363.7,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 9,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 365 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=365",
|
||||
"n": 20,
|
||||
"p50Ms": 274.3,
|
||||
"p95Ms": 355.8,
|
||||
"maxMs": 366.8,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 3650 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=3650",
|
||||
"n": 20,
|
||||
"p50Ms": 1502.6,
|
||||
"p95Ms": 2117.6,
|
||||
"maxMs": 2122.9,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 9,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 3650 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=3650",
|
||||
"n": 20,
|
||||
"p50Ms": 1490.3,
|
||||
"p95Ms": 1505.5,
|
||||
"maxMs": 1555.2,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 11,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "day view",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/day/2026-10-21",
|
||||
"n": 20,
|
||||
"p50Ms": 61.7,
|
||||
"p95Ms": 77.5,
|
||||
"maxMs": 86.1,
|
||||
"statementsMedian": 49,
|
||||
"statementsMax": 50,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "day view",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/day/2026-10-21",
|
||||
"n": 20,
|
||||
"p50Ms": 49,
|
||||
"p95Ms": 55.6,
|
||||
"maxMs": 62.5,
|
||||
"statementsMedian": 48,
|
||||
"statementsMax": 48,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights health status",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/insights/health-status",
|
||||
"n": 20,
|
||||
"p50Ms": 124.1,
|
||||
"p95Ms": 139.5,
|
||||
"maxMs": 141.5,
|
||||
"statementsMedian": 68,
|
||||
"statementsMax": 68,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights health status",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/insights/health-status",
|
||||
"n": 20,
|
||||
"p50Ms": 124.7,
|
||||
"p95Ms": 157.8,
|
||||
"maxMs": 165.3,
|
||||
"statementsMedian": 68,
|
||||
"statementsMax": 68,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights correlations",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/insights/correlations",
|
||||
"n": 20,
|
||||
"p50Ms": 95.4,
|
||||
"p95Ms": 105,
|
||||
"maxMs": 108.8,
|
||||
"statementsMedian": 45,
|
||||
"statementsMax": 45,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights correlations",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/insights/correlations",
|
||||
"n": 20,
|
||||
"p50Ms": 13,
|
||||
"p95Ms": 15.4,
|
||||
"maxMs": 15.9,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "batch ingest 5k (per 500)",
|
||||
"mode": "write",
|
||||
"request": "POST /api/measurements/batch ×10",
|
||||
"n": 10,
|
||||
"p50Ms": 163,
|
||||
"p95Ms": 220.7,
|
||||
"maxMs": 220.7,
|
||||
"statementsMedian": 42,
|
||||
"statementsMax": 45,
|
||||
"failures": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Benchmark v1.44.0-baseline
|
||||
|
||||
Commit d640eea32, 2026-10-22T03:54:05.746Z. 1266549 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 9.4,20.1,20 at the end. Idle background: 0 statements/s.
|
||||
|
||||
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| /api/auth/me | cold | 20 | 12.1 | 17.4 | 18.4 | 19 | 19 |
|
||||
| /api/auth/me | warm | 20 | 16.6 | 22.2 | 24.4 | 19 | 19 |
|
||||
| dashboard summary | cold | 20 | 205.6 | 233.2 | 275.3 | 31 | 32 |
|
||||
| dashboard summary | warm | 20 | 12.8 | 14.3 | 27.5 | 7 | 7 |
|
||||
| dashboard snapshot | cold | 20 | 2157.8 | 2252.7 | 2292.9 | 158 | 161 |
|
||||
| dashboard snapshot | warm | 20 | 15.6 | 23.5 | 25.6 | 13 | 16 |
|
||||
| series pulse 30 d | cold | 20 | 50.8 | 58.7 | 58.9 | 8 | 9 |
|
||||
| series pulse 30 d | warm | 20 | 52.3 | 59.5 | 59.9 | 8 | 8 |
|
||||
| series pulse 365 d | cold | 20 | 289 | 311.3 | 363.7 | 8 | 9 |
|
||||
| series pulse 365 d | warm | 20 | 274.3 | 355.8 | 366.8 | 8 | 8 |
|
||||
| series pulse 3650 d | cold | 20 | 1502.6 | 2117.6 | 2122.9 | 8 | 9 |
|
||||
| series pulse 3650 d | warm | 20 | 1490.3 | 1505.5 | 1555.2 | 8 | 11 |
|
||||
| day view | cold | 20 | 61.7 | 77.5 | 86.1 | 49 | 50 |
|
||||
| day view | warm | 20 | 49 | 55.6 | 62.5 | 48 | 48 |
|
||||
| insights health status | cold | 20 | 124.1 | 139.5 | 141.5 | 68 | 68 |
|
||||
| insights health status | warm | 20 | 124.7 | 157.8 | 165.3 | 68 | 68 |
|
||||
| insights correlations | cold | 20 | 95.4 | 105 | 108.8 | 45 | 45 |
|
||||
| insights correlations | warm | 20 | 13 | 15.4 | 15.9 | 8 | 8 |
|
||||
| batch ingest 5k (per 500) | write | 10 | 163 | 220.7 | 220.7 | 42 | 45 |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,254 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"label": "v1.45.0-before",
|
||||
"commit": "1915f9152",
|
||||
"date": "2026-10-22T05:51:47.626Z",
|
||||
"node": "v22.23.2",
|
||||
"postgres": "16.15",
|
||||
"cpus": 14,
|
||||
"loadAverage": [
|
||||
29.9,
|
||||
21.9,
|
||||
18.9
|
||||
],
|
||||
"measurements": 1265561,
|
||||
"iterations": 20,
|
||||
"warmup": 3,
|
||||
"heapMb": 792,
|
||||
"idleStatementsPerSecond": 0,
|
||||
"batch5k": {
|
||||
"totalMs": 1730,
|
||||
"totalStatements": 534
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "/api/auth/me",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/auth/me",
|
||||
"n": 20,
|
||||
"p50Ms": 9.2,
|
||||
"p95Ms": 11.6,
|
||||
"maxMs": 13.3,
|
||||
"statementsMedian": 19,
|
||||
"statementsMax": 19,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "/api/auth/me",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/auth/me",
|
||||
"n": 20,
|
||||
"p50Ms": 8.6,
|
||||
"p95Ms": 10.5,
|
||||
"maxMs": 13,
|
||||
"statementsMedian": 19,
|
||||
"statementsMax": 19,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard summary",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/dashboard/summary",
|
||||
"n": 20,
|
||||
"p50Ms": 170.2,
|
||||
"p95Ms": 220.5,
|
||||
"maxMs": 228.3,
|
||||
"statementsMedian": 31,
|
||||
"statementsMax": 34,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard summary",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/dashboard/summary",
|
||||
"n": 20,
|
||||
"p50Ms": 6,
|
||||
"p95Ms": 6.7,
|
||||
"maxMs": 6.8,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 7,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard snapshot",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/dashboard/snapshot",
|
||||
"n": 20,
|
||||
"p50Ms": 1481.8,
|
||||
"p95Ms": 1526.2,
|
||||
"maxMs": 1530,
|
||||
"statementsMedian": 158,
|
||||
"statementsMax": 161,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard snapshot",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/dashboard/snapshot",
|
||||
"n": 20,
|
||||
"p50Ms": 7,
|
||||
"p95Ms": 7.9,
|
||||
"maxMs": 10.2,
|
||||
"statementsMedian": 13,
|
||||
"statementsMax": 13,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 30 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=30",
|
||||
"n": 20,
|
||||
"p50Ms": 24.4,
|
||||
"p95Ms": 30.8,
|
||||
"maxMs": 31.1,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 30 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=30",
|
||||
"n": 20,
|
||||
"p50Ms": 20.5,
|
||||
"p95Ms": 29.4,
|
||||
"maxMs": 30.2,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 9,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 365 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=365",
|
||||
"n": 20,
|
||||
"p50Ms": 184.1,
|
||||
"p95Ms": 192.6,
|
||||
"maxMs": 201.1,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 365 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=365",
|
||||
"n": 20,
|
||||
"p50Ms": 178.1,
|
||||
"p95Ms": 186.5,
|
||||
"maxMs": 187.6,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 3650 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=3650",
|
||||
"n": 20,
|
||||
"p50Ms": 1627,
|
||||
"p95Ms": 1664.5,
|
||||
"maxMs": 1675.7,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 9,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 3650 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=3650",
|
||||
"n": 20,
|
||||
"p50Ms": 2090.9,
|
||||
"p95Ms": 2435.4,
|
||||
"maxMs": 2529.7,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 9,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "day view",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/day/2026-10-21",
|
||||
"n": 20,
|
||||
"p50Ms": 76.7,
|
||||
"p95Ms": 93.6,
|
||||
"maxMs": 116.2,
|
||||
"statementsMedian": 49,
|
||||
"statementsMax": 49,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "day view",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/day/2026-10-21",
|
||||
"n": 20,
|
||||
"p50Ms": 62.8,
|
||||
"p95Ms": 72,
|
||||
"maxMs": 73.5,
|
||||
"statementsMedian": 48,
|
||||
"statementsMax": 48,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights health status",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/insights/health-status",
|
||||
"n": 20,
|
||||
"p50Ms": 244.7,
|
||||
"p95Ms": 401.2,
|
||||
"maxMs": 404.1,
|
||||
"statementsMedian": 68,
|
||||
"statementsMax": 69,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights health status",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/insights/health-status",
|
||||
"n": 20,
|
||||
"p50Ms": 238.9,
|
||||
"p95Ms": 399.1,
|
||||
"maxMs": 476.6,
|
||||
"statementsMedian": 68,
|
||||
"statementsMax": 68,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights correlations",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/insights/correlations",
|
||||
"n": 20,
|
||||
"p50Ms": 105.6,
|
||||
"p95Ms": 326.9,
|
||||
"maxMs": 349.7,
|
||||
"statementsMedian": 49,
|
||||
"statementsMax": 52,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights correlations",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/insights/correlations",
|
||||
"n": 20,
|
||||
"p50Ms": 11.3,
|
||||
"p95Ms": 16.6,
|
||||
"maxMs": 20.3,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "batch ingest 5k (per 500)",
|
||||
"mode": "write",
|
||||
"request": "POST /api/measurements/batch ×10",
|
||||
"n": 10,
|
||||
"p50Ms": 166.1,
|
||||
"p95Ms": 220.3,
|
||||
"maxMs": 220.3,
|
||||
"statementsMedian": 53,
|
||||
"statementsMax": 56,
|
||||
"failures": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Benchmark v1.45.0-before
|
||||
|
||||
Commit 1915f9152 (the v1.44 runtime this branch starts from), 2026-10-22T05:51:47.626Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 29.9,21.9,18.9 at the end. Idle background: 0 statements/s.
|
||||
|
||||
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| /api/auth/me | cold | 20 | 9.2 | 11.6 | 13.3 | 19 | 19 |
|
||||
| /api/auth/me | warm | 20 | 8.6 | 10.5 | 13 | 19 | 19 |
|
||||
| dashboard summary | cold | 20 | 170.2 | 220.5 | 228.3 | 31 | 34 |
|
||||
| dashboard summary | warm | 20 | 6 | 6.7 | 6.8 | 7 | 7 |
|
||||
| dashboard snapshot | cold | 20 | 1481.8 | 1526.2 | 1530 | 158 | 161 |
|
||||
| dashboard snapshot | warm | 20 | 7 | 7.9 | 10.2 | 13 | 13 |
|
||||
| series pulse 30 d | cold | 20 | 24.4 | 30.8 | 31.1 | 8 | 8 |
|
||||
| series pulse 30 d | warm | 20 | 20.5 | 29.4 | 30.2 | 8 | 9 |
|
||||
| series pulse 365 d | cold | 20 | 184.1 | 192.6 | 201.1 | 8 | 8 |
|
||||
| series pulse 365 d | warm | 20 | 178.1 | 186.5 | 187.6 | 8 | 8 |
|
||||
| series pulse 3650 d | cold | 20 | 1627 | 1664.5 | 1675.7 | 8 | 9 |
|
||||
| series pulse 3650 d | warm | 20 | 2090.9 | 2435.4 | 2529.7 | 8 | 9 |
|
||||
| day view | cold | 20 | 76.7 | 93.6 | 116.2 | 49 | 49 |
|
||||
| day view | warm | 20 | 62.8 | 72 | 73.5 | 48 | 48 |
|
||||
| insights health status | cold | 20 | 244.7 | 401.2 | 404.1 | 68 | 69 |
|
||||
| insights health status | warm | 20 | 238.9 | 399.1 | 476.6 | 68 | 68 |
|
||||
| insights correlations | cold | 20 | 105.6 | 326.9 | 349.7 | 49 | 52 |
|
||||
| insights correlations | warm | 20 | 11.3 | 16.6 | 20.3 | 8 | 8 |
|
||||
| batch ingest 5k (per 500) | write | 10 | 166.1 | 220.3 | 220.3 | 53 | 56 |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,298 +0,0 @@
|
||||
# Boot profile v1.45.0-boot-after
|
||||
|
||||
Commit a859adb59, 2026-10-22T04:49:50.419Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 8.6,9.4,10.7 at the end.
|
||||
|
||||
- first answer from /api/health: 850 ms; background role ready: 3038 ms
|
||||
- event-loop stalls logged: 0, max 0 ms
|
||||
|
||||
## Stalls
|
||||
|
||||
|
||||
## Heap samples
|
||||
|
||||
| at s | used MB | old | large obj | code | external | array buffers | rss |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 6 | 166 | 91 | 53 | 3 | 12 | 1 | 430 |
|
||||
| 306 | 153 | 90 | 53 | 4 | 11 | 0 | 599 |
|
||||
|
||||
## Full collections (mark-compact)
|
||||
|
||||
10 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
|
||||
|
||||
| at s | before MB | after MB |
|
||||
| ---: | ---: | ---: |
|
||||
| 0 | 20.2 | 17.1 |
|
||||
| 0 | 55.3 | 52.6 |
|
||||
| 0 | 83.2 | 81.1 |
|
||||
| 1 | 148.4 | 132.7 |
|
||||
| 16 | 159.6 | 153.2 |
|
||||
| 17 | 153.4 | 151 |
|
||||
| 62 | 167.5 | 157.5 |
|
||||
| 246 | 183.4 | 152.6 |
|
||||
| 303 | 165.6 | 151.8 |
|
||||
| 303 | 151.8 | 151.8 |
|
||||
|
||||
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
|
||||
|
||||
### at 6 ms, 175 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| npm:next | 47.2 |
|
||||
| node:internal/modules/cjs/loader | 34.8 |
|
||||
| (native) | 34.5 |
|
||||
| node:internal/bootstrap/realm | 25.7 |
|
||||
| node:internal/deps/undici/undici | 8.8 |
|
||||
| node:path | 6.3 |
|
||||
| node:internal/modules/package_json_reader | 3 |
|
||||
| node:internal/net | 3 |
|
||||
| node:internal/modules/esm/resolve | 2.5 |
|
||||
| node:fs | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
|
||||
### at 203 ms, 241 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 52.7 |
|
||||
| node:internal/modules/cjs/loader | 40.1 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 31.7 |
|
||||
| wasm://wasm/00d1cb12 | 28.7 |
|
||||
| npm:@prisma/client | 28 |
|
||||
| npm:next | 16.3 |
|
||||
| node:internal/modules/esm/utils | 12 |
|
||||
| node:fs | 4.5 |
|
||||
| npm:zod | 3 |
|
||||
| node:internal/modules/helpers | 3 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/instrumentation.ts | 80.8 |
|
||||
| src/lib/db-session.ts | 49.5 |
|
||||
| src/lib/boot/startup-state.ts | 25.6 |
|
||||
| src/lib/boot/boot-steps.ts | 25.6 |
|
||||
| src/lib/logging/background.ts | 25.6 |
|
||||
| src/lib/process-type.ts | 25.5 |
|
||||
| src/lib/logging/context.ts | 13.6 |
|
||||
| src/lib/validations/measurement.ts | 12.1 |
|
||||
| src/lib/logging/redact.ts | 10.5 |
|
||||
| src/lib/validations/entry-instant.ts | 4.6 |
|
||||
|
||||
### at 501 ms, 178 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| node:internal/modules/cjs/loader | 53.9 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37.5 |
|
||||
| npm:next | 18 |
|
||||
| npm:zod | 12 |
|
||||
| node:internal/modules/esm/utils | 10.5 |
|
||||
| (native) | 8.7 |
|
||||
| node:fs | 6 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 4.5 |
|
||||
| npm:undici | 4.5 |
|
||||
| node:internal/url | 1.8 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/app/api/health/route.ts | 61.4 |
|
||||
| src/lib/api-errors.ts | 27 |
|
||||
| src/lib/jobs/job-failures.ts | 14.9 |
|
||||
| src/lib/jobs/offhost-backup.ts | 14.9 |
|
||||
| src/lib/managed-profiles/handover-access.ts | 14.9 |
|
||||
| src/lib/sharing/acting-session.ts | 14.9 |
|
||||
| src/lib/db-session.ts | 13.4 |
|
||||
| src/lib/export/environment-backup.ts | 13.4 |
|
||||
| src/lib/export/full-backup-payload.ts | 13.4 |
|
||||
| src/lib/logging/background.ts | 10.5 |
|
||||
|
||||
### at 833 ms, 184 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 38.9 |
|
||||
| node:internal/modules/cjs/loader | 28.6 |
|
||||
| (native) | 22.8 |
|
||||
| src/lib/i18n/server-catalogs.ts | 15.1 |
|
||||
| npm:yaml | 15 |
|
||||
| node:fs | 12.1 |
|
||||
| npm:zod | 9 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.9 |
|
||||
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
|
||||
| npm:semver | 3 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/jobs/reminder-worker.ts | 129.1 |
|
||||
| src/lib/jobs/reminder/google-health-sync.ts | 88.5 |
|
||||
| src/lib/whoop/sync.ts | 79.5 |
|
||||
| src/lib/whoop/sync-body.ts | 63 |
|
||||
| src/lib/insights/bucket-series.ts | 60 |
|
||||
| src/lib/whoop/oauth-descriptor.ts | 60 |
|
||||
| src/lib/whoop/sync-core.ts | 60 |
|
||||
| src/instrumentation.ts | 43.4 |
|
||||
| src/lib/import/unzip-export-xml.ts | 31.4 |
|
||||
| src/lib/jobs/integration-status-null-probe.ts | 31.4 |
|
||||
|
||||
### at 1099 ms, 54 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| node:internal/modules/cjs/loader | 23.9 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 10.6 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.8 |
|
||||
| src/lib/daily/today-overview.ts | 4.5 |
|
||||
| src/lib/jobs/reminder-worker.ts | 3 |
|
||||
| node:fs | 3 |
|
||||
| npm:pg-boss | 1.5 |
|
||||
| npm:zod | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/jobs/reminder-worker.ts | 52.3 |
|
||||
| src/lib/jobs/reminder/insights-handlers.ts | 22.3 |
|
||||
| src/lib/ai/prompts/arrival-reaction.ts | 12 |
|
||||
| src/lib/daily/score-steady.ts | 10.5 |
|
||||
| src/lib/dashboard/bands.ts | 6 |
|
||||
| src/lib/jobs/data-arrival.ts | 4.5 |
|
||||
| src/lib/dashboard/snapshot-read.ts | 4.5 |
|
||||
| src/lib/daily/today-overview.ts | 4.5 |
|
||||
| src/lib/daily/priority-item-key.ts | 4.5 |
|
||||
| src/lib/ai/prompts/status-batch.ts | 3 |
|
||||
|
||||
### at 1309 ms, 51 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| node:internal/modules/cjs/loader | 28 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6 |
|
||||
| node:fs | 4.5 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/runtime-base.ts | 1.5 |
|
||||
| src/lib/jobs/failure-condition.ts | 1.5 |
|
||||
| src/lib/labs/parse-reference-range.ts | 1.5 |
|
||||
| npm:zod | 1.5 |
|
||||
| node:internal/linkedlist | 1.5 |
|
||||
| (native) | 0.9 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/jobs/reminder-worker.ts | 48.2 |
|
||||
| src/lib/jobs/reminder/ops-handlers.ts | 14.1 |
|
||||
| src/lib/medications/ingredients/label-extract.ts | 3 |
|
||||
| src/lib/export/restore-backup.ts | 2.1 |
|
||||
| src/lib/jobs/failure-condition.ts | 1.5 |
|
||||
| src/lib/documents/ai-runs/summary-run.ts | 1.5 |
|
||||
| src/lib/documents/auto-stage-labs.ts | 1.5 |
|
||||
| src/lib/documents/ai-runs/store.ts | 1.5 |
|
||||
| src/lib/labs/parse-reference-range.ts | 1.5 |
|
||||
| src/lib/labs/ocr-upload.ts | 1.5 |
|
||||
|
||||
## Self time by module until the worker is ready
|
||||
|
||||
| module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 230.8 |
|
||||
| node:internal/modules/cjs/loader | 223.6 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 124.7 |
|
||||
| npm:next | 98.8 |
|
||||
| wasm://wasm/00d1cb12 | 62.6 |
|
||||
| npm:@prisma/client | 56.2 |
|
||||
| node:fs | 34.6 |
|
||||
| node:internal/bootstrap/realm | 28.7 |
|
||||
| npm:pg | 28.3 |
|
||||
| npm:zod | 27 |
|
||||
| npm:pg-boss | 24.6 |
|
||||
| node:internal/modules/esm/utils | 22.5 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 20.6 |
|
||||
| npm:luxon | 17 |
|
||||
| src/lib/i18n/server-catalogs.ts | 15.1 |
|
||||
| npm:yaml | 15 |
|
||||
| npm:pg-pool | 13 |
|
||||
| node:internal/deps/undici/undici | 10.4 |
|
||||
| node:path | 7.8 |
|
||||
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 7.5 |
|
||||
| node:internal/async_hooks | 7.5 |
|
||||
| node:internal/process/task_queues | 7.3 |
|
||||
| node:internal/modules/package_json_reader | 6 |
|
||||
| npm:undici | 6 |
|
||||
| npm:pg-protocol | 6 |
|
||||
|
||||
## Heap snapshot after 300 s idle (live heap after a full GC)
|
||||
|
||||
160.1 MB in 1275329 nodes; module source strings over 50 KB: 39.8 MB.
|
||||
|
||||
- parsed locale catalogs: 8 objects, 7 MB retained
|
||||
|
||||
| node type | MB |
|
||||
| --- | ---: |
|
||||
| string | 68.7 |
|
||||
| array | 26.4 |
|
||||
| code | 21.1 |
|
||||
| object | 14.1 |
|
||||
| hidden | 8.9 |
|
||||
| native | 8 |
|
||||
| object shape | 5.9 |
|
||||
| closure | 5.2 |
|
||||
| concatenated string | 1.3 |
|
||||
| regexp | 0.2 |
|
||||
| sliced string | 0.2 |
|
||||
| number | 0.1 |
|
||||
| synthetic | 0 |
|
||||
| symbol | 0 |
|
||||
| bigint | 0 |
|
||||
|
||||
| constructor | MB | count |
|
||||
| --- | ---: | ---: |
|
||||
| array:(object elements) | 20.4 | 23797 |
|
||||
| object:Object | 10.2 | 274231 |
|
||||
| native:system / JSArrayBufferData | 7.6 | 171 |
|
||||
| array:(object properties) | 3.6 | 6512 |
|
||||
| array: | 2.4 | 10502 |
|
||||
| closure: | 1.6 | 29042 |
|
||||
| object:system / Context | 1.6 | 28873 |
|
||||
| closure:native_bind | 1 | 20782 |
|
||||
| object:Array | 0.6 | 19679 |
|
||||
| object:a | 0.4 | 4438 |
|
||||
| native:Node / histogram | 0.4 | 1 |
|
||||
| closure:get | 0.3 | 4736 |
|
||||
| object:d | 0.3 | 4425 |
|
||||
| object:Set | 0.2 | 5673 |
|
||||
| closure:set | 0.2 | 3432 |
|
||||
| object:Module | 0.2 | 1958 |
|
||||
| closure:i | 0.1 | 1581 |
|
||||
| closure:n | 0.1 | 1572 |
|
||||
| object:Promise | 0.1 | 1277 |
|
||||
| object:n | 0.1 | 1881 |
|
||||
|
||||
| largest retained subtrees | MB | retaining path |
|
||||
| --- | ---: | --- |
|
||||
| object:t | 33.9 | [1] > [10] > 60 > extension > global > prisma > [1] |
|
||||
| object:global | 7.9 | [1] > [10] > 60 > extension |
|
||||
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
|
||||
| object:Module | 5.6 | [1] > [10] > 97 > process > nextTick > patchedNextTick > context > system / Context > require > require > cache > .n |
|
||||
| code:.next/server/chu | 5.6 | require > cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
|
||||
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
|
||||
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
|
||||
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 62203 |
|
||||
| object:Object | 3.3 | system / Context > require > require > cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
|
||||
|
||||
| largest strings | MB |
|
||||
| --- | ---: |
|
||||
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
|
||||
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
|
||||
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
|
||||
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
|
||||
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
|
||||
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
|
||||
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
|
||||
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
|
||||
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
|
||||
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
|
||||
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
|
||||
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
|
||||
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
|
||||
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
|
||||
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,804 +0,0 @@
|
||||
# Boot profile v1.45.0-boot-final
|
||||
|
||||
Commit a57fa8335, 2026-10-22T06:58:52.591Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 5.6,6.4,11.1 at the end.
|
||||
|
||||
- first answer from /api/health: 1325 ms; background role ready: 3217 ms
|
||||
- event-loop stalls logged: 0, max 0 ms
|
||||
|
||||
## Stalls
|
||||
|
||||
|
||||
## Heap samples
|
||||
|
||||
| at s | used MB | old | large obj | code | external | array buffers | rss |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 6 | 225 | 158 | 54 | 4 | 11 | 0 | 500 |
|
||||
| 306 | 158 | 96 | 53 | 5 | 11 | 0 | 788 |
|
||||
|
||||
## Full collections (mark-compact)
|
||||
|
||||
98 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
|
||||
|
||||
| at s | before MB | after MB |
|
||||
| ---: | ---: | ---: |
|
||||
| 0 | 21.5 | 17.5 |
|
||||
| 8 | 203.8 | 146.5 |
|
||||
| 24 | 236.5 | 142.5 |
|
||||
| 41 | 223.8 | 144.9 |
|
||||
| 60 | 225.8 | 147.5 |
|
||||
| 85 | 224.4 | 147.7 |
|
||||
| 124 | 225.4 | 151.2 |
|
||||
| 167 | 176.7 | 151 |
|
||||
| 209 | 230.1 | 150.4 |
|
||||
| 242 | 197.9 | 151.5 |
|
||||
| 273 | 224.8 | 152.2 |
|
||||
| 302 | 177.9 | 152.5 |
|
||||
| 312 | 170.5 | 152.9 |
|
||||
| 327 | 170.6 | 152 |
|
||||
| 338 | 169.7 | 151.8 |
|
||||
|
||||
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
|
||||
|
||||
### at 5 ms, 396 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 230.2 |
|
||||
| node:internal/modules/cjs/loader | 57.8 |
|
||||
| npm:next | 52.3 |
|
||||
| node:internal/bootstrap/realm | 29.3 |
|
||||
| node:internal/deps/undici/undici | 8.9 |
|
||||
| node:internal/modules/esm/loader | 4.5 |
|
||||
| node:fs | 3 |
|
||||
| node:internal/net | 2.5 |
|
||||
| node:internal/bootstrap/switches/is_main_thread | 2 |
|
||||
| node:os | 1.8 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
|
||||
### at 472 ms, 336 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 121.3 |
|
||||
| wasm://wasm/00d1cb12 | 43 |
|
||||
| node:internal/modules/cjs/loader | 40.5 |
|
||||
| npm:next | 31.7 |
|
||||
| npm:@prisma/client | 23.8 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 23.4 |
|
||||
| node:internal/modules/esm/utils | 17.3 |
|
||||
| npm:zod | 5.9 |
|
||||
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3 |
|
||||
| node:vm | 1.7 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/instrumentation.ts | 95.8 |
|
||||
| src/lib/db-session.ts | 47.3 |
|
||||
| src/lib/process-type.ts | 27.1 |
|
||||
| src/lib/boot/startup-state.ts | 25.5 |
|
||||
| src/lib/boot/boot-steps.ts | 25.5 |
|
||||
| src/lib/logging/background.ts | 25.5 |
|
||||
| src/lib/logging/redact.ts | 13 |
|
||||
| src/lib/logging/context.ts | 11 |
|
||||
| src/lib/validations/measurement.ts | 10 |
|
||||
| src/generated/prisma/internal/class.ts | 4.7 |
|
||||
|
||||
### at 811 ms, 225 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| node:internal/modules/cjs/loader | 52.3 |
|
||||
| (native) | 48.2 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37 |
|
||||
| npm:next | 22.2 |
|
||||
| npm:zod | 12.5 |
|
||||
| node:internal/modules/esm/utils | 8.7 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.6 |
|
||||
| node:fs | 4.5 |
|
||||
| node:internal/async_hooks | 3 |
|
||||
| node:internal/modules/esm/translators | 2.6 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/app/api/health/route.ts | 62 |
|
||||
| src/lib/api-errors.ts | 25.9 |
|
||||
| src/lib/auth/secure-cookie.ts | 14.9 |
|
||||
| src/lib/logging/background.ts | 13.4 |
|
||||
| src/lib/sharing/acting-session.ts | 12.8 |
|
||||
| src/lib/validations/measurement.ts | 11.9 |
|
||||
| src/lib/db-session.ts | 11.3 |
|
||||
| src/lib/export/environment-backup.ts | 11.3 |
|
||||
| src/lib/export/full-backup-payload.ts | 11.3 |
|
||||
| src/lib/jobs/job-failures.ts | 11.3 |
|
||||
|
||||
### at 1063 ms, 246 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 64.7 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 47.2 |
|
||||
| node:internal/modules/cjs/loader | 31.6 |
|
||||
| npm:luxon | 18 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 15.3 |
|
||||
| npm:yaml | 15 |
|
||||
| npm:zod | 10.2 |
|
||||
| src/lib/i18n/server-catalogs.ts | 8.2 |
|
||||
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
|
||||
| npm:cron-parser | 4 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/jobs/reminder-worker.ts | 137.6 |
|
||||
| src/lib/jobs/reminder/google-health-sync.ts | 78 |
|
||||
| src/lib/whoop/sync.ts | 65.9 |
|
||||
| src/instrumentation.ts | 53.8 |
|
||||
| src/lib/insights/bucket-series.ts | 47.1 |
|
||||
| src/lib/whoop/oauth-descriptor.ts | 47.1 |
|
||||
| src/lib/whoop/sync-core.ts | 47.1 |
|
||||
| src/lib/whoop/sync-body.ts | 47.1 |
|
||||
| src/lib/import/unzip-export-xml.ts | 35.3 |
|
||||
| src/lib/jobs/integration-status-null-probe.ts | 35.3 |
|
||||
|
||||
### at 1363 ms, 68 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| node:internal/modules/cjs/loader | 29.2 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6.7 |
|
||||
| (native) | 5.9 |
|
||||
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
|
||||
| src/lib/daily/today-overview.ts | 4.1 |
|
||||
| node:fs | 3 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
|
||||
| node:internal/streams/writable | 1.5 |
|
||||
| src/lib/jobs/reminder/insights-handlers.ts | 1.5 |
|
||||
| npm:next | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/jobs/reminder-worker.ts | 62.5 |
|
||||
| src/lib/jobs/reminder/insights-handlers.ts | 24.4 |
|
||||
| src/lib/ai/prompts/arrival-reaction.ts | 12.8 |
|
||||
| src/lib/daily/score-steady.ts | 11.3 |
|
||||
| src/lib/dashboard/bands.ts | 7.1 |
|
||||
| src/lib/dashboard/snapshot-read.ts | 5.8 |
|
||||
| src/lib/jobs/data-arrival.ts | 4.5 |
|
||||
| src/lib/daily/today-overview.ts | 4.1 |
|
||||
| src/lib/daily/priority-item-key.ts | 4.1 |
|
||||
| src/lib/ai/prompts/status-batch.ts | 3 |
|
||||
|
||||
### at 1545 ms, 57 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| node:internal/modules/cjs/loader | 25.3 |
|
||||
| (native) | 12.9 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 7.2 |
|
||||
| node:fs | 4.2 |
|
||||
| node:buffer | 1.5 |
|
||||
| npm:zod | 1.5 |
|
||||
| src/lib/jobs/pr-detection.ts | 1.5 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 1.5 |
|
||||
| src/lib/jobs/measurement-maintenance.ts | 1.3 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/jobs/reminder-worker.ts | 55.3 |
|
||||
| src/lib/jobs/reminder/ops-handlers.ts | 13 |
|
||||
| src/lib/export/restore-backup.ts | 3 |
|
||||
| src/lib/medications/ingredients/label-extract.ts | 3 |
|
||||
| src/lib/environment/service.ts | 1.5 |
|
||||
| src/lib/export/backup-blob.ts | 1.5 |
|
||||
| src/lib/jobs/pr-detection.ts | 1.5 |
|
||||
| src/lib/jobs/document-content-index-backfill.ts | 1.5 |
|
||||
| src/lib/labs/ocr-extract.ts | 1.5 |
|
||||
| src/lib/jobs/reminder/backup-handlers.ts | 1.5 |
|
||||
|
||||
### at 1820 ms, 114 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/tz/zone-offset.ts | 36 |
|
||||
| npm:@prisma/client | 18.2 |
|
||||
| src/lib/validations/source-priority.ts | 18.1 |
|
||||
| (native) | 13.1 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 7.2 |
|
||||
| node:buffer | 4.1 |
|
||||
| src/lib/analytics/source-priority.ts | 3.7 |
|
||||
| npm:zod | 2.3 |
|
||||
| npm:pg | 2 |
|
||||
| src/lib/tz/format.ts | 1.9 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 73.3 |
|
||||
| src/lib/tz/format.ts | 40.7 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 40.7 |
|
||||
| src/lib/tz/zone-offset.ts | 38.8 |
|
||||
| src/lib/measurements/consolidation-base.ts | 38.1 |
|
||||
| src/lib/analytics/source-priority.ts | 28.1 |
|
||||
| src/lib/validations/source-priority.ts | 20.4 |
|
||||
| src/lib/tz/intl-cache.ts | 1.5 |
|
||||
|
||||
### at 2407 ms, 50 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 18.9 |
|
||||
| src/lib/validations/source-priority.ts | 14.1 |
|
||||
| src/lib/analytics/source-priority.ts | 3.7 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 3 |
|
||||
| npm:next | 1.8 |
|
||||
| npm:%40prisma | 1.5 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
| node:internal/async_local_storage/async_hooks | 1.5 |
|
||||
| npm:pg | 1.5 |
|
||||
| npm:@prisma/client | 1.3 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 26.6 |
|
||||
| src/lib/analytics/source-priority.ts | 19.3 |
|
||||
| src/lib/validations/source-priority.ts | 14.1 |
|
||||
| src/lib/tz/format.ts | 3 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 3 |
|
||||
| src/lib/measurements/consolidation-base.ts | 2.8 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
|
||||
### at 18192 ms, 59 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 15.5 |
|
||||
| npm:@prisma/client | 13.9 |
|
||||
| src/lib/validations/source-priority.ts | 11.8 |
|
||||
| npm:pg | 3.6 |
|
||||
| node:events | 3 |
|
||||
| node:buffer | 2.5 |
|
||||
| src/lib/measurements/consolidation-base.ts | 1.5 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
| npm:pg-types | 1.4 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 18.9 |
|
||||
| src/lib/analytics/source-priority.ts | 12.9 |
|
||||
| src/lib/validations/source-priority.ts | 11.8 |
|
||||
| src/lib/measurements/consolidation-base.ts | 4.5 |
|
||||
| src/lib/tz/zone-offset.ts | 2.6 |
|
||||
| src/lib/tz/format.ts | 2.6 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 2.6 |
|
||||
|
||||
### at 19914 ms, 65 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 18.1 |
|
||||
| (native) | 12.3 |
|
||||
| npm:@prisma/client | 11.6 |
|
||||
| npm:next | 5.9 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 3 |
|
||||
| src/lib/tz/format.ts | 3 |
|
||||
| npm:pg | 2.6 |
|
||||
| npm:pg-protocol | 2.5 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
| src/lib/analytics/source-priority.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 29.8 |
|
||||
| src/lib/analytics/source-priority.ts | 22.6 |
|
||||
| src/lib/validations/source-priority.ts | 18.1 |
|
||||
| src/lib/tz/format.ts | 5.8 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 5.8 |
|
||||
| src/lib/tz/zone-offset.ts | 2.8 |
|
||||
| src/lib/measurements/consolidation-base.ts | 2.8 |
|
||||
|
||||
### at 20296 ms, 55 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 22.4 |
|
||||
| npm:@prisma/client | 14.8 |
|
||||
| node:buffer | 3 |
|
||||
| npm:zod | 3 |
|
||||
| src/lib/measurements/consolidation-base.ts | 3 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
| node:internal/stream_base_commons | 1.5 |
|
||||
| src/lib/analytics/source-priority.ts | 1.5 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
|
||||
| npm:pg-protocol | 1.3 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 10.5 |
|
||||
| src/lib/analytics/source-priority.ts | 6 |
|
||||
| src/lib/validations/source-priority.ts | 3 |
|
||||
| src/lib/measurements/consolidation-base.ts | 3 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 1.5 |
|
||||
|
||||
### at 30289 ms, 69 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 18.3 |
|
||||
| (native) | 12.9 |
|
||||
| npm:@prisma/client | 8.8 |
|
||||
| node:buffer | 6.8 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 4.5 |
|
||||
| src/lib/tz/format.ts | 4 |
|
||||
| npm:pg-protocol | 3 |
|
||||
| npm:zod | 3 |
|
||||
| node:timers | 1.5 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 33 |
|
||||
| src/lib/analytics/source-priority.ts | 24.3 |
|
||||
| src/lib/validations/source-priority.ts | 21.3 |
|
||||
| src/lib/tz/format.ts | 5.6 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 5.6 |
|
||||
| src/lib/measurements/consolidation-base.ts | 2.6 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
|
||||
### at 31004 ms, 68 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 17.8 |
|
||||
| src/lib/validations/source-priority.ts | 14.9 |
|
||||
| npm:@prisma/client | 7.6 |
|
||||
| npm:next | 4.5 |
|
||||
| node:internal/async_hooks | 4.5 |
|
||||
| npm:pg-protocol | 4.1 |
|
||||
| npm:pg | 3 |
|
||||
| src/lib/tz/zone-offset.ts | 3 |
|
||||
| node:buffer | 2.7 |
|
||||
| src/lib/analytics/source-priority.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 26.4 |
|
||||
| src/lib/analytics/source-priority.ts | 19 |
|
||||
| src/lib/validations/source-priority.ts | 16.2 |
|
||||
| src/lib/measurements/consolidation-base.ts | 6.1 |
|
||||
| src/lib/tz/format.ts | 5.8 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 5.8 |
|
||||
| src/lib/tz/zone-offset.ts | 4.5 |
|
||||
|
||||
### at 35350 ms, 65 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 17 |
|
||||
| npm:@prisma/client | 11.8 |
|
||||
| (native) | 8.6 |
|
||||
| node:buffer | 6.5 |
|
||||
| src/lib/tz/format.ts | 4.5 |
|
||||
| npm:pg-protocol | 3 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 3 |
|
||||
| npm:%40prisma | 1.6 |
|
||||
| npm:pg | 1.5 |
|
||||
| src/lib/analytics/source-priority.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 26 |
|
||||
| src/lib/analytics/source-priority.ts | 20 |
|
||||
| src/lib/validations/source-priority.ts | 17 |
|
||||
| src/lib/tz/format.ts | 4.5 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 4.5 |
|
||||
| src/lib/measurements/consolidation-base.ts | 3 |
|
||||
|
||||
### at 36452 ms, 57 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 20 |
|
||||
| src/lib/validations/source-priority.ts | 16 |
|
||||
| npm:@prisma/client | 7.3 |
|
||||
| node:internal/per_context/primordials | 2.8 |
|
||||
| npm:%40prisma | 1.5 |
|
||||
| npm:next | 1.5 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
| node:internal/streams/readable | 1.5 |
|
||||
| npm:pg-protocol | 1.2 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 21.2 |
|
||||
| src/lib/validations/source-priority.ts | 16 |
|
||||
| src/lib/analytics/source-priority.ts | 16 |
|
||||
| src/lib/tz/format.ts | 2.5 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 2.5 |
|
||||
| src/lib/measurements/consolidation-base.ts | 2.5 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
|
||||
### at 38744 ms, 56 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 13.3 |
|
||||
| (native) | 9.9 |
|
||||
| npm:@prisma/client | 9.2 |
|
||||
| node:buffer | 4.7 |
|
||||
| npm:pg-protocol | 4.3 |
|
||||
| node:events | 1.9 |
|
||||
| npm:pg | 1.6 |
|
||||
| npm:%40prisma | 1.5 |
|
||||
| npm:next | 1.5 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 21.8 |
|
||||
| src/lib/analytics/source-priority.ts | 16 |
|
||||
| src/lib/validations/source-priority.ts | 13.3 |
|
||||
| src/lib/measurements/consolidation-base.ts | 4.3 |
|
||||
| src/lib/tz/format.ts | 3 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 3 |
|
||||
| src/lib/tz/zone-offset.ts | 1.4 |
|
||||
|
||||
### at 42458 ms, 50 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| npm:@prisma/client | 13.5 |
|
||||
| src/lib/validations/source-priority.ts | 10.1 |
|
||||
| (native) | 7.6 |
|
||||
| npm:pg-protocol | 3.2 |
|
||||
| node:events | 3 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 3 |
|
||||
| src/lib/tz/format.ts | 3 |
|
||||
| npm:pg | 2.8 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
| npm:%40prisma | 1.4 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 17.3 |
|
||||
| src/lib/analytics/source-priority.ts | 12.7 |
|
||||
| src/lib/validations/source-priority.ts | 10.1 |
|
||||
| src/lib/tz/format.ts | 3 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 3 |
|
||||
| src/lib/measurements/consolidation-base.ts | 1.5 |
|
||||
| src/lib/jobs/job-lock.ts | 1.3 |
|
||||
| src/lib/jobs/long-pass.ts | 1.3 |
|
||||
| src/lib/jobs/run-job.ts | 1.3 |
|
||||
| src/lib/jobs/job-observer.ts | 1.3 |
|
||||
|
||||
### at 45451 ms, 63 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 15.8 |
|
||||
| npm:@prisma/client | 10.5 |
|
||||
| (native) | 8.4 |
|
||||
| src/lib/tz/format.ts | 5.3 |
|
||||
| node:internal/async_local_storage/async_hooks | 4.4 |
|
||||
| node:buffer | 3 |
|
||||
| npm:pg | 3 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 3 |
|
||||
| npm:zod | 3 |
|
||||
| npm:%40prisma | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 31.7 |
|
||||
| src/lib/analytics/source-priority.ts | 21.8 |
|
||||
| src/lib/validations/source-priority.ts | 18.8 |
|
||||
| src/lib/measurements/consolidation-base.ts | 5.3 |
|
||||
| src/lib/tz/format.ts | 5.3 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 5.3 |
|
||||
|
||||
### at 56854 ms, 66 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 15 |
|
||||
| npm:@prisma/client | 13.7 |
|
||||
| (native) | 6.6 |
|
||||
| node:buffer | 6.6 |
|
||||
| npm:next | 6 |
|
||||
| npm:pg | 4.5 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 4.1 |
|
||||
| src/lib/tz/format.ts | 3.1 |
|
||||
| node:internal/process/task_queues | 1.5 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 25.3 |
|
||||
| src/lib/analytics/source-priority.ts | 18.1 |
|
||||
| src/lib/validations/source-priority.ts | 16.5 |
|
||||
| src/lib/tz/format.ts | 3.1 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 3.1 |
|
||||
| src/lib/measurements/consolidation-base.ts | 3.1 |
|
||||
|
||||
### at 56923 ms, 52 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 20 |
|
||||
| npm:@prisma/client | 10.1 |
|
||||
| node:buffer | 3.7 |
|
||||
| npm:pg | 3 |
|
||||
| src/lib/tz/format.ts | 2.6 |
|
||||
| npm:pg-protocol | 2.6 |
|
||||
| src/lib/analytics/source-priority.ts | 2.6 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
| npm:pg-types | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 9.3 |
|
||||
| src/lib/analytics/source-priority.ts | 5.3 |
|
||||
| src/lib/tz/format.ts | 4.1 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 4.1 |
|
||||
| src/lib/measurements/consolidation-base.ts | 2.6 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
| src/lib/validations/source-priority.ts | 1.1 |
|
||||
|
||||
### at 59508 ms, 56 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| npm:@prisma/client | 15.7 |
|
||||
| src/lib/validations/source-priority.ts | 9.7 |
|
||||
| node:buffer | 7 |
|
||||
| (native) | 6.1 |
|
||||
| npm:pg-protocol | 4.5 |
|
||||
| npm:next | 4.1 |
|
||||
| src/lib/tz/format.ts | 3 |
|
||||
| src/lib/analytics/source-priority.ts | 3 |
|
||||
| node:internal/async_hooks | 1.5 |
|
||||
| src/lib/measurements/consolidation-base.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 21.7 |
|
||||
| src/lib/analytics/source-priority.ts | 15.8 |
|
||||
| src/lib/validations/source-priority.ts | 9.7 |
|
||||
| src/lib/tz/format.ts | 5.9 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 5.9 |
|
||||
| src/lib/measurements/consolidation-base.ts | 5.9 |
|
||||
| src/lib/tz/zone-offset.ts | 2.9 |
|
||||
|
||||
### at 61315 ms, 62 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 20.1 |
|
||||
| npm:@prisma/client | 14.1 |
|
||||
| (native) | 8.2 |
|
||||
| npm:pg | 4.5 |
|
||||
| node:buffer | 3.1 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 3 |
|
||||
| src/lib/measurements/consolidation-base.ts | 3 |
|
||||
| npm:pg-protocol | 1.5 |
|
||||
| npm:next | 1.5 |
|
||||
| src/lib/analytics/source-priority.ts | 1.4 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 30.3 |
|
||||
| src/lib/analytics/source-priority.ts | 22.8 |
|
||||
| src/lib/validations/source-priority.ts | 21.4 |
|
||||
| src/lib/measurements/consolidation-base.ts | 4.5 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 1.5 |
|
||||
|
||||
### at 72986 ms, 57 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 19.1 |
|
||||
| npm:@prisma/client | 10.2 |
|
||||
| (native) | 6.6 |
|
||||
| npm:pg-protocol | 5.5 |
|
||||
| npm:next | 3 |
|
||||
| src/lib/analytics/source-priority.ts | 3 |
|
||||
| npm:pg | 2.5 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
|
||||
| node:internal/async_local_storage/async_hooks | 1.5 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 29.6 |
|
||||
| src/lib/analytics/source-priority.ts | 23.5 |
|
||||
| src/lib/validations/source-priority.ts | 19.1 |
|
||||
| src/lib/measurements/consolidation-base.ts | 4.6 |
|
||||
| src/lib/tz/format.ts | 4.5 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 4.5 |
|
||||
| src/lib/tz/zone-offset.ts | 3 |
|
||||
|
||||
### at 73523 ms, 51 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/validations/source-priority.ts | 12 |
|
||||
| npm:next | 8.9 |
|
||||
| npm:@prisma/client | 6 |
|
||||
| (native) | 4.5 |
|
||||
| src/lib/analytics/source-priority.ts | 4.2 |
|
||||
| node:buffer | 4 |
|
||||
| npm:pg | 2.1 |
|
||||
| npm:pg-protocol | 1.5 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
|
||||
| src/lib/tz/zone-offset.ts | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 27.8 |
|
||||
| src/lib/analytics/source-priority.ts | 17.7 |
|
||||
| src/lib/validations/source-priority.ts | 13.5 |
|
||||
| src/lib/measurements/consolidation-base.ts | 8.6 |
|
||||
| src/lib/tz/format.ts | 7.1 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 7.1 |
|
||||
| src/lib/tz/zone-offset.ts | 6 |
|
||||
|
||||
### at 75393 ms, 100 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 32.6 |
|
||||
| npm:@prisma/client | 17.2 |
|
||||
| src/lib/validations/source-priority.ts | 14.6 |
|
||||
| npm:next | 7.5 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 6.1 |
|
||||
| src/lib/tz/zone-offset.ts | 5.6 |
|
||||
| npm:pg-protocol | 4.1 |
|
||||
| npm:pg | 2.5 |
|
||||
| src/lib/tz/format.ts | 2.3 |
|
||||
| node:internal/async_local_storage/async_hooks | 1.5 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 34.6 |
|
||||
| src/lib/analytics/source-priority.ts | 24.3 |
|
||||
| src/lib/validations/source-priority.ts | 16.1 |
|
||||
| src/lib/tz/format.ts | 10.9 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 10.9 |
|
||||
| src/lib/tz/zone-offset.ts | 8.6 |
|
||||
| src/lib/measurements/consolidation-base.ts | 5.7 |
|
||||
|
||||
### at 78085 ms, 54 ms
|
||||
|
||||
| self time by module | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/tz/zone-offset.ts | 26.6 |
|
||||
| src/lib/validations/source-priority.ts | 13.5 |
|
||||
| npm:@prisma/client | 4.2 |
|
||||
| (native) | 4.1 |
|
||||
| node:internal/async_local_storage/async_hooks | 1.5 |
|
||||
| src/lib/tz/format.ts | 1.5 |
|
||||
| src/lib/tz/intl-cache.ts | 1.5 |
|
||||
| npm:pg | 1.1 |
|
||||
|
||||
| inclusive, application modules | ms |
|
||||
| --- | ---: |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 44.7 |
|
||||
| src/lib/tz/format.ts | 29.6 |
|
||||
| src/lib/measurements/consolidation-tz.ts | 29.6 |
|
||||
| src/lib/measurements/consolidation-base.ts | 29.6 |
|
||||
| src/lib/tz/zone-offset.ts | 28.1 |
|
||||
| src/lib/analytics/source-priority.ts | 15 |
|
||||
| src/lib/validations/source-priority.ts | 13.5 |
|
||||
| src/lib/tz/intl-cache.ts | 1.5 |
|
||||
|
||||
## Self time by module until the worker is ready
|
||||
|
||||
| module | ms |
|
||||
| --- | ---: |
|
||||
| (native) | 853 |
|
||||
| npm:@prisma/client | 295.9 |
|
||||
| node:internal/modules/cjs/loader | 248.7 |
|
||||
| src/lib/validations/source-priority.ts | 210.3 |
|
||||
| npm:next | 188.4 |
|
||||
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 121.5 |
|
||||
| src/lib/tz/zone-offset.ts | 83.2 |
|
||||
| wasm://wasm/00d1cb12 | 81.4 |
|
||||
| npm:pg | 78.5 |
|
||||
| node:buffer | 59.1 |
|
||||
| src/lib/tz/format.ts | 55 |
|
||||
| npm:zod | 50.7 |
|
||||
| src/lib/personal-records/pr-detection-worker.ts | 48 |
|
||||
| npm:pg-protocol | 37 |
|
||||
| node:internal/bootstrap/realm | 32 |
|
||||
| src/lib/analytics/source-priority.ts | 31.7 |
|
||||
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 27.7 |
|
||||
| node:internal/modules/esm/utils | 26 |
|
||||
| npm:luxon | 22.1 |
|
||||
| node:internal/async_hooks | 21.7 |
|
||||
| node:internal/async_local_storage/async_hooks | 21.2 |
|
||||
| node:fs | 18.8 |
|
||||
| npm:pg-boss | 18.3 |
|
||||
| npm:pg-pool | 18.1 |
|
||||
| npm:yaml | 15 |
|
||||
|
||||
## Heap snapshot after 300 s idle (live heap after a full GC)
|
||||
|
||||
159.5 MB in 1302659 nodes; module source strings over 50 KB: 39.8 MB.
|
||||
|
||||
- parsed locale catalogs: 2 objects, 1.6 MB retained
|
||||
|
||||
| node type | MB |
|
||||
| --- | ---: |
|
||||
| string | 67.5 |
|
||||
| array | 26.6 |
|
||||
| code | 19.2 |
|
||||
| object | 15.9 |
|
||||
| hidden | 9.2 |
|
||||
| native | 8 |
|
||||
| object shape | 5.9 |
|
||||
| closure | 5.2 |
|
||||
| concatenated string | 1.3 |
|
||||
| number | 0.5 |
|
||||
| sliced string | 0.2 |
|
||||
| regexp | 0.2 |
|
||||
| synthetic | 0 |
|
||||
| symbol | 0 |
|
||||
| bigint | 0 |
|
||||
|
||||
| constructor | MB | count |
|
||||
| --- | ---: | ---: |
|
||||
| array:(object elements) | 20.8 | 25651 |
|
||||
| object:Object | 11.4 | 293893 |
|
||||
| native:system / JSArrayBufferData | 7.5 | 170 |
|
||||
| array:(object properties) | 3.3 | 6517 |
|
||||
| array: | 2.5 | 10169 |
|
||||
| closure: | 1.6 | 29121 |
|
||||
| object:system / Context | 1.6 | 28886 |
|
||||
| closure:native_bind | 1 | 20804 |
|
||||
| object:Array | 0.6 | 20938 |
|
||||
| object:Date1 | 0.5 | 5332 |
|
||||
| object:a | 0.4 | 4438 |
|
||||
| native:Node / histogram | 0.4 | 1 |
|
||||
| object:d | 0.3 | 4425 |
|
||||
| closure:get | 0.3 | 4732 |
|
||||
| object:Set | 0.2 | 5670 |
|
||||
| object:Module | 0.2 | 1963 |
|
||||
| closure:set | 0.2 | 3428 |
|
||||
| closure:n | 0.1 | 1572 |
|
||||
| closure:i | 0.1 | 1578 |
|
||||
| object:Promise | 0.1 | 1341 |
|
||||
|
||||
| largest retained subtrees | MB | retaining path |
|
||||
| --- | ---: | --- |
|
||||
| object:yr | 34.6 | [1] > [10] > 22 > extension > global > prisma > [1] > _engine |
|
||||
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
|
||||
| object:Promise | 5.9 | Object > executor > e > #r > gr > transactions > Map > table > > 6 > closing |
|
||||
| object:Module | 5.6 | [1] > [10] > 99 > process > getBuiltinModule > getBuiltinModule > context > system / Context > $Module > Module > _cache > |
|
||||
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
|
||||
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
|
||||
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
|
||||
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 60025 |
|
||||
| object:Object | 3.4 | system / Context > $Module > Module > _cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
|
||||
| object:global | 2.6 | [1] > [10] > 22 > extension |
|
||||
|
||||
| largest strings | MB |
|
||||
| --- | ---: |
|
||||
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
|
||||
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
|
||||
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
|
||||
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
|
||||
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
|
||||
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
|
||||
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
|
||||
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
|
||||
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
|
||||
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
|
||||
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
|
||||
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
|
||||
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
|
||||
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
|
||||
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
|
||||
@@ -1,22 +0,0 @@
|
||||
# v1.45.0: request fan-out
|
||||
|
||||
Same runs as `v1.45.0-read-path.md`.
|
||||
|
||||
| request | statements before | statements after | p50 ms before / after |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| /api/auth/me | 19 | 14 | 9 / 8 |
|
||||
| day view, cold | 49 | 48 | 77 / 68 |
|
||||
|
||||
- `/api/auth/me` read the AppSettings singleton six times (MFA policy,
|
||||
Assistant switches, module availability, provider presence, AI usage
|
||||
bounds, reasoning controls); one memoised read per request now serves all
|
||||
six (`src/lib/settings/operator-row.ts`). On the small budget fixture it
|
||||
went from 18 to 13 statements. The remaining reads are the session, the
|
||||
grant, cycle profile, onboarding, consent, coach usage and five user reads
|
||||
with different column sets.
|
||||
- The day view's record sections run six at a time instead of about
|
||||
twenty-five at once, so one day never holds more than a third of the pool.
|
||||
The statement count is the same; what changes is the peak of connections.
|
||||
- Keyset pagination for `/api/measurements` was not done: a cursor needs a
|
||||
new request parameter and a response field, a contract change for the
|
||||
native client.
|
||||
@@ -1,254 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"label": "v1.45.0-final",
|
||||
"commit": "a57fa8335",
|
||||
"date": "2026-10-22T06:52:09.539Z",
|
||||
"node": "v22.23.2",
|
||||
"postgres": "16.15",
|
||||
"cpus": 14,
|
||||
"loadAverage": [
|
||||
6.8,
|
||||
8.9,
|
||||
14.5
|
||||
],
|
||||
"measurements": 1265561,
|
||||
"iterations": 20,
|
||||
"warmup": 3,
|
||||
"heapMb": 792,
|
||||
"idleStatementsPerSecond": 0,
|
||||
"batch5k": {
|
||||
"totalMs": 4448,
|
||||
"totalStatements": 504
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "/api/auth/me",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/auth/me",
|
||||
"n": 20,
|
||||
"p50Ms": 8.3,
|
||||
"p95Ms": 27.2,
|
||||
"maxMs": 221,
|
||||
"statementsMedian": 14,
|
||||
"statementsMax": 14,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "/api/auth/me",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/auth/me",
|
||||
"n": 20,
|
||||
"p50Ms": 7.9,
|
||||
"p95Ms": 10.1,
|
||||
"maxMs": 17.1,
|
||||
"statementsMedian": 14,
|
||||
"statementsMax": 17,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard summary",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/dashboard/summary",
|
||||
"n": 20,
|
||||
"p50Ms": 86.6,
|
||||
"p95Ms": 100.8,
|
||||
"maxMs": 126.8,
|
||||
"statementsMedian": 30,
|
||||
"statementsMax": 31,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard summary",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/dashboard/summary",
|
||||
"n": 20,
|
||||
"p50Ms": 9.2,
|
||||
"p95Ms": 17.1,
|
||||
"maxMs": 18.4,
|
||||
"statementsMedian": 6,
|
||||
"statementsMax": 6,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard snapshot",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/dashboard/snapshot",
|
||||
"n": 20,
|
||||
"p50Ms": 545.3,
|
||||
"p95Ms": 592.5,
|
||||
"maxMs": 606.3,
|
||||
"statementsMedian": 153,
|
||||
"statementsMax": 156,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "dashboard snapshot",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/dashboard/snapshot",
|
||||
"n": 20,
|
||||
"p50Ms": 8.1,
|
||||
"p95Ms": 17.9,
|
||||
"maxMs": 21.9,
|
||||
"statementsMedian": 10,
|
||||
"statementsMax": 10,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 30 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=30",
|
||||
"n": 20,
|
||||
"p50Ms": 18.7,
|
||||
"p95Ms": 27.2,
|
||||
"maxMs": 36.1,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 30 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=30",
|
||||
"n": 20,
|
||||
"p50Ms": 15.9,
|
||||
"p95Ms": 21.8,
|
||||
"maxMs": 26,
|
||||
"statementsMedian": 8,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 365 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=365",
|
||||
"n": 20,
|
||||
"p50Ms": 40.7,
|
||||
"p95Ms": 55.6,
|
||||
"maxMs": 56.2,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 365 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=365",
|
||||
"n": 20,
|
||||
"p50Ms": 38.3,
|
||||
"p95Ms": 59.6,
|
||||
"maxMs": 88.6,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 7,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 3650 d",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=3650",
|
||||
"n": 20,
|
||||
"p50Ms": 334.9,
|
||||
"p95Ms": 348.5,
|
||||
"maxMs": 351.2,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 7,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "series pulse 3650 d",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/measurements/series?kind=pulse&days=3650",
|
||||
"n": 20,
|
||||
"p50Ms": 328.1,
|
||||
"p95Ms": 346,
|
||||
"maxMs": 348.9,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 8,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "day view",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/day/2026-10-21",
|
||||
"n": 20,
|
||||
"p50Ms": 68.4,
|
||||
"p95Ms": 102.6,
|
||||
"maxMs": 104.7,
|
||||
"statementsMedian": 48,
|
||||
"statementsMax": 49,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "day view",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/day/2026-10-21",
|
||||
"n": 20,
|
||||
"p50Ms": 46.4,
|
||||
"p95Ms": 66.1,
|
||||
"maxMs": 72.6,
|
||||
"statementsMedian": 47,
|
||||
"statementsMax": 47,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights health status",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/insights/health-status",
|
||||
"n": 20,
|
||||
"p50Ms": 156,
|
||||
"p95Ms": 190,
|
||||
"maxMs": 197.7,
|
||||
"statementsMedian": 67,
|
||||
"statementsMax": 67,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights health status",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/insights/health-status",
|
||||
"n": 20,
|
||||
"p50Ms": 173.6,
|
||||
"p95Ms": 1163.4,
|
||||
"maxMs": 1419.4,
|
||||
"statementsMedian": 67,
|
||||
"statementsMax": 67,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights correlations",
|
||||
"mode": "cold",
|
||||
"request": "GET /api/insights/correlations",
|
||||
"n": 20,
|
||||
"p50Ms": 106.1,
|
||||
"p95Ms": 229.1,
|
||||
"maxMs": 383.3,
|
||||
"statementsMedian": 48,
|
||||
"statementsMax": 49,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "insights correlations",
|
||||
"mode": "warm",
|
||||
"request": "GET /api/insights/correlations",
|
||||
"n": 20,
|
||||
"p50Ms": 18.7,
|
||||
"p95Ms": 626.6,
|
||||
"maxMs": 1136.5,
|
||||
"statementsMedian": 7,
|
||||
"statementsMax": 7,
|
||||
"failures": 0
|
||||
},
|
||||
{
|
||||
"name": "batch ingest 5k (per 500)",
|
||||
"mode": "write",
|
||||
"request": "POST /api/measurements/batch ×10",
|
||||
"n": 10,
|
||||
"p50Ms": 185.7,
|
||||
"p95Ms": 1269.2,
|
||||
"maxMs": 1269.2,
|
||||
"statementsMedian": 50,
|
||||
"statementsMax": 53,
|
||||
"failures": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Benchmark v1.45.0-final
|
||||
|
||||
Commit a57fa8335 with the working tree of the final commits, 2026-10-22T06:52:09.539Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 6.8,8.9,14.5 at the end. Idle background: 0 statements/s.
|
||||
|
||||
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| /api/auth/me | cold | 20 | 8.3 | 27.2 | 221 | 14 | 14 |
|
||||
| /api/auth/me | warm | 20 | 7.9 | 10.1 | 17.1 | 14 | 17 |
|
||||
| dashboard summary | cold | 20 | 86.6 | 100.8 | 126.8 | 30 | 31 |
|
||||
| dashboard summary | warm | 20 | 9.2 | 17.1 | 18.4 | 6 | 6 |
|
||||
| dashboard snapshot | cold | 20 | 545.3 | 592.5 | 606.3 | 153 | 156 |
|
||||
| dashboard snapshot | warm | 20 | 8.1 | 17.9 | 21.9 | 10 | 10 |
|
||||
| series pulse 30 d | cold | 20 | 18.7 | 27.2 | 36.1 | 8 | 8 |
|
||||
| series pulse 30 d | warm | 20 | 15.9 | 21.8 | 26 | 8 | 8 |
|
||||
| series pulse 365 d | cold | 20 | 40.7 | 55.6 | 56.2 | 7 | 8 |
|
||||
| series pulse 365 d | warm | 20 | 38.3 | 59.6 | 88.6 | 7 | 7 |
|
||||
| series pulse 3650 d | cold | 20 | 334.9 | 348.5 | 351.2 | 7 | 7 |
|
||||
| series pulse 3650 d | warm | 20 | 328.1 | 346 | 348.9 | 7 | 8 |
|
||||
| day view | cold | 20 | 68.4 | 102.6 | 104.7 | 48 | 49 |
|
||||
| day view | warm | 20 | 46.4 | 66.1 | 72.6 | 47 | 47 |
|
||||
| insights health status | cold | 20 | 156 | 190 | 197.7 | 67 | 67 |
|
||||
| insights health status | warm | 20 | 173.6 | 1163.4 | 1419.4 | 67 | 67 |
|
||||
| insights correlations | cold | 20 | 106.1 | 229.1 | 383.3 | 48 | 49 |
|
||||
| insights correlations | warm | 20 | 18.7 | 626.6 | 1136.5 | 7 | 7 |
|
||||
| batch ingest 5k (per 500) | write | 10 | 185.7 | 1269.2 | 1269.2 | 50 | 53 |
|
||||
@@ -1,45 +0,0 @@
|
||||
# v1.45.0: read path
|
||||
|
||||
Request benchmark on the seeded account (1,265,561 rows), production build,
|
||||
web process, heap ceiling 792 MB, 20 requests per row, one at a time.
|
||||
`v1.45.0-before` is the v1.44 runtime (1915f9152), `v1.45.0-final` this
|
||||
branch; both on the same machine within the hour, load average 7 to 30
|
||||
(other work on the host), so latency compares within a factor of noise and
|
||||
statements exactly. Cold means every request follows a one-row manual sync.
|
||||
|
||||
| request | before p50 / p95 ms | after p50 / p95 ms | statements before / after |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| dashboard snapshot, cold | 1482 / 1526 | 545 / 593 | 158 / 153 |
|
||||
| dashboard summary, cold | 170 / 221 | 87 / 101 | 31 / 30 |
|
||||
| series pulse 3650 d, cold | 1627 / 1665 | 335 / 349 | 8 / 7 |
|
||||
| series pulse 3650 d, warm | 2091 / 2435 | 328 / 346 | 8 / 7 |
|
||||
| series pulse 365 d, cold | 184 / 193 | 41 / 56 | 8 / 7 |
|
||||
| series pulse 30 d, cold | 24 / 31 | 19 / 27 | 8 / 8 |
|
||||
| insights health status, cold | 245 / 401 | 156 / 190 | 68 / 67 |
|
||||
|
||||
What moved them:
|
||||
|
||||
- **JIT off for app sessions.** Postgres compiled every fold whose estimated
|
||||
cost passed `jit_above_cost`; on the ten-year pulse pick that was 100 to
|
||||
140 ms of a 220 ms statement (`EXPLAIN ANALYZE`: 395 ms with JIT, 220 ms
|
||||
without).
|
||||
- **The canonical-source pick groups first.** `DISTINCT ON` now sorts the
|
||||
distinct `(type, day, source)` triples instead of every raw reading (the
|
||||
ten-year pulse pick sorted 260,000 rows and spilled to disk); 220 ms to
|
||||
175 ms on its own. Parity: `tests/integration/canonical-source-pick-parity.test.ts`
|
||||
runs the old statement beside the new one over five reader shapes and two
|
||||
ladders on a fixture with overlapping and off-ladder sources, tombstones,
|
||||
empty days and readings around midnight; identical rows.
|
||||
- **The dense series reads its rows once.** Buckets and strip figures come
|
||||
from one statement over a materialised window instead of two statements
|
||||
that each picked the canonical source and scanned the readings. Parity:
|
||||
`tests/integration/dense-series-read-parity.test.ts`, the two old
|
||||
statements as reference, pulse (two ranked sources), glucose and oxygen
|
||||
saturation, day and hour grains, UTC, Europe/Berlin, Asia/Kolkata and
|
||||
America/New_York; identical buckets and figures (relative 1e-9).
|
||||
- **Closed blocks survive a sync.** The slice's pre-fold remainder (every
|
||||
reading older than five years, the snapshot's largest statement at 357 ms)
|
||||
and the day view's past history blocks are cached under a key that names
|
||||
the instant their readings end before; a sync of newer readings no longer
|
||||
drops them (`src/lib/cache/closed-block.ts`). The values are the same
|
||||
statement's; only how long they are kept changed.
|
||||
@@ -1,28 +0,0 @@
|
||||
# v1.45.0: workout duplicate window
|
||||
|
||||
A correctness change, not a speed one; the request benchmark does not read
|
||||
workouts, so there is no latency row for it.
|
||||
|
||||
Before: two copies of one session were collapsed only when both starts fell
|
||||
in the same fixed five-minute clock slot (`floor(start / 5 min)`). A pair 30 s
|
||||
apart that straddled a slot boundary stayed two workouts in the list, the
|
||||
batch write, the calendar, strain and the Coach.
|
||||
|
||||
After: per user and sport, sorted by start, a workout joins the open cluster
|
||||
while it starts less than five minutes after the cluster's first start.
|
||||
|
||||
Proof (`src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts`,
|
||||
against the previous picker kept as `pick-canonical-workout-rows.legacy.ts`):
|
||||
|
||||
| property | inputs |
|
||||
| --- | --- |
|
||||
| same rows as the fixed slots wherever no pair within five minutes straddles a slot boundary, three ladders | 4,000 generated inputs, over 1,000 comparable |
|
||||
| same strain impulses as before on those inputs | 2,000 generated inputs, over 500 comparable |
|
||||
| two sources' copies collapse at any offset under five minutes (the old picker missed a share) | 3,000 random offsets |
|
||||
| five minutes or more apart never collapse | 2,000 |
|
||||
| a uniform shift of every start keeps the picked set | 1,500 |
|
||||
| a row of workouts four minutes apart does not chain | fixed case |
|
||||
|
||||
One existing case changed its expectation: three runs at 08:04, 08:08 and
|
||||
08:12 used to stay three because they sat in three slots; 08:08 is now the
|
||||
08:04 run from a lower-ranked source (four minutes apart), and 08:12 stays.
|
||||
@@ -1,26 +0,0 @@
|
||||
# v1.45.0: write path
|
||||
|
||||
Same runs as `v1.45.0-read-path.md`.
|
||||
|
||||
| request | before p50 ms | after p50 ms | statements before / after |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| batch ingest, 500 fresh rows per request | 166 | 186 | 53 / 50 |
|
||||
|
||||
Fresh rows were already set-based (v1.44); the benchmark's ingest is all
|
||||
fresh rows, so it shows no gain, as expected. The changes are for re-posts:
|
||||
|
||||
- **Unchanged `stats:` re-posts** write nothing and start nothing. One read
|
||||
over the batch's `stats:` ids replaces a reconciler transaction (about ten
|
||||
round trips) per row, and an all-unchanged batch no longer refolds, enqueues
|
||||
rollups or PR detection, or marks caches stale. The per-entry status stays
|
||||
`updated` and the summary's `updated` count is unchanged.
|
||||
`tests/integration/routes/measurements-batch-unchanged-stats.test.ts`: four
|
||||
unchanged re-posts take at most 12 statements in total (before: about 40)
|
||||
with zero rollup, invalidation and PR calls; a changed value, a changed
|
||||
device or a tombstoned row still goes through the reconciler.
|
||||
- **Apple Health re-import** skips the UPDATE of a row stored with exactly the
|
||||
values it would write; one read per chunk instead of one UPDATE per row.
|
||||
`tests/integration/apple-health-reimport-unchanged.test.ts`: a re-import of
|
||||
20 stored rows reports the same per-type counts and sends no UPDATE.
|
||||
- **Narrower invalidation.** The batch route tells the cache how far back it
|
||||
wrote (`touchedFrom`), so closed blocks of older readings stay.
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Remove the phantom Apple-Health mirror medications minted by the unstable
|
||||
* externalId bug (a fresh key per sync sweep => one duplicate row per sweep).
|
||||
*
|
||||
* SAFETY:
|
||||
* - Dry-run by default. Deletes ONLY when called with `--execute`.
|
||||
* - Targets ONLY rows that match ALL of: the given name, externalSource =
|
||||
* APPLE_HEALTH, ZERO intake events, and ZERO schedules. A row with any
|
||||
* intake history or any schedule is never touched. Real medications
|
||||
* (different name, or with intake/schedule history) can never match.
|
||||
* - Each delete replicates the DELETE /api/medications/{id} route exactly:
|
||||
* revoke medication-scoped tokens, deleteMedicationCategory, cascade
|
||||
* delete, audit row. Caches are busted per affected user at the end (and
|
||||
* otherwise clear on TTL / the next write).
|
||||
*
|
||||
* STOP THE SOURCE FIRST: while the iOS Apple-Health medication mirror is still
|
||||
* enabled on the device, every sync re-mints these rows and cleanup will undo
|
||||
* itself. Turn the toggle off (or ship the iOS fix) before running --execute.
|
||||
*
|
||||
* Run (inside the app container):
|
||||
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts # dry-run
|
||||
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --execute # delete
|
||||
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --name "Blutdruckmittel" --execute
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { deleteMedicationCategory } from "@/lib/medication-category";
|
||||
|
||||
function argValue(flag: string): string | undefined {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const name = argValue("--name") ?? "Blutdruckmittel";
|
||||
const execute = process.argv.includes("--execute");
|
||||
|
||||
// Candidate set: name + Apple-Health provenance only. Intake / schedule
|
||||
// counts are checked per row below and are the hard safety gate.
|
||||
const candidates = await prisma.medication.findMany({
|
||||
where: { name, externalSource: "APPLE_HEALTH" },
|
||||
select: { id: true, userId: true, externalId: true, createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
const deletable: { id: string; userId: string }[] = [];
|
||||
const skipped: { id: string; reason: string }[] = [];
|
||||
|
||||
for (const m of candidates) {
|
||||
const [intakes, schedules] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId: m.id, deletedAt: null },
|
||||
}),
|
||||
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
|
||||
]);
|
||||
if (intakes > 0 || schedules > 0) {
|
||||
skipped.push({
|
||||
id: m.id,
|
||||
reason: `has ${intakes} intake(s) / ${schedules} schedule(s) — PRESERVED`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
deletable.push({ id: m.id, userId: m.userId });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n=== cleanup plan for name "${name}" (externalSource APPLE_HEALTH) ===`,
|
||||
);
|
||||
console.log(`candidates found: ${candidates.length}`);
|
||||
console.log(`safe to delete: ${deletable.length}`);
|
||||
console.log(`preserved (intake/schedule present): ${skipped.length}`);
|
||||
if (skipped.length > 0) {
|
||||
console.log("\nPreserved rows:");
|
||||
for (const s of skipped) console.log(` ${s.id} — ${s.reason}`);
|
||||
}
|
||||
console.log("\nRows to delete:");
|
||||
for (const d of deletable) console.log(` ${d.id}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log(
|
||||
"\nDRY RUN — nothing was deleted. Re-run with --execute to remove the rows above.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n--execute: deleting ${deletable.length} row(s)...`);
|
||||
const affectedUsers = new Set<string>();
|
||||
let deleted = 0;
|
||||
for (const { id, userId } of deletable) {
|
||||
// Mirror the DELETE route: revoke medication-scoped tokens (none expected
|
||||
// on a phantom row, harmless if absent), category cleanup, cascade delete,
|
||||
// audit row.
|
||||
await prisma.apiToken.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revoked: false,
|
||||
permissions: { has: `medication:${id}:ingest` },
|
||||
},
|
||||
data: { revoked: true },
|
||||
});
|
||||
await deleteMedicationCategory(id);
|
||||
await prisma.medication.delete({ where: { id } });
|
||||
await auditLog("medication.delete", {
|
||||
userId,
|
||||
details: {
|
||||
medicationId: id,
|
||||
name,
|
||||
reason: "apple_health_phantom_cleanup",
|
||||
},
|
||||
});
|
||||
affectedUsers.add(userId);
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
for (const userId of affectedUsers) {
|
||||
invalidateUserMedications(userId, { evict: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone. Deleted ${deleted} row(s) across ${affectedUsers.size} user(s).`,
|
||||
);
|
||||
console.log(
|
||||
"Caches were busted for the affected users; a page refresh reflects the change.",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* Read-only before/after count of correlation findings per account, for the
|
||||
* release-candidate dry run of the v1.42 statistics change.
|
||||
*
|
||||
* v1.42 changes what the discovery engine accepts: trend and season are
|
||||
* removed from both series before correlating, p-values use the effective
|
||||
* sample size, shrinkage and the phrasing tier read that effective size, and
|
||||
* the weather channels are tested once over the averaged day-before-and-same-
|
||||
* day exposure instead of a next-day lag. Every one of those can make an
|
||||
* existing finding disappear. This script answers "how many, and where",
|
||||
* on a copy of production, before the release ships.
|
||||
*
|
||||
* For each account it builds the matrix exactly as the correlations route
|
||||
* does (same assembler, same 180-day window, same module map, same tiered
|
||||
* read) and scans it twice:
|
||||
*
|
||||
* - BEFORE: the pre-v1.42 rules, restated here from the engine's own
|
||||
* exported pieces (lag join at 1 day, Pearson with df = n − 2, BH-FDR,
|
||||
* shrinkage and tier by n), over the raw daily weather values;
|
||||
* - AFTER: the engine as it now ships.
|
||||
*
|
||||
* It prints counts only. No correlation, p-value, metric value or account
|
||||
* identifier leaves the database: accounts are listed by a short hash of
|
||||
* their id, channels by their stable key family. Nothing is written — the
|
||||
* pattern store is not synced, no cache is touched.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/compare-correlation-findings.ts [--limit N]
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
|
||||
import { MIN_PAIRED_N, pearson } from "@/lib/insights/correlations";
|
||||
import {
|
||||
benjaminiHochberg,
|
||||
confidenceTier,
|
||||
discoverCorrelations,
|
||||
discoverEmergingCorrelations,
|
||||
EARLY_FDR_Q,
|
||||
EARLY_MIN_PAIRS,
|
||||
EARLY_WINDOW_DAYS,
|
||||
FDR_Q,
|
||||
filterSeriesToWindow,
|
||||
lagJoin,
|
||||
metricFamily,
|
||||
shrinkEstimate,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
|
||||
import {
|
||||
isSurfaceVisible,
|
||||
correlationChannelSurfaceId,
|
||||
} from "@/lib/modules/surface";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
/** The correlations route's window. */
|
||||
const WINDOW_DAYS = 180;
|
||||
|
||||
interface Finding {
|
||||
behaviour: string;
|
||||
outcome: string;
|
||||
}
|
||||
|
||||
interface LegacyFinding extends Finding {
|
||||
tier: ReturnType<typeof confidenceTier>;
|
||||
}
|
||||
|
||||
/** The pre-v1.42 discovery rules, restated from the engine's parts. */
|
||||
function legacyDiscover(
|
||||
series: NamedSeries[],
|
||||
opts: { minPairs: number; fdrQ: number },
|
||||
): { found: LegacyFinding[]; pairsTested: number } {
|
||||
const behaviours = series.filter((s) => s.role === "behaviour");
|
||||
const outcomes = series.filter((s) => s.role === "outcome");
|
||||
const tested: Array<Finding & { n: number; r: number; pValue: number }> = [];
|
||||
for (const b of behaviours) {
|
||||
for (const o of outcomes) {
|
||||
if (metricFamily(b.key) === metricFamily(o.key)) continue;
|
||||
const { xs, ys } = lagJoin(b.points, o.points, 1);
|
||||
if (xs.length < opts.minPairs) continue;
|
||||
const result = pearson({ xs, ys, minPairs: opts.minPairs });
|
||||
if (result.status !== "ok") continue;
|
||||
tested.push({
|
||||
behaviour: b.key,
|
||||
outcome: o.key,
|
||||
n: result.n,
|
||||
r: result.r,
|
||||
pValue: result.pValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
const q = benjaminiHochberg(tested.map((t) => t.pValue));
|
||||
const found = tested
|
||||
.filter((t, i) => t.pValue < 0.05 && q[i] <= opts.fdrQ)
|
||||
.map((t) => ({
|
||||
behaviour: t.behaviour,
|
||||
outcome: t.outcome,
|
||||
tier: confidenceTier(shrinkEstimate(t.r, t.n), t.n),
|
||||
}))
|
||||
.filter((t) => t.tier !== null);
|
||||
return { found, pairsTested: tested.length };
|
||||
}
|
||||
|
||||
/** The pre-v1.42 emerging pass: same rules, trailing window, no faint tier. */
|
||||
function legacyEmerging(
|
||||
series: NamedSeries[],
|
||||
retrospective: Finding[],
|
||||
recentFromDayKey: string,
|
||||
): Finding[] {
|
||||
const recent = legacyDiscover(
|
||||
filterSeriesToWindow(series, recentFromDayKey),
|
||||
{ minPairs: EARLY_MIN_PAIRS, fdrQ: EARLY_FDR_Q },
|
||||
);
|
||||
const established = new Set(retrospective.map(key));
|
||||
return recent.found
|
||||
.filter((f) => f.tier !== "faint")
|
||||
.filter((f) => !established.has(key(f)));
|
||||
}
|
||||
|
||||
function key(f: Finding): string {
|
||||
return `${f.behaviour}␟${f.outcome}`;
|
||||
}
|
||||
|
||||
function tzDayKey(at: Date, tz: string): string {
|
||||
const { year, month, day } = wallClockInTz(at, tz);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The weather channels as the engine read them before v1.42: one raw daily
|
||||
* value per field, no averaging. Read here directly because the shipped
|
||||
* series now carries the averaged window.
|
||||
*/
|
||||
async function legacyEnvironmentSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<Map<string, DailySeriesPoint[]>> {
|
||||
// Same UTC lower bound the engine's own environment read uses.
|
||||
const sinceKey = since.toISOString().slice(0, 10);
|
||||
const rows = await prisma.environmentContext.findMany({
|
||||
where: { userId, date: { gte: sinceKey } },
|
||||
orderBy: { date: "asc" },
|
||||
take: 1000,
|
||||
});
|
||||
const out = new Map<string, DailySeriesPoint[]>();
|
||||
for (const field of ENVIRONMENT_FIELDS) {
|
||||
const points: DailySeriesPoint[] = [];
|
||||
for (const row of rows) {
|
||||
const raw = row[field.column];
|
||||
if (raw == null || !Number.isFinite(raw)) continue;
|
||||
const value =
|
||||
field.column === "sunshineSec" || field.column === "daylightSec"
|
||||
? raw / 3600
|
||||
: raw;
|
||||
points.push({ day: row.date, value });
|
||||
}
|
||||
out.set(field.key, points);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Channel family label for the aggregate table (no user data). */
|
||||
function familyLabel(channel: string): string {
|
||||
if (channel.startsWith("ENV_")) return "ENVIRONMENT";
|
||||
if (channel.startsWith("CUSTOM_METRIC:")) return "CUSTOM_METRIC";
|
||||
if (channel.startsWith("SYMPTOM:")) return "SYMPTOM_EVENT";
|
||||
if (channel.startsWith("FACTOR:")) return "MOOD_FACTOR";
|
||||
return channel;
|
||||
}
|
||||
|
||||
interface Tally {
|
||||
before: number;
|
||||
after: number;
|
||||
}
|
||||
|
||||
function bump(map: Map<string, Tally>, k: string, side: keyof Tally) {
|
||||
const t = map.get(k) ?? { before: 0, after: 0 };
|
||||
t[side]++;
|
||||
map.set(k, t);
|
||||
}
|
||||
|
||||
function parseLimit(argv: string[]): number | undefined {
|
||||
const i = argv.indexOf("--limit");
|
||||
if (i === -1) return undefined;
|
||||
const n = Number(argv[i + 1]);
|
||||
return Number.isInteger(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const limit = parseLimit(process.argv.slice(2));
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, timezone: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
...(limit ? { take: limit } : {}),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
|
||||
const byBehaviour = new Map<string, Tally>();
|
||||
const byOutcome = new Map<string, Tally>();
|
||||
const totals = {
|
||||
accounts: 0,
|
||||
scanned: 0,
|
||||
retroBefore: 0,
|
||||
retroAfter: 0,
|
||||
recentBefore: 0,
|
||||
recentAfter: 0,
|
||||
kept: 0,
|
||||
lost: 0,
|
||||
gained: 0,
|
||||
};
|
||||
|
||||
console.log(
|
||||
"account tested(b/a) retro b→a recent b→a kept lost new env b→a",
|
||||
);
|
||||
for (const user of users) {
|
||||
totals.accounts++;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const { series } = await assembleDiscoveryMatrix(user.id, {
|
||||
tz,
|
||||
since,
|
||||
fetchMode: "tiered",
|
||||
modules,
|
||||
});
|
||||
const envVisible = isSurfaceVisible(
|
||||
correlationChannelSurfaceId(ENVIRONMENT_FIELDS[0].key),
|
||||
modules,
|
||||
);
|
||||
const rawEnv = envVisible
|
||||
? await legacyEnvironmentSeries(user.id, since)
|
||||
: new Map<string, DailySeriesPoint[]>();
|
||||
const legacySeries: NamedSeries[] = series.map((s) =>
|
||||
s.key.startsWith("ENV_")
|
||||
? { key: s.key, role: s.role, points: rawEnv.get(s.key) ?? [] }
|
||||
: { key: s.key, role: s.role, label: s.label, points: s.points },
|
||||
);
|
||||
|
||||
const recentFromDayKey = tzDayKey(
|
||||
new Date(now.getTime() - EARLY_WINDOW_DAYS * MS_PER_DAY),
|
||||
tz,
|
||||
);
|
||||
|
||||
const legacy = legacyDiscover(legacySeries, {
|
||||
minPairs: MIN_PAIRED_N,
|
||||
fdrQ: FDR_Q,
|
||||
});
|
||||
const before = legacy.found;
|
||||
const beforeRecent = legacyEmerging(legacySeries, before, recentFromDayKey);
|
||||
const after = discoverCorrelations(series, { locale: "en" });
|
||||
const afterRecent = discoverEmergingCorrelations(series, after, {
|
||||
recentFromDayKey,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (legacy.pairsTested === 0 && after.pairsTested === 0) continue;
|
||||
totals.scanned++;
|
||||
|
||||
const beforeKeys = new Set(before.map(key));
|
||||
const afterKeys = new Set(after.discovered.map(key));
|
||||
const kept = [...afterKeys].filter((k) => beforeKeys.has(k)).length;
|
||||
const lost = beforeKeys.size - kept;
|
||||
const gained = afterKeys.size - kept;
|
||||
const envBefore = before.filter((f) => f.behaviour.startsWith("ENV_"));
|
||||
const envAfter = after.discovered.filter((f) =>
|
||||
f.behaviour.startsWith("ENV_"),
|
||||
);
|
||||
|
||||
for (const f of before) {
|
||||
bump(byBehaviour, familyLabel(f.behaviour), "before");
|
||||
bump(byOutcome, familyLabel(f.outcome), "before");
|
||||
}
|
||||
for (const f of after.discovered) {
|
||||
bump(byBehaviour, familyLabel(f.behaviour), "after");
|
||||
bump(byOutcome, familyLabel(f.outcome), "after");
|
||||
}
|
||||
|
||||
totals.retroBefore += before.length;
|
||||
totals.retroAfter += after.discovered.length;
|
||||
totals.recentBefore += beforeRecent.length;
|
||||
totals.recentAfter += afterRecent.emerging.length;
|
||||
totals.kept += kept;
|
||||
totals.lost += lost;
|
||||
totals.gained += gained;
|
||||
|
||||
const tag = createHash("sha256").update(user.id).digest("hex").slice(0, 8);
|
||||
console.log(
|
||||
[
|
||||
tag.padEnd(9),
|
||||
`${legacy.pairsTested}/${after.pairsTested}`.padEnd(12),
|
||||
`${before.length}→${after.discovered.length}`.padEnd(10),
|
||||
`${beforeRecent.length}→${afterRecent.emerging.length}`.padEnd(11),
|
||||
String(kept).padStart(4),
|
||||
String(lost).padStart(4),
|
||||
String(gained).padStart(3),
|
||||
` ${envBefore.length}→${envAfter.length}`,
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
`accounts ${totals.accounts}, with a testable matrix ${totals.scanned}`,
|
||||
);
|
||||
console.log(
|
||||
`retrospective findings ${totals.retroBefore} → ${totals.retroAfter} (kept ${totals.kept}, lost ${totals.lost}, new ${totals.gained})`,
|
||||
);
|
||||
console.log(
|
||||
`emerging findings ${totals.recentBefore} → ${totals.recentAfter}`,
|
||||
);
|
||||
const table = (title: string, map: Map<string, Tally>) => {
|
||||
console.log("");
|
||||
console.log(`${title}: before → after`);
|
||||
for (const [k, t] of [...map.entries()].sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
)) {
|
||||
console.log(` ${k.padEnd(24)} ${t.before} → ${t.after}`);
|
||||
}
|
||||
};
|
||||
table("by behaviour family", byBehaviour);
|
||||
table("by outcome family", byOutcome);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
|
||||
* canonical SI, in place. Companion to
|
||||
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
|
||||
*
|
||||
* SAFETY:
|
||||
* - Dry-run by default. Writes ONLY when called with `--execute`.
|
||||
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
|
||||
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
|
||||
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
|
||||
* unit is unrecognised, is never touched.
|
||||
* - Each row is rewritten to the canonical `{ value, unit }` the same
|
||||
* resolver the CSV importer + MCP write path use produces, so the number is
|
||||
* re-expressed, never re-labelled in place (which would corrupt it).
|
||||
* - Affected users' measurement caches are busted at the end.
|
||||
*
|
||||
* This repairs the storage LABEL + value for the recognised-alias legacy rows
|
||||
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
|
||||
* but whose value was a foreign unit — that class is not mechanically
|
||||
* identifiable (see the diagnose script).
|
||||
*
|
||||
* Run (inside the app container):
|
||||
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
|
||||
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const execute = process.argv.includes("--execute");
|
||||
|
||||
const groups = await prisma.measurement.groupBy({
|
||||
by: ["type", "unit"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const convertibleGroups = groups.filter((g) => {
|
||||
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
|
||||
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
|
||||
});
|
||||
|
||||
if (convertibleGroups.length === 0) {
|
||||
console.log(
|
||||
"Nothing to convert — no recognised non-canonical units found.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedUsers = new Set<string>();
|
||||
let converted = 0;
|
||||
|
||||
for (const g of convertibleGroups) {
|
||||
const canonical = getUnitForType(g.type);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { type: g.type, unit: g.unit },
|
||||
select: { id: true, userId: true, value: true, unit: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const resolved = resolveToCanonicalUnit(
|
||||
g.type,
|
||||
row.value,
|
||||
row.unit as string,
|
||||
);
|
||||
if (!resolved) continue; // defensive; group already filtered
|
||||
converted += 1;
|
||||
affectedUsers.add(row.userId);
|
||||
console.log(
|
||||
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
|
||||
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
|
||||
);
|
||||
if (execute) {
|
||||
await prisma.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: resolved.value, unit: canonical },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (execute) {
|
||||
for (const userId of affectedUsers) {
|
||||
invalidateUserMeasurements(userId, { evict: true });
|
||||
}
|
||||
console.log(
|
||||
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
|
||||
"Rollups recompute on the next read/backfill.",
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
|
||||
"would be converted. Re-run with --execute to write.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Read-only diagnostic for the phantom Apple-Health medication mirror.
|
||||
*
|
||||
* Prints the shape of every medication matching a name (default
|
||||
* "Blutdruckmittel"): its provenance (externalSource / externalId), dose,
|
||||
* asNeeded flag, createdAt, and its intake-event + schedule counts. Nothing is
|
||||
* written. Use it to confirm the root cause before running any cleanup:
|
||||
*
|
||||
* - externalSource = APPLE_HEALTH with many DISTINCT externalId values
|
||||
* (address-shaped or otherwise mutually different) => the iOS mirror is
|
||||
* minting a fresh key per sync, so idempotency never matches.
|
||||
* - externalSource NULL => the rows are not mirror rows (a different path).
|
||||
* - createdAt spread => one row per sync sweep vs a single burst.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts
|
||||
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts --name "Blutdruckmittel"
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function argValue(flag: string): string | undefined {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const name = argValue("--name") ?? "Blutdruckmittel";
|
||||
|
||||
const meds = await prisma.medication.findMany({
|
||||
where: { name },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
dose: true,
|
||||
asNeeded: true,
|
||||
externalSource: true,
|
||||
externalId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (meds.length === 0) {
|
||||
console.log(`No medications found with name "${name}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const m of meds) {
|
||||
const [intakes, schedules] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId: m.id, deletedAt: null },
|
||||
}),
|
||||
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
|
||||
]);
|
||||
rows.push({
|
||||
id: m.id,
|
||||
user: m.userId.slice(0, 8),
|
||||
source: m.externalSource ?? "NULL",
|
||||
externalId: m.externalId ?? "NULL",
|
||||
dose: m.dose,
|
||||
asNeeded: m.asNeeded,
|
||||
intakes,
|
||||
schedules,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n=== ${rows.length} medication(s) named "${name}" ===\n`);
|
||||
console.table(rows);
|
||||
|
||||
const distinctExternalIds = new Set(
|
||||
meds.map((m) => m.externalId).filter((v): v is string => v !== null),
|
||||
);
|
||||
const bySource = new Map<string, number>();
|
||||
for (const m of meds) {
|
||||
const key = m.externalSource ?? "NULL";
|
||||
bySource.set(key, (bySource.get(key) ?? 0) + 1);
|
||||
}
|
||||
const created = meds.map((m) => m.createdAt.getTime());
|
||||
const withIntake = rows.filter((r) => r.intakes > 0).length;
|
||||
const withSchedule = rows.filter((r) => r.schedules > 0).length;
|
||||
|
||||
console.log("\n=== summary ===");
|
||||
console.log(`total rows: ${rows.length}`);
|
||||
console.log(
|
||||
`externalSource breakdown: ${[...bySource.entries()]
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
console.log(`distinct externalId: ${distinctExternalIds.size}`);
|
||||
console.log(`rows with >=1 intake: ${withIntake}`);
|
||||
console.log(`rows with >=1 schedule: ${withSchedule}`);
|
||||
console.log(
|
||||
`createdAt range: ${new Date(Math.min(...created)).toISOString()} .. ${new Date(Math.max(...created)).toISOString()}`,
|
||||
);
|
||||
console.log(
|
||||
"\nSample externalId values (first 5):\n" +
|
||||
meds
|
||||
.slice(0, 5)
|
||||
.map((m) => ` ${m.externalId ?? "NULL"}`)
|
||||
.join("\n"),
|
||||
);
|
||||
console.log(
|
||||
"\nRead-only. No rows were modified. If the shape confirms the phantom set,\n" +
|
||||
"run scripts/cleanup-apple-health-phantom-meds.ts (dry-run first).",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Read-only diagnostic for cycle records the module has stopped reading.
|
||||
*
|
||||
* Two defects put records into states that look, from the app, like no periods
|
||||
* were ever logged. Both are fixed going forward; neither repairs the rows it
|
||||
* already made, and neither is visible from the surface it broke.
|
||||
*
|
||||
* The first is a closed torso. Logging a period start closes the cycle before
|
||||
* it, and removing that start used to leave the closure standing. The previous
|
||||
* cycle keeps an end date that a cycle which no longer exists gave it, and a
|
||||
* record whose last cycle is closed has no open cycle for the engine to
|
||||
* forecast from, so the prediction disappears and does not come back. The
|
||||
* signature is exact and needs no audit trail: a live cycle carrying an end
|
||||
* date with no live cycle after it. A cycle is closed by its successor, so
|
||||
* closed with no successor means the successor is gone.
|
||||
*
|
||||
* The second is a period that only ever existed as flow. Cycle rows used to be
|
||||
* written by the one-tap period boundary alone, so bleeding entered through the
|
||||
* log sheet's chips, the bulk drain or an Apple Health import produced day-logs
|
||||
* and no cycle at all. The engine reads cycle rows, so the record holds periods
|
||||
* and reports none.
|
||||
*
|
||||
* The bleeding-day rule below is imported from the write path rather than
|
||||
* restated, so this report and the app cannot drift on what counts as a period
|
||||
* start.
|
||||
*
|
||||
* Nothing is written. The output is a per-record inventory plus the two flags,
|
||||
* so the state can be read before anything is decided about it.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-cycle-state.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bleedOpensCycle } from "@/lib/cycle/cycle-boundaries";
|
||||
import { dayDiff } from "@/lib/cycle/day-math";
|
||||
|
||||
/** Below this many observed cycles the app withholds its phase band. */
|
||||
const STILL_LEARNING_BELOW = 3;
|
||||
|
||||
interface CycleRow {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
lengthDays: number | null;
|
||||
periodEndDate: string | null;
|
||||
deletedAt: Date | null;
|
||||
isPredicted: boolean;
|
||||
}
|
||||
|
||||
interface DayRow {
|
||||
date: string;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
}
|
||||
|
||||
function describeCycle(c: CycleRow): string {
|
||||
const state = c.deletedAt
|
||||
? `deleted ${c.deletedAt.toISOString().slice(0, 10)}`
|
||||
: c.endDate
|
||||
? `closed ${c.endDate}`
|
||||
: "open";
|
||||
const length = c.lengthDays === null ? "-" : `${c.lengthDays}d`;
|
||||
const period = c.periodEndDate ? ` periodEnd=${c.periodEndDate}` : "";
|
||||
const predicted = c.isPredicted ? " PREDICTED" : "";
|
||||
return `${c.startDate} ${state.padEnd(20)} length=${length.padEnd(5)}${period}${predicted}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Every record that has ever touched the module, from either side: a cycle
|
||||
// row, or a day-log. A record with day-logs and no cycles is precisely one
|
||||
// of the cases this exists to find, so it cannot be found by cycles alone.
|
||||
const [cycleOwners, dayOwners] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
distinct: ["userId"],
|
||||
select: { userId: true },
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
distinct: ["userId"],
|
||||
select: { userId: true },
|
||||
}),
|
||||
]);
|
||||
const userIds = Array.from(
|
||||
new Set([
|
||||
...cycleOwners.map((r) => r.userId),
|
||||
...dayOwners.map((r) => r.userId),
|
||||
]),
|
||||
).sort();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
console.log("No cycle records on this instance. Nothing to report.");
|
||||
return;
|
||||
}
|
||||
|
||||
let torsoTotal = 0;
|
||||
let flowOnlyTotal = 0;
|
||||
let staleBoundaryTotal = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
const [cycles, days] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
periodEndDate: true,
|
||||
deletedAt: true,
|
||||
isPredicted: true,
|
||||
},
|
||||
}) as Promise<CycleRow[]>,
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { date: "asc" },
|
||||
select: { date: true, flow: true, intermenstrualBleeding: true },
|
||||
}) as Promise<DayRow[]>,
|
||||
]);
|
||||
|
||||
const live = cycles.filter((c) => c.deletedAt === null);
|
||||
const liveStarts = live.map((c) => c.startDate);
|
||||
const startSet = new Set(liveStarts);
|
||||
|
||||
console.log(`record ${userId}`);
|
||||
console.log(
|
||||
` ${cycles.length} cycle row(s), ${live.length} live, ` +
|
||||
`${days.length} day-log(s)`,
|
||||
);
|
||||
for (const c of cycles) console.log(` ${describeCycle(c)}`);
|
||||
|
||||
// A closed cycle with nothing after it: closed by a start that is gone.
|
||||
const torsos = live.filter(
|
||||
(c, i) => c.endDate !== null && i === live.length - 1,
|
||||
);
|
||||
// A closed cycle whose boundary does not match the successor it has.
|
||||
const stale = live.filter((c, i) => {
|
||||
const next = live[i + 1];
|
||||
if (!next) return false;
|
||||
const expectedLength = dayDiff(next.startDate, c.startDate);
|
||||
return c.endDate === null || c.lengthDays !== expectedLength;
|
||||
});
|
||||
|
||||
// Bleeding days the write path would treat as a period start today, on a
|
||||
// date carrying no live cycle. Walked forward so each decision sees the
|
||||
// starts that already exist plus the ones this pass would infer, exactly
|
||||
// as a replay of the writes would.
|
||||
const inferred: string[] = [...liveStarts].sort();
|
||||
const flowOnly: string[] = [];
|
||||
for (const d of days) {
|
||||
if (startSet.has(d.date)) continue;
|
||||
const prior = inferred.filter((s) => s <= d.date).at(-1) ?? null;
|
||||
if (
|
||||
bleedOpensCycle(
|
||||
d.flow as never,
|
||||
d.intermenstrualBleeding,
|
||||
prior,
|
||||
d.date,
|
||||
)
|
||||
) {
|
||||
flowOnly.push(d.date);
|
||||
inferred.push(d.date);
|
||||
inferred.sort();
|
||||
}
|
||||
}
|
||||
|
||||
if (torsos.length > 0) {
|
||||
torsoTotal += torsos.length;
|
||||
console.log(
|
||||
` CLOSED TORSO: ${torsos
|
||||
.map((c) => `${c.startDate} (ends ${c.endDate})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
console.log(
|
||||
" The last cycle is closed and has no successor, so the engine has " +
|
||||
"no open cycle to forecast from. Re-opening it means clearing its " +
|
||||
"endDate and lengthDays.",
|
||||
);
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
staleBoundaryTotal += stale.length;
|
||||
console.log(
|
||||
` STALE BOUNDARY: ${stale.map((c) => c.startDate).join(", ")}`,
|
||||
);
|
||||
console.log(
|
||||
" The stored end or length disagrees with the next cycle's start.",
|
||||
);
|
||||
}
|
||||
if (flowOnly.length > 0) {
|
||||
flowOnlyTotal += flowOnly.length;
|
||||
console.log(` PERIOD WITH NO CYCLE ROW: ${flowOnly.join(", ")}`);
|
||||
console.log(
|
||||
" Bleeding was logged on these days and no cycle opens on them. " +
|
||||
"Re-saving each day through the log sheet opens it now.",
|
||||
);
|
||||
}
|
||||
if (live.length > 0 && live.length - 1 < STILL_LEARNING_BELOW) {
|
||||
console.log(
|
||||
` Only ${live.length - 1} observed cycle length(s): under the ` +
|
||||
`${STILL_LEARNING_BELOW}-cycle gate, so the phase band stays withheld.`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${userIds.length} record(s) inspected. ` +
|
||||
`${torsoTotal} closed torso(s), ${staleBoundaryTotal} stale boundary(ies), ` +
|
||||
`${flowOnlyTotal} logged period(s) with no cycle row.`,
|
||||
);
|
||||
console.log("Nothing was changed.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Read-only diagnostic for stored measurement rows outside the app's own
|
||||
* declared plausibility domain.
|
||||
*
|
||||
* `VALUE_RANGES` in `@/lib/validations/measurement` is the band the
|
||||
* application says a metric can occupy. Every path a person can drive has
|
||||
* always enforced it; the provider sync writers did not, so rows the
|
||||
* application itself calls impossible reached the table — and every mean,
|
||||
* median and personal band built from them inherited the impossibility. The
|
||||
* writers enforce it now, which stops new rows landing. It says nothing about
|
||||
* the rows already there.
|
||||
*
|
||||
* This reports them and repairs nothing. What to do with a stored impossible
|
||||
* row is a judgement the data cannot make: a pulse of 55 million is plainly a
|
||||
* decode slip and a candidate for deletion, while a weight of 0.9 kg might be
|
||||
* a neonatal record on a shared scale, or a unit mix-up, or a real reading the
|
||||
* band is simply too narrow for. Deleting somebody's health record on a
|
||||
* heuristic is not a thing a script gets to decide.
|
||||
*
|
||||
* Output is aggregate: one line per (metric, source) bucket with the row
|
||||
* count, the number of distinct records affected, the observed extremes, and
|
||||
* the window they fall in. Individual readings are not printed — the bucket
|
||||
* plus the metric's own declared bound is what a decision needs, and a log
|
||||
* file full of somebody's vitals is not.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-implausible-measurements.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
|
||||
interface BucketRow {
|
||||
type: string;
|
||||
source: string;
|
||||
row_count: bigint;
|
||||
record_count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
first_at: Date;
|
||||
last_at: Date;
|
||||
tombstoned: bigint;
|
||||
}
|
||||
|
||||
function ymd(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const types = Object.keys(VALUE_RANGES).sort();
|
||||
const mins = types.map((t) => VALUE_RANGES[t]!.min);
|
||||
const maxes = types.map((t) => VALUE_RANGES[t]!.max);
|
||||
|
||||
// The band table rides in as three parallel bound arrays rather than a
|
||||
// spliced VALUES list, so the query carries no interpolated text at all.
|
||||
// `value <> value` is the NaN probe: a double-precision column can hold one,
|
||||
// and it compares false against every bound including itself.
|
||||
const buckets = await prisma.$queryRaw<BucketRow[]>`
|
||||
WITH bands(type, min_value, max_value) AS (
|
||||
SELECT * FROM unnest(
|
||||
${types}::text[],
|
||||
${mins}::double precision[],
|
||||
${maxes}::double precision[]
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
m."source"::text AS source,
|
||||
COUNT(*)::bigint AS row_count,
|
||||
COUNT(DISTINCT m."user_id")::bigint AS record_count,
|
||||
MIN(m."value") AS min_value,
|
||||
MAX(m."value") AS max_value,
|
||||
MIN(m."measured_at") AS first_at,
|
||||
MAX(m."measured_at") AS last_at,
|
||||
COUNT(*) FILTER (WHERE m."deleted_at" IS NOT NULL)::bigint AS tombstoned
|
||||
FROM "measurements" m
|
||||
JOIN bands b ON b."type" = m."type"::text
|
||||
WHERE m."value" < b.min_value
|
||||
OR m."value" > b.max_value
|
||||
OR m."value" <> m."value"
|
||||
GROUP BY m."type", m."source"
|
||||
ORDER BY COUNT(*) DESC, m."type"::text ASC, m."source"::text ASC
|
||||
`;
|
||||
|
||||
if (buckets.length === 0) {
|
||||
console.log(
|
||||
`Every stored measurement sits inside its metric's declared range ` +
|
||||
`(${types.length} metric bands checked). Nothing to review.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalRows = buckets.reduce((sum, b) => sum + Number(b.row_count), 0);
|
||||
const affectedTypes = new Set(buckets.map((b) => b.type));
|
||||
const affectedSources = new Set(buckets.map((b) => b.source));
|
||||
|
||||
console.log(
|
||||
`${totalRows} stored row(s) sit outside the declared plausibility band, ` +
|
||||
`across ${affectedTypes.size} metric(s) and ${affectedSources.size} source(s).\n` +
|
||||
`Nothing below is modified. Each line is a (metric, source) bucket; the ` +
|
||||
`band is the metric's own declared domain.\n`,
|
||||
);
|
||||
|
||||
// Group by metric so the declared band is stated once per heading, and rank
|
||||
// the metrics by how much of the problem each one is. The query's own
|
||||
// ordering already ranks the sources inside a metric.
|
||||
const byType = new Map<string, BucketRow[]>();
|
||||
for (const bucket of buckets) {
|
||||
const bucketsForType = byType.get(bucket.type);
|
||||
if (bucketsForType) bucketsForType.push(bucket);
|
||||
else byType.set(bucket.type, [bucket]);
|
||||
}
|
||||
const ranked = [...byType.entries()].sort(
|
||||
(a, b) =>
|
||||
b[1].reduce((sum, x) => sum + Number(x.row_count), 0) -
|
||||
a[1].reduce((sum, x) => sum + Number(x.row_count), 0),
|
||||
);
|
||||
|
||||
for (const [type, rows] of ranked) {
|
||||
const band = VALUE_RANGES[type]!;
|
||||
console.log(`${type} — declared band ${band.min}…${band.max}`);
|
||||
for (const bucket of rows) {
|
||||
const live = Number(bucket.row_count) - Number(bucket.tombstoned);
|
||||
console.log(
|
||||
` ${bucket.source.padEnd(14)} ` +
|
||||
`${String(bucket.row_count).padStart(7)} row(s) ` +
|
||||
`(${live} live, ${bucket.tombstoned} tombstoned) ` +
|
||||
`across ${bucket.record_count} record(s) — ` +
|
||||
`observed ${bucket.min_value}…${bucket.max_value}, ` +
|
||||
`${ymd(bucket.first_at)} → ${ymd(bucket.last_at)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nThe sync writers refuse these values now, so the population above is ` +
|
||||
`closed unless a writer without the gate is still in play. Removing a ` +
|
||||
`row is a maintainer decision, per bucket.`,
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Read-only diagnostic for hand-entered sleep rows stored in the wrong unit.
|
||||
*
|
||||
* `SLEEP_DURATION` is stored in minutes. The manual entry field asked for
|
||||
* hours and sent the typed number through unconverted, so a night entered as
|
||||
* 7.5 was filed as seven and a half MINUTES. Nothing objected: a single sleep
|
||||
* STAGE really can be that short, so the value sits inside the column's
|
||||
* plausibility band and looks like an ordinary row.
|
||||
*
|
||||
* That ambiguity is why this reports and does not repair. A stage segment of
|
||||
* eight minutes and a night typed as eight hours are the same number in the
|
||||
* same column, and only the person who entered it knows which it was. What CAN
|
||||
* be narrowed is the population: a MANUAL, whole-night row (no sleep stage) is
|
||||
* one somebody typed into the field, and a typed night shorter than the
|
||||
* longest plausible hours entry is almost certainly hours that were read as
|
||||
* minutes.
|
||||
*
|
||||
* The output lists the candidates with their date, stored value and the night
|
||||
* they would have been if the number was hours, so the maintainer can decide
|
||||
* per row. Nothing is written.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-manual-sleep-hours.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MINUTES_PER_HOUR } from "@/lib/measurements/entry-units";
|
||||
|
||||
/**
|
||||
* Longest night anybody types into an hours field. A stored value at or below
|
||||
* this reads as hours; above it, the number can only ever have been minutes.
|
||||
*/
|
||||
const MAX_PLAUSIBLE_HOURS = 24;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const suspects = await prisma.measurement.findMany({
|
||||
where: {
|
||||
type: "SLEEP_DURATION",
|
||||
source: "MANUAL",
|
||||
deletedAt: null,
|
||||
// Whole-night rows only — a staged row comes from a sync source, never
|
||||
// from the entry field.
|
||||
sleepStage: null,
|
||||
value: { gt: 0, lte: MAX_PLAUSIBLE_HOURS },
|
||||
},
|
||||
orderBy: [{ userId: "asc" }, { measuredAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (suspects.length === 0) {
|
||||
console.log(
|
||||
"No hand-entered sleep rows in the hours-shaped band. Nothing to review.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const byUser = new Map<string, typeof suspects>();
|
||||
for (const row of suspects) {
|
||||
const bucket = byUser.get(row.userId);
|
||||
if (bucket) bucket.push(row);
|
||||
else byUser.set(row.userId, [row]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${suspects.length} hand-entered sleep row(s) across ${byUser.size} record(s) ` +
|
||||
`store a value of ${MAX_PLAUSIBLE_HOURS} or less.\n` +
|
||||
`Each is either a very short recorded sleep, or hours that were read as minutes.\n`,
|
||||
);
|
||||
|
||||
for (const [userId, rows] of byUser) {
|
||||
console.log(`record ${userId} — ${rows.length} row(s)`);
|
||||
for (const row of rows) {
|
||||
const asHours = row.value * MINUTES_PER_HOUR;
|
||||
console.log(
|
||||
` ${row.measuredAt.toISOString().slice(0, 10)} id=${row.id} ` +
|
||||
`stored=${row.value} min if it was hours=${asHours} min ` +
|
||||
`(${(asHours / MINUTES_PER_HOUR).toFixed(2)} h) ` +
|
||||
`entered=${row.createdAt.toISOString().slice(0, 10)}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Nothing was changed. Correct a row from the record list, or delete and re-enter it.",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
|
||||
*
|
||||
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
|
||||
* one historical leak was the MCP `log_measurement` tool, which persisted a
|
||||
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
|
||||
* column differs from its type's canonical unit is such a legacy row, and it
|
||||
* mislabels every display surface that trusts the column.
|
||||
*
|
||||
* This groups measurements by `(type, unit)` and prints every group whose
|
||||
* `unit` is not the canonical unit for its type, flagging whether the stored
|
||||
* unit is a RECOGNISED alias (mechanically convertible by the companion
|
||||
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
|
||||
*
|
||||
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
|
||||
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
|
||||
* into the old kg-labelled entry form). Those are indistinguishable from real
|
||||
* canonical readings; the reporter edits them via the record list.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const groups = await prisma.measurement.groupBy({
|
||||
by: ["type", "unit"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const mismatches = groups.filter((g) => {
|
||||
if (g.unit === null) return false;
|
||||
return g.unit !== getUnitForType(g.type);
|
||||
});
|
||||
|
||||
if (mismatches.length === 0) {
|
||||
console.log("No non-canonical measurement units found. Storage is clean.");
|
||||
return;
|
||||
}
|
||||
|
||||
let convertible = 0;
|
||||
let unknown = 0;
|
||||
console.log(
|
||||
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
|
||||
);
|
||||
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
|
||||
const canonical = getUnitForType(g.type);
|
||||
// Probe convertibility with a neutral value — only the recognition matters.
|
||||
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
|
||||
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
|
||||
if (recognised) convertible += g._count._all;
|
||||
else unknown += g._count._all;
|
||||
console.log(
|
||||
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
|
||||
`rows=${g._count._all} [${status}]`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
|
||||
);
|
||||
console.log(
|
||||
"Convert the recognised aliases with:\n" +
|
||||
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* One-shot helper for a text sweep across the six locale bundles.
|
||||
* One-shot helper for a text sweep across every locale bundle.
|
||||
*
|
||||
* node scripts/i18n-key-tool.mjs drop <dotted.key> [...]
|
||||
* node scripts/i18n-key-tool.mjs set <locale> <dotted.key> <value>
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
v1.4.25 W9e — build messages/{fr,es,it,pl}.json from messages/en.json.
|
||||
|
||||
These are AI-initial translations. A MaintainershipBanner runs at the
|
||||
top of /auth-shell for non-maintained locales, advertising the AI
|
||||
provenance and linking to the GitHub translation-feedback template.
|
||||
|
||||
We translate via a layered lookup:
|
||||
1. Full-path overrides (e.g. "auth.email" -> precise FR)
|
||||
2. Last-segment lookups (covers Save/Cancel/Delete/etc.)
|
||||
3. Whole-string lookups (covers common short phrases)
|
||||
4. Token-by-token translation for sentences
|
||||
5. Fallback: keep the English value verbatim (the banner says so)
|
||||
|
||||
Placeholders ({foo}, {bar}, <tag>) are preserved verbatim — the
|
||||
translator never touches text inside curly braces or angle brackets.
|
||||
|
||||
Run: python3 scripts/i18n/build-locale.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MESSAGES = ROOT / "messages"
|
||||
EN = json.loads((MESSAGES / "en.json").read_text(encoding="utf-8"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core UI vocabulary — high-confidence translations for the most-used
|
||||
# short strings. Each entry is keyed on the lowercase English form (with
|
||||
# trailing punctuation/ellipsis stripped) so a "Save" / "save" / "Save..."
|
||||
# all hit the same row.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format: english_lower -> { fr, es, it, pl }
|
||||
COMMON_VOCAB: dict[str, dict[str, str]] = {
|
||||
# Buttons / actions
|
||||
"save": {"fr": "Enregistrer", "es": "Guardar", "it": "Salva", "pl": "Zapisz"},
|
||||
"saved": {"fr": "Enregistré", "es": "Guardado", "it": "Salvato", "pl": "Zapisano"},
|
||||
"cancel": {"fr": "Annuler", "es": "Cancelar", "it": "Annulla", "pl": "Anuluj"},
|
||||
"delete": {"fr": "Supprimer", "es": "Eliminar", "it": "Elimina", "pl": "Usuń"},
|
||||
"edit": {"fr": "Modifier", "es": "Editar", "it": "Modifica", "pl": "Edytuj"},
|
||||
"create": {"fr": "Créer", "es": "Crear", "it": "Crea", "pl": "Utwórz"},
|
||||
"close": {"fr": "Fermer", "es": "Cerrar", "it": "Chiudi", "pl": "Zamknij"},
|
||||
"confirm": {"fr": "Confirmer", "es": "Confirmar", "it": "Conferma", "pl": "Potwierdź"},
|
||||
"back": {"fr": "Retour", "es": "Atrás", "it": "Indietro", "pl": "Wstecz"},
|
||||
"next": {"fr": "Suivant", "es": "Siguiente", "it": "Avanti", "pl": "Dalej"},
|
||||
"yes": {"fr": "Oui", "es": "Sí", "it": "Sì", "pl": "Tak"},
|
||||
"no": {"fr": "Non", "es": "No", "it": "No", "pl": "Nie"},
|
||||
"search": {"fr": "Rechercher", "es": "Buscar", "it": "Cerca", "pl": "Szukaj"},
|
||||
"filter": {"fr": "Filtrer", "es": "Filtrar", "it": "Filtra", "pl": "Filtruj"},
|
||||
"add": {"fr": "Ajouter", "es": "Añadir", "it": "Aggiungi", "pl": "Dodaj"},
|
||||
"import": {"fr": "Importer", "es": "Importar", "it": "Importa", "pl": "Importuj"},
|
||||
"export": {"fr": "Exporter", "es": "Exportar", "it": "Esporta", "pl": "Eksportuj"},
|
||||
"reset": {"fr": "Réinitialiser", "es": "Restablecer", "it": "Reimposta", "pl": "Resetuj"},
|
||||
"test": {"fr": "Tester", "es": "Probar", "it": "Verifica", "pl": "Testuj"},
|
||||
"copy": {"fr": "Copier", "es": "Copiar", "it": "Copia", "pl": "Kopiuj"},
|
||||
"retry": {"fr": "Réessayer", "es": "Reintentar", "it": "Riprova", "pl": "Spróbuj ponownie"},
|
||||
"replace": {"fr": "Remplacer", "es": "Reemplazar", "it": "Sostituisci", "pl": "Zastąp"},
|
||||
"loading": {"fr": "Chargement…", "es": "Cargando…", "it": "Caricamento…", "pl": "Ładowanie…"},
|
||||
"loading...": {"fr": "Chargement…", "es": "Cargando…", "it": "Caricamento…", "pl": "Ładowanie…"},
|
||||
"error": {"fr": "Erreur", "es": "Error", "it": "Errore", "pl": "Błąd"},
|
||||
"success": {"fr": "Succès", "es": "Éxito", "it": "Successo", "pl": "Sukces"},
|
||||
"unknown": {"fr": "Inconnu", "es": "Desconocido", "it": "Sconosciuto", "pl": "Nieznane"},
|
||||
"unknown error": {"fr": "Erreur inconnue", "es": "Error desconocido", "it": "Errore sconosciuto", "pl": "Nieznany błąd"},
|
||||
"network error": {"fr": "Erreur réseau", "es": "Error de red", "it": "Errore di rete", "pl": "Błąd sieci"},
|
||||
"no data available": {"fr": "Aucune donnée disponible", "es": "Sin datos disponibles", "it": "Nessun dato disponibile", "pl": "Brak danych"},
|
||||
"all": {"fr": "Tout", "es": "Todo", "it": "Tutto", "pl": "Wszystko"},
|
||||
"active": {"fr": "Actif", "es": "Activo", "it": "Attivo", "pl": "Aktywne"},
|
||||
"inactive": {"fr": "Inactif", "es": "Inactivo", "it": "Inattivo", "pl": "Nieaktywne"},
|
||||
"enabled": {"fr": "Activé", "es": "Activado", "it": "Abilitato", "pl": "Włączone"},
|
||||
"disabled": {"fr": "Désactivé", "es": "Desactivado", "it": "Disabilitato", "pl": "Wyłączone"},
|
||||
"optional": {"fr": "Facultatif", "es": "Opcional", "it": "Opzionale", "pl": "Opcjonalne"},
|
||||
"required": {"fr": "Requis", "es": "Obligatorio", "it": "Obbligatorio", "pl": "Wymagane"},
|
||||
"or": {"fr": "ou", "es": "o", "it": "o", "pl": "lub"},
|
||||
"not logged in": {"fr": "Non connecté", "es": "No conectado", "it": "Non connesso", "pl": "Niezalogowany"},
|
||||
"access denied": {"fr": "Accès refusé", "es": "Acceso denegado", "it": "Accesso negato", "pl": "Brak dostępu"},
|
||||
"copied!": {"fr": "Copié !", "es": "¡Copiado!", "it": "Copiato!", "pl": "Skopiowano!"},
|
||||
"show password": {"fr": "Afficher le mot de passe", "es": "Mostrar contraseña", "it": "Mostra password", "pl": "Pokaż hasło"},
|
||||
"hide password": {"fr": "Masquer le mot de passe", "es": "Ocultar contraseña", "it": "Nascondi password", "pl": "Ukryj hasło"},
|
||||
"more options": {"fr": "Plus d'options", "es": "Más opciones", "it": "Altre opzioni", "pl": "Więcej opcji"},
|
||||
"copy details": {"fr": "Copier les détails", "es": "Copiar detalles", "it": "Copia dettagli", "pl": "Kopiuj szczegóły"},
|
||||
"report issue": {"fr": "Signaler un problème", "es": "Reportar problema", "it": "Segnala un problema", "pl": "Zgłoś problem"},
|
||||
|
||||
# Auth
|
||||
"log in": {"fr": "Se connecter", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj się"},
|
||||
"login": {"fr": "Connexion", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj"},
|
||||
"log out": {"fr": "Se déconnecter", "es": "Cerrar sesión", "it": "Disconnetti", "pl": "Wyloguj się"},
|
||||
"logout": {"fr": "Déconnexion", "es": "Cerrar sesión", "it": "Disconnetti", "pl": "Wyloguj"},
|
||||
"register": {"fr": "S'inscrire", "es": "Registrarse", "it": "Registrati", "pl": "Zarejestruj się"},
|
||||
"sign in": {"fr": "Se connecter", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj się"},
|
||||
"sign up": {"fr": "S'inscrire", "es": "Registrarse", "it": "Registrati", "pl": "Zarejestruj się"},
|
||||
"email": {"fr": "E-mail", "es": "Correo electrónico", "it": "E-mail", "pl": "E-mail"},
|
||||
"password": {"fr": "Mot de passe", "es": "Contraseña", "it": "Password", "pl": "Hasło"},
|
||||
"username": {"fr": "Nom d'utilisateur", "es": "Nombre de usuario", "it": "Nome utente", "pl": "Nazwa użytkownika"},
|
||||
|
||||
# Health terms (Apple Health / Withings vocabulary)
|
||||
"blood pressure": {"fr": "Tension artérielle", "es": "Presión arterial", "it": "Pressione arteriosa", "pl": "Ciśnienie krwi"},
|
||||
"pulse": {"fr": "Pouls", "es": "Pulso", "it": "Polso", "pl": "Tętno"},
|
||||
"weight": {"fr": "Poids", "es": "Peso", "it": "Peso", "pl": "Waga"},
|
||||
"mood": {"fr": "Humeur", "es": "Estado de ánimo", "it": "Umore", "pl": "Nastrój"},
|
||||
"sleep": {"fr": "Sommeil", "es": "Sueño", "it": "Sonno", "pl": "Sen"},
|
||||
"steps": {"fr": "Pas", "es": "Pasos", "it": "Passi", "pl": "Kroki"},
|
||||
"medications": {"fr": "Médicaments", "es": "Medicamentos", "it": "Farmaci", "pl": "Leki"},
|
||||
"medication": {"fr": "Médicament", "es": "Medicamento", "it": "Farmaco", "pl": "Lek"},
|
||||
"height": {"fr": "Taille", "es": "Altura", "it": "Altezza", "pl": "Wzrost"},
|
||||
"bmi": {"fr": "IMC", "es": "IMC", "it": "IMC", "pl": "BMI"},
|
||||
"heart rate": {"fr": "Fréquence cardiaque", "es": "Frecuencia cardíaca", "it": "Frequenza cardiaca", "pl": "Tętno"},
|
||||
"resting heart rate": {"fr": "Fréquence cardiaque au repos", "es": "Frecuencia cardíaca en reposo", "it": "Frequenza cardiaca a riposo", "pl": "Tętno spoczynkowe"},
|
||||
"body fat": {"fr": "Masse grasse", "es": "Grasa corporal", "it": "Massa grassa", "pl": "Tkanka tłuszczowa"},
|
||||
|
||||
# Nav
|
||||
"dashboard": {"fr": "Tableau de bord", "es": "Panel", "it": "Cruscotto", "pl": "Pulpit"},
|
||||
"insights": {"fr": "Analyses", "es": "Análisis", "it": "Analisi", "pl": "Statystyki"},
|
||||
"settings": {"fr": "Paramètres", "es": "Ajustes", "it": "Impostazioni", "pl": "Ustawienia"},
|
||||
"profile": {"fr": "Profil", "es": "Perfil", "it": "Profilo", "pl": "Profil"},
|
||||
"trends": {"fr": "Tendances", "es": "Tendencias", "it": "Tendenze", "pl": "Trendy"},
|
||||
"history": {"fr": "Historique", "es": "Historial", "it": "Cronologia", "pl": "Historia"},
|
||||
"overview": {"fr": "Vue d'ensemble", "es": "Resumen", "it": "Panoramica", "pl": "Przegląd"},
|
||||
"language": {"fr": "Langue", "es": "Idioma", "it": "Lingua", "pl": "Język"},
|
||||
"timezone": {"fr": "Fuseau horaire", "es": "Zona horaria", "it": "Fuso orario", "pl": "Strefa czasowa"},
|
||||
|
||||
# Time periods
|
||||
"today": {"fr": "Aujourd'hui", "es": "Hoy", "it": "Oggi", "pl": "Dzisiaj"},
|
||||
"yesterday": {"fr": "Hier", "es": "Ayer", "it": "Ieri", "pl": "Wczoraj"},
|
||||
"tomorrow": {"fr": "Demain", "es": "Mañana", "it": "Domani", "pl": "Jutro"},
|
||||
"this week": {"fr": "Cette semaine", "es": "Esta semana", "it": "Questa settimana", "pl": "Ten tydzień"},
|
||||
"this month": {"fr": "Ce mois-ci", "es": "Este mes", "it": "Questo mese", "pl": "Ten miesiąc"},
|
||||
"last 7 days": {"fr": "7 derniers jours", "es": "Últimos 7 días", "it": "Ultimi 7 giorni", "pl": "Ostatnie 7 dni"},
|
||||
"last 30 days": {"fr": "30 derniers jours", "es": "Últimos 30 días", "it": "Ultimi 30 giorni", "pl": "Ostatnie 30 dni"},
|
||||
"last 90 days": {"fr": "90 derniers jours", "es": "Últimos 90 días", "it": "Ultimi 90 giorni", "pl": "Ostatnie 90 dni"},
|
||||
"all time": {"fr": "Toute la période", "es": "Todo el tiempo", "it": "Tutto il tempo", "pl": "Cały okres"},
|
||||
|
||||
# Mood scale
|
||||
"great": {"fr": "Excellent", "es": "Excelente", "it": "Ottimo", "pl": "Świetnie"},
|
||||
"good": {"fr": "Bien", "es": "Bien", "it": "Bene", "pl": "Dobrze"},
|
||||
"neutral": {"fr": "Neutre", "es": "Neutral", "it": "Neutro", "pl": "Neutralnie"},
|
||||
"bad": {"fr": "Mauvais", "es": "Mal", "it": "Male", "pl": "Źle"},
|
||||
"awful": {"fr": "Terrible", "es": "Terrible", "it": "Pessimo", "pl": "Okropnie"},
|
||||
|
||||
# Misc
|
||||
"date": {"fr": "Date", "es": "Fecha", "it": "Data", "pl": "Data"},
|
||||
"time": {"fr": "Heure", "es": "Hora", "it": "Ora", "pl": "Czas"},
|
||||
"value": {"fr": "Valeur", "es": "Valor", "it": "Valore", "pl": "Wartość"},
|
||||
"type": {"fr": "Type", "es": "Tipo", "it": "Tipo", "pl": "Typ"},
|
||||
"note": {"fr": "Note", "es": "Nota", "it": "Nota", "pl": "Notatka"},
|
||||
"notes": {"fr": "Notes", "es": "Notas", "it": "Note", "pl": "Notatki"},
|
||||
"name": {"fr": "Nom", "es": "Nombre", "it": "Nome", "pl": "Nazwa"},
|
||||
"description": {"fr": "Description", "es": "Descripción", "it": "Descrizione", "pl": "Opis"},
|
||||
"actions": {"fr": "Actions", "es": "Acciones", "it": "Azioni", "pl": "Akcje"},
|
||||
"status": {"fr": "Statut", "es": "Estado", "it": "Stato", "pl": "Status"},
|
||||
"total": {"fr": "Total", "es": "Total", "it": "Totale", "pl": "Łącznie"},
|
||||
"average": {"fr": "Moyenne", "es": "Promedio", "it": "Media", "pl": "Średnia"},
|
||||
"min": {"fr": "Min", "es": "Mín", "it": "Min", "pl": "Min"},
|
||||
"max": {"fr": "Max", "es": "Máx", "it": "Max", "pl": "Maks"},
|
||||
"from": {"fr": "De", "es": "Desde", "it": "Da", "pl": "Od"},
|
||||
"to": {"fr": "À", "es": "Hasta", "it": "A", "pl": "Do"},
|
||||
"of": {"fr": "de", "es": "de", "it": "di", "pl": "z"},
|
||||
|
||||
# Charts
|
||||
"chart": {"fr": "Graphique", "es": "Gráfico", "it": "Grafico", "pl": "Wykres"},
|
||||
"trend": {"fr": "Tendance", "es": "Tendencia", "it": "Tendenza", "pl": "Trend"},
|
||||
"compare": {"fr": "Comparer", "es": "Comparar", "it": "Confronta", "pl": "Porównaj"},
|
||||
"comparison": {"fr": "Comparaison", "es": "Comparación", "it": "Confronto", "pl": "Porównanie"},
|
||||
|
||||
# Doctor report
|
||||
"patient": {"fr": "Patient", "es": "Paciente", "it": "Paziente", "pl": "Pacjent"},
|
||||
"gender": {"fr": "Sexe", "es": "Sexo", "it": "Sesso", "pl": "Płeć"},
|
||||
"male": {"fr": "Homme", "es": "Hombre", "it": "Uomo", "pl": "Mężczyzna"},
|
||||
"female": {"fr": "Femme", "es": "Mujer", "it": "Donna", "pl": "Kobieta"},
|
||||
"non-binary": {"fr": "Non-binaire", "es": "No binario", "it": "Non binario", "pl": "Niebinarna"},
|
||||
"date of birth": {"fr": "Date de naissance", "es": "Fecha de nacimiento", "it": "Data di nascita", "pl": "Data urodzenia"},
|
||||
|
||||
# GLP-1
|
||||
"glp-1 therapy": {"fr": "Traitement GLP-1", "es": "Terapia GLP-1", "it": "Terapia GLP-1", "pl": "Terapia GLP-1"},
|
||||
"current dose": {"fr": "Dose actuelle", "es": "Dosis actual", "it": "Dose attuale", "pl": "Aktualna dawka"},
|
||||
"compliance": {"fr": "Observance", "es": "Cumplimiento", "it": "Aderenza", "pl": "Przestrzeganie zaleceń"},
|
||||
"compliance rate": {"fr": "Taux d'observance", "es": "Tasa de cumplimiento", "it": "Tasso di aderenza", "pl": "Wskaźnik przestrzegania"},
|
||||
"taken": {"fr": "Pris", "es": "Tomada", "it": "Assunta", "pl": "Przyjęte"},
|
||||
"skipped": {"fr": "Sauté", "es": "Omitida", "it": "Saltata", "pl": "Pominięte"},
|
||||
"missed": {"fr": "Manqué", "es": "Perdida", "it": "Persa", "pl": "Pominięte"},
|
||||
"side effect": {"fr": "Effet secondaire", "es": "Efecto secundario", "it": "Effetto collaterale", "pl": "Skutek uboczny"},
|
||||
"dose": {"fr": "Dose", "es": "Dosis", "it": "Dose", "pl": "Dawka"},
|
||||
|
||||
# Brand/identity
|
||||
"healthlog": {"fr": "HealthLog", "es": "HealthLog", "it": "HealthLog", "pl": "HealthLog"},
|
||||
|
||||
# Settings sections
|
||||
"account": {"fr": "Compte", "es": "Cuenta", "it": "Account", "pl": "Konto"},
|
||||
"appearance": {"fr": "Apparence", "es": "Apariencia", "it": "Aspetto", "pl": "Wygląd"},
|
||||
"notifications": {"fr": "Notifications", "es": "Notificaciones", "it": "Notifiche", "pl": "Powiadomienia"},
|
||||
"integrations": {"fr": "Intégrations", "es": "Integraciones", "it": "Integrazioni", "pl": "Integracje"},
|
||||
"data": {"fr": "Données", "es": "Datos", "it": "Dati", "pl": "Dane"},
|
||||
"privacy": {"fr": "Confidentialité", "es": "Privacidad", "it": "Privacy", "pl": "Prywatność"},
|
||||
"security": {"fr": "Sécurité", "es": "Seguridad", "it": "Sicurezza", "pl": "Bezpieczeństwo"},
|
||||
"theme": {"fr": "Thème", "es": "Tema", "it": "Tema", "pl": "Motyw"},
|
||||
"light": {"fr": "Clair", "es": "Claro", "it": "Chiaro", "pl": "Jasny"},
|
||||
"dark": {"fr": "Sombre", "es": "Oscuro", "it": "Scuro", "pl": "Ciemny"},
|
||||
"system": {"fr": "Système", "es": "Sistema", "it": "Sistema", "pl": "Systemowy"},
|
||||
|
||||
# Onboarding
|
||||
"welcome": {"fr": "Bienvenue", "es": "Bienvenido", "it": "Benvenuto", "pl": "Witamy"},
|
||||
"get started": {"fr": "Commencer", "es": "Empezar", "it": "Inizia", "pl": "Rozpocznij"},
|
||||
"continue": {"fr": "Continuer", "es": "Continuar", "it": "Continua", "pl": "Kontynuuj"},
|
||||
"skip": {"fr": "Ignorer", "es": "Omitir", "it": "Salta", "pl": "Pomiń"},
|
||||
"finish": {"fr": "Terminer", "es": "Finalizar", "it": "Termina", "pl": "Zakończ"},
|
||||
"done": {"fr": "Terminé", "es": "Hecho", "it": "Fatto", "pl": "Gotowe"},
|
||||
|
||||
# Errors
|
||||
"page not found": {"fr": "Page introuvable", "es": "Página no encontrada", "it": "Pagina non trovata", "pl": "Strona nie znaleziona"},
|
||||
"something went wrong": {"fr": "Une erreur est survenue", "es": "Algo salió mal", "it": "Si è verificato un errore", "pl": "Coś poszło nie tak"},
|
||||
}
|
||||
|
||||
# Full-path overrides for highly contextual strings
|
||||
PATH_OVERRIDES: dict[str, dict[str, str]] = {
|
||||
# doctorReport core
|
||||
"doctorReport.title": {
|
||||
"fr": "Rapport de santé",
|
||||
"es": "Informe de salud",
|
||||
"it": "Rapporto sanitario",
|
||||
"pl": "Raport zdrowotny",
|
||||
},
|
||||
"doctorReport.subtitle": {
|
||||
"fr": "HealthLog — Rapport personnel de santé",
|
||||
"es": "HealthLog — Informe personal de salud",
|
||||
"it": "HealthLog — Rapporto personale di salute",
|
||||
"pl": "HealthLog — Osobisty raport zdrowotny",
|
||||
},
|
||||
"doctorReport.period": {
|
||||
"fr": "Période du rapport",
|
||||
"es": "Período del informe",
|
||||
"it": "Periodo del rapporto",
|
||||
"pl": "Okres raportu",
|
||||
},
|
||||
"doctorReport.createdOn": {
|
||||
"fr": "Créé le",
|
||||
"es": "Creado el",
|
||||
"it": "Creato il",
|
||||
"pl": "Utworzono",
|
||||
},
|
||||
"doctorReport.vitalsTitle": {
|
||||
"fr": "Signes vitaux — Résumé",
|
||||
"es": "Signos vitales — Resumen",
|
||||
"it": "Parametri vitali — Riepilogo",
|
||||
"pl": "Parametry życiowe — Podsumowanie",
|
||||
},
|
||||
"doctorReport.footerDisclaimer1": {
|
||||
"fr": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
|
||||
"es": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
|
||||
"it": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
|
||||
"pl": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
|
||||
},
|
||||
"doctorReport.footerDisclaimer2": {
|
||||
"fr": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
|
||||
"es": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
|
||||
"it": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
|
||||
"pl": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
|
||||
},
|
||||
"doctorReport.footerSource": {
|
||||
"fr": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}",
|
||||
"es": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}",
|
||||
"it": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}",
|
||||
"pl": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}",
|
||||
},
|
||||
# Maintainership banner — referenced by the React component
|
||||
"i18n.maintainershipBanner.notice": {
|
||||
"fr": "Cette traduction est maintenue par IA et peut contenir des imprécisions.",
|
||||
"es": "Esta traducción está mantenida por IA y puede contener imprecisiones.",
|
||||
"it": "Questa traduzione è gestita tramite IA e potrebbe contenere imprecisioni.",
|
||||
"pl": "To tłumaczenie jest utrzymywane przez AI i może zawierać nieścisłości.",
|
||||
},
|
||||
"i18n.maintainershipBanner.cta": {
|
||||
"fr": "Aidez-nous sur GitHub",
|
||||
"es": "Ayúdanos en GitHub",
|
||||
"it": "Aiutaci su GitHub",
|
||||
"pl": "Pomóż na GitHubie",
|
||||
},
|
||||
"i18n.maintainershipBanner.dismiss": {
|
||||
"fr": "Ignorer",
|
||||
"es": "Cerrar",
|
||||
"it": "Chiudi",
|
||||
"pl": "Zamknij",
|
||||
},
|
||||
}
|
||||
|
||||
# Last-segment fallback: many keys named after the EN word ("save",
|
||||
# "cancel", "close", "delete"). When the value is just the capitalised
|
||||
# / titlecased English version, we can translate by the EN value lookup.
|
||||
PLACEHOLDER_RE = re.compile(r"(\{[^}]+\}|<[^>]+>|`[^`]+`)")
|
||||
|
||||
|
||||
def split_protected(s: str) -> list[tuple[str, bool]]:
|
||||
"""Split a string into (chunk, is_protected) parts. Protected chunks
|
||||
are placeholders/tags that must NEVER be translated."""
|
||||
parts: list[tuple[str, bool]] = []
|
||||
last = 0
|
||||
for m in PLACEHOLDER_RE.finditer(s):
|
||||
if m.start() > last:
|
||||
parts.append((s[last : m.start()], False))
|
||||
parts.append((m.group(0), True))
|
||||
last = m.end()
|
||||
if last < len(s):
|
||||
parts.append((s[last:], False))
|
||||
return parts
|
||||
|
||||
|
||||
def translate_chunk(text: str, locale: str) -> str:
|
||||
"""Apply COMMON_VOCAB to a free-text chunk. Lowercase lookup; preserves
|
||||
the original capitalisation of the FIRST translated word."""
|
||||
if not text.strip():
|
||||
return text
|
||||
# Whole-string lookup first
|
||||
key = text.strip().lower().rstrip(".!?:,;")
|
||||
if key in COMMON_VOCAB and locale in COMMON_VOCAB[key]:
|
||||
translated = COMMON_VOCAB[key][locale]
|
||||
# Preserve leading whitespace + trailing punctuation
|
||||
leading = text[: len(text) - len(text.lstrip())]
|
||||
trailing_punct = ""
|
||||
stripped = text.strip()
|
||||
while stripped and stripped[-1] in ".!?:,;":
|
||||
trailing_punct = stripped[-1] + trailing_punct
|
||||
stripped = stripped[:-1]
|
||||
return f"{leading}{translated}{trailing_punct}"
|
||||
# No translation — return verbatim
|
||||
return text
|
||||
|
||||
|
||||
def translate_value(value: str, locale: str, full_path: str) -> str:
|
||||
"""Translate one leaf value to the target locale.
|
||||
|
||||
Priority:
|
||||
1. PATH_OVERRIDES exact match
|
||||
2. Whole-value lookup in COMMON_VOCAB
|
||||
3. Verbatim English (banner advertises this)
|
||||
"""
|
||||
# Empty / whitespace
|
||||
if not value.strip():
|
||||
return value
|
||||
|
||||
# 1. Path override
|
||||
if full_path in PATH_OVERRIDES and locale in PATH_OVERRIDES[full_path]:
|
||||
return PATH_OVERRIDES[full_path][locale]
|
||||
|
||||
# 2. Whole-value lookup with placeholder protection
|
||||
parts = split_protected(value)
|
||||
out: list[str] = []
|
||||
for chunk, protected in parts:
|
||||
if protected:
|
||||
out.append(chunk)
|
||||
else:
|
||||
out.append(translate_chunk(chunk, locale))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def walk_and_translate(node, locale: str, prefix: str = "") -> object:
|
||||
if isinstance(node, dict):
|
||||
return {k: walk_and_translate(v, locale, f"{prefix}.{k}" if prefix else k) for k, v in node.items()}
|
||||
if isinstance(node, str):
|
||||
return translate_value(node, locale, prefix)
|
||||
return node
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1:
|
||||
targets = sys.argv[1:]
|
||||
else:
|
||||
targets = ["fr", "es", "it", "pl"]
|
||||
|
||||
for locale in targets:
|
||||
out = walk_and_translate(EN, locale)
|
||||
out_path = MESSAGES / f"{locale}.json"
|
||||
# Preserve EN's pretty-print style (2-space indent, sorted? — EN is not sorted)
|
||||
# We mirror EN's literal indent + key order so diffs stay reviewable.
|
||||
out_path.write_text(
|
||||
json.dumps(out, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Verify key parity
|
||||
new = json.loads(out_path.read_text(encoding="utf-8"))
|
||||
en_keys, new_keys = set(), set()
|
||||
|
||||
def flat(o, p, s):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
flat(v, f"{p}.{k}" if p else k, s)
|
||||
elif isinstance(o, str):
|
||||
s.add(p)
|
||||
|
||||
flat(EN, "", en_keys)
|
||||
flat(new, "", new_keys)
|
||||
only_en = en_keys - new_keys
|
||||
only_new = new_keys - en_keys
|
||||
print(f" {locale}.json: {len(new_keys)} leaf keys; only_en={len(only_en)}; only_new={len(only_new)}")
|
||||
if only_en or only_new:
|
||||
print(" PARITY MISMATCH!", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,154 +0,0 @@
|
||||
-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo).
|
||||
--
|
||||
-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on,
|
||||
-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first).
|
||||
-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with
|
||||
-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered
|
||||
-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights
|
||||
-- board surfaces real findings. role=USER (login is open on prod; an
|
||||
-- admin demo would be a risk). AI insights are generated separately afterwards.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── User (FEMALE, USER role) ──────────────────────────────────────
|
||||
INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
|
||||
SELECT 'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'demo', 'demo@healthlog.app',
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
|
||||
'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo');
|
||||
|
||||
UPDATE users SET
|
||||
gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15',
|
||||
password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
|
||||
onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW()
|
||||
WHERE username = 'demo';
|
||||
|
||||
CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM _demo) THEN
|
||||
RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Wipe prior demo rows (idempotent) ─────────────────────────────
|
||||
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo));
|
||||
DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
|
||||
-- ── Cycle profile (tracking ON) ───────────────────────────────────
|
||||
INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, prediction_enabled, raw_chart_mode, discreet_notifications, sensitive_category_encryption, created_at, updated_at)
|
||||
SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW()
|
||||
FROM _demo
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id);
|
||||
|
||||
UPDATE cycle_profiles SET
|
||||
cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true,
|
||||
typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW()
|
||||
WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
|
||||
-- ── Measurements (90-day sliding window, phase-aware) ─────────────
|
||||
-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13.
|
||||
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
|
||||
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
|
||||
v.gc::glucose_context,
|
||||
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (
|
||||
SELECT ((128 - d) % 28) + 1 AS cd
|
||||
) c,
|
||||
LATERAL (VALUES
|
||||
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
|
||||
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
|
||||
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
|
||||
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
|
||||
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
|
||||
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
|
||||
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
|
||||
) AS v(type, value, unit, gc);
|
||||
|
||||
-- ── Mood (90 days, luteal dip) ────────────────────────────────────
|
||||
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
|
||||
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo),
|
||||
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
|
||||
s.score, 'MANUAL',
|
||||
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (
|
||||
SELECT GREATEST(1, LEAST(5,
|
||||
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
|
||||
)) AS score
|
||||
) s;
|
||||
|
||||
-- ── Menstrual cycles (5, NOW-anchored; last open) ─────────────────
|
||||
WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off)
|
||||
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
|
||||
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo),
|
||||
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END,
|
||||
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE WHEN off = 16 THEN NULL ELSE 28 END,
|
||||
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
true, false, 'Europe/Berlin', NOW(), NOW()
|
||||
FROM offs;
|
||||
|
||||
-- ── Cycle day-logs (128 days) + phase-clustered symptom links ─────
|
||||
WITH demo AS (SELECT user_id FROM _demo),
|
||||
gen AS (
|
||||
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
|
||||
FROM generate_series(0, 127) AS d
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr,
|
||||
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
|
||||
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
|
||||
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
|
||||
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
|
||||
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
|
||||
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
|
||||
FROM gen g
|
||||
RETURNING id, date
|
||||
),
|
||||
link_menstrual AS (
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
|
||||
WHERE g.cd BETWEEN 1 AND 3
|
||||
)
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
|
||||
WHERE g.cd BETWEEN 24 AND 28;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Summary ───────────────────────────────────────────────────────
|
||||
SELECT 'demo user' AS what, id FROM users WHERE username = 'demo';
|
||||
SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
@@ -1,216 +0,0 @@
|
||||
-- HealthLog demo 1-year history extender for apps01.
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Extends the demo account's data BACKWARD from the existing ~90-day seed
|
||||
-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days
|
||||
-- of realistic medication-intake history for the two pre-existing demo meds.
|
||||
-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored
|
||||
-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the
|
||||
-- same phase-aware vitals / mood / cycle channels.
|
||||
--
|
||||
-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs),
|
||||
-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older
|
||||
-- tail so nothing is duplicated:
|
||||
-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d)
|
||||
-- - cycle day-logs : offsets 128..364
|
||||
-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352
|
||||
-- - medication intakes : ~365d of due slots for both demo meds
|
||||
--
|
||||
-- SCOPE + SAFETY
|
||||
-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id.
|
||||
-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT
|
||||
-- DO NOTHING (or a date-offset window that begins past the seed's coverage),
|
||||
-- so re-running adds nothing new.
|
||||
-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user,
|
||||
-- no table-wide DELETE/UPDATE.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql
|
||||
|
||||
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
|
||||
\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d)
|
||||
\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Refuse to run if the demo user does not exist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ─────
|
||||
-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to
|
||||
-- keep row volume sane while still covering the full year. Unique index
|
||||
-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but
|
||||
-- measured_at carries a random sub-day jitter, so collisions are effectively
|
||||
-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway.
|
||||
WITH days AS (
|
||||
SELECT d FROM generate_series(90, 364) AS d
|
||||
WHERE d < 180 OR (d % 2) = 0
|
||||
)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
|
||||
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
|
||||
v.gc::glucose_context,
|
||||
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (VALUES
|
||||
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
|
||||
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
|
||||
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
|
||||
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
|
||||
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
|
||||
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
|
||||
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
|
||||
) AS v(type, value, unit, gc)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ───────────────────────
|
||||
-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail.
|
||||
WITH days AS (SELECT generate_series(90, 364) AS d)
|
||||
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
|
||||
s.score, 'MANUAL',
|
||||
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (
|
||||
SELECT GREATEST(1, LEAST(5,
|
||||
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
|
||||
)) AS score
|
||||
) s
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ───────────────────
|
||||
-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156).
|
||||
-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles.
|
||||
WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off)
|
||||
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
|
||||
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
|
||||
to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
28,
|
||||
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
true, false, 'Europe/Berlin', NOW(), NOW()
|
||||
FROM offs
|
||||
ON CONFLICT (user_id, start_date) DO NOTHING;
|
||||
|
||||
-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ───────────
|
||||
-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index
|
||||
-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows
|
||||
-- this run actually inserts (RETURNING), so re-runs add no duplicate links.
|
||||
WITH gen AS (
|
||||
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
|
||||
FROM generate_series(128, 364) AS d
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr,
|
||||
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
|
||||
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
|
||||
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
|
||||
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
|
||||
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
|
||||
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
|
||||
FROM gen g
|
||||
ON CONFLICT (user_id, date) DO NOTHING
|
||||
RETURNING id, date
|
||||
),
|
||||
link_menstrual AS (
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
|
||||
WHERE g.cd BETWEEN 1 AND 3
|
||||
)
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
|
||||
WHERE g.cd BETWEEN 24 AND 28;
|
||||
|
||||
-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ───────────────────
|
||||
-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST).
|
||||
-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time
|
||||
-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured.
|
||||
-- Live partial unique index: (user_id, medication_id, scheduled_for, source)
|
||||
-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe.
|
||||
-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks.
|
||||
-- Never future-dated: WHERE the slot timestamp <= NOW().
|
||||
WITH slots AS (
|
||||
SELECT
|
||||
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours')
|
||||
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
|
||||
-- deterministic skip decision per week offset (no random(): re-run stable)
|
||||
( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
|
||||
FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days
|
||||
)
|
||||
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
|
||||
SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'cmq2lzjqh005x01nrys2crzg9',
|
||||
s.sched_utc,
|
||||
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END,
|
||||
CASE WHEN s.r < 0.88 THEN false ELSE true END,
|
||||
'WEB'::intake_source,
|
||||
s.sched_utc, s.sched_utc, 0
|
||||
FROM slots s
|
||||
WHERE s.sched_utc <= NOW()
|
||||
-- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00)
|
||||
AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day'
|
||||
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
|
||||
|
||||
-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ──────
|
||||
-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip
|
||||
-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards.
|
||||
WITH slots AS (
|
||||
SELECT
|
||||
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval)
|
||||
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
|
||||
( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
|
||||
FROM generate_series(0, 364) AS dd
|
||||
CROSS JOIN (VALUES (8), (18)) AS h(hh)
|
||||
)
|
||||
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
|
||||
SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'cmq2m0mzk006101nrdpijbij0',
|
||||
s.sched_utc,
|
||||
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END,
|
||||
CASE WHEN s.r < 0.88 THEN false ELSE true END,
|
||||
'WEB'::intake_source,
|
||||
s.sched_utc, s.sched_utc, 0
|
||||
FROM slots s
|
||||
WHERE s.sched_utc <= NOW()
|
||||
-- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC)
|
||||
AND s.sched_utc < date_trunc('day', NOW())
|
||||
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification (demo-user-scoped totals after apply) ─────────────────────────
|
||||
\echo === post-apply totals (demo user) ===
|
||||
SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid';
|
||||
SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid';
|
||||
SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid';
|
||||
SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid';
|
||||
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid';
|
||||
SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9';
|
||||
SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0';
|
||||
@@ -1,65 +0,0 @@
|
||||
-- HealthLog demo medications for apps01 — canonical ids the 1-year extender
|
||||
-- (scripts/seed-demo-extend-1y.sql) references for its intake history.
|
||||
-- Idempotent. Creates the two demo meds + schedules so the extender's
|
||||
-- medication_intake_events FK resolves and the medication cards/compliance
|
||||
-- surfaces populate. Demo user id is the canonical seeded id.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-meds-apps01.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-meds: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Med A — Mounjaro (GLP-1, weekly on Sundays @ 08:00) ────────────
|
||||
INSERT INTO medications
|
||||
(id, user_id, name, dose, treatment_class, doses_per_unit, active, notifications_enabled, starts_on, created_at, updated_at)
|
||||
VALUES
|
||||
('cmq2lzjqh005x01nrys2crzg9', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'Mounjaro', '5 mg', 'GLP1', 4, true, true,
|
||||
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
|
||||
doses_per_unit = EXCLUDED.doses_per_unit, active = true, starts_on = EXCLUDED.starts_on,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO medication_schedules
|
||||
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
|
||||
VALUES
|
||||
('sched_demo_medA_mounjaro', 'cmq2lzjqh005x01nrys2crzg9',
|
||||
'08:00', '10:00', 'Wöchentlich', ARRAY['08:00'], '7', 'FREQ=WEEKLY;BYDAY=SU')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
|
||||
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
|
||||
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
|
||||
|
||||
-- ── Med B — Ramipril (twice daily @ 08:00 + 18:00) ─────────────────
|
||||
INSERT INTO medications
|
||||
(id, user_id, name, dose, treatment_class, active, notifications_enabled, starts_on, created_at, updated_at)
|
||||
VALUES
|
||||
('cmq2m0mzk006101nrdpijbij0', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'Ramipril', '5 mg', 'GENERIC', true, true,
|
||||
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
|
||||
active = true, starts_on = EXCLUDED.starts_on, updated_at = NOW();
|
||||
|
||||
INSERT INTO medication_schedules
|
||||
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
|
||||
VALUES
|
||||
('sched_demo_medB_ramipril', 'cmq2m0mzk006101nrdpijbij0',
|
||||
'08:00', '18:00', 'Morgens & Abends', ARRAY['08:00','18:00'], NULL, 'FREQ=DAILY')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
|
||||
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
|
||||
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
|
||||
|
||||
COMMIT;
|
||||
|
||||
\echo === demo meds ===
|
||||
SELECT id, name, dose, treatment_class, active FROM medications
|
||||
WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' ORDER BY name;
|
||||
@@ -1,153 +0,0 @@
|
||||
-- HealthLog demo computed-wellness-score seeder for apps01 (1 year).
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Seeds the three PERSISTED nightly wellness scores for the demo account so
|
||||
-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full
|
||||
-- year of history. These three are normally written by the nightly score
|
||||
-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED
|
||||
-- Measurement rows; the demo never had them computed, so only the two
|
||||
-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the
|
||||
-- demo's RHR/HRV/sleep/mood, NOT stored) were showing.
|
||||
--
|
||||
-- channel type external_id unit value
|
||||
-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90)
|
||||
-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75)
|
||||
-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65)
|
||||
--
|
||||
-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the
|
||||
-- measurement_type enum (verified live) — both are recomputed on read from
|
||||
-- the underlying signals the demo already carries, so they already render and
|
||||
-- a stored row would be ignored. Seeding them would be dead data.
|
||||
--
|
||||
-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly:
|
||||
-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day,
|
||||
-- external_id = '<prefix>:YYYY-MM-DD', sleep_stage / device_type /
|
||||
-- external_source_version / deleted_at all NULL.
|
||||
--
|
||||
-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets
|
||||
-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS
|
||||
-- day, so the freshest persisted day is yesterday; this matches the job and
|
||||
-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels =
|
||||
-- 1,095 rows on a clean apply.
|
||||
--
|
||||
-- DETERMINISM
|
||||
-- Values + ids are hash-derived from the day stamp (hashtextextended /
|
||||
-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are
|
||||
-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index
|
||||
-- (user_id, type, source, external_id) — the exact key the cron upserts on.
|
||||
--
|
||||
-- SCOPE + SAFETY
|
||||
-- - Demo-user-scoped: EVERY statement hardcodes the demo user id.
|
||||
-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing.
|
||||
-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no
|
||||
-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()).
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql
|
||||
|
||||
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Refuse to run if the demo user does not exist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── The three persisted score channels, one row per (channel, day) ─────────────
|
||||
-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is
|
||||
-- never persisted by the cron). day_key is the UTC calendar day; measured_at is
|
||||
-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values:
|
||||
-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly
|
||||
-- rhythm — lighter mid-week, heavier toward the weekend).
|
||||
-- - noise : per-(channel,day) hash in [0,1), no random().
|
||||
-- - recovery : 55..90, dips after a "hard" (high-strain) day.
|
||||
-- - strain : 20..75, weekend / mid-week training spikes (~25% of days).
|
||||
-- - stress : 25..65, inversely coupled to recovery (recovery up → stress
|
||||
-- down) so the two rings tell a coherent story.
|
||||
WITH days AS (
|
||||
SELECT
|
||||
d,
|
||||
(NOW() AT TIME ZONE 'UTC')::date - d AS day_key
|
||||
FROM generate_series(1, 365) AS d
|
||||
),
|
||||
calc AS (
|
||||
SELECT
|
||||
d,
|
||||
day_key,
|
||||
(day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day
|
||||
-- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7)
|
||||
sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase,
|
||||
-- deterministic per-day "is this a training day" flag (~25%)
|
||||
( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r,
|
||||
-- independent deterministic noise streams per channel, all in [0,1)
|
||||
( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r,
|
||||
( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r,
|
||||
( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r
|
||||
FROM days
|
||||
),
|
||||
vals AS (
|
||||
SELECT
|
||||
d, day_key, measured_at, train_r,
|
||||
-- STRAIN 20..75: base + weekly rhythm + a spike on training days.
|
||||
LEAST(75, GREATEST(20, round(
|
||||
40 + week_phase * 8
|
||||
+ (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END)
|
||||
+ (str_r - 0.5) * 14
|
||||
)))::int AS strain,
|
||||
-- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day.
|
||||
LEAST(90, GREATEST(55, round(
|
||||
74 + week_phase * 6
|
||||
- (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END)
|
||||
+ (rec_r - 0.5) * 16
|
||||
)))::int AS recovery
|
||||
FROM calc
|
||||
),
|
||||
final AS (
|
||||
SELECT
|
||||
v.d, v.day_key, v.measured_at, v.strain, v.recovery,
|
||||
-- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise.
|
||||
LEAST(65, GREATEST(25, round(
|
||||
90 - v.recovery * 0.5
|
||||
+ (c.stress_r - 0.5) * 14
|
||||
)))::int AS stress
|
||||
FROM vals v JOIN calc c USING (d)
|
||||
),
|
||||
rows AS (
|
||||
SELECT day_key, measured_at, type, ext_prefix, value FROM final,
|
||||
LATERAL (VALUES
|
||||
('RECOVERY_SCORE', 'recovery:', recovery),
|
||||
('STRAIN_SCORE', 'strain:', strain),
|
||||
('STRESS_SCORE', 'stress:', stress)
|
||||
) AS ch(type, ext_prefix, value)
|
||||
)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
-- deterministic id from the per-row external_id → byte-stable re-runs.
|
||||
'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
type::measurement_type,
|
||||
value::float8,
|
||||
'score',
|
||||
'COMPUTED'::measurement_source,
|
||||
measured_at,
|
||||
ext_prefix || to_char(day_key, 'YYYY-MM-DD'),
|
||||
measured_at,
|
||||
measured_at
|
||||
FROM rows
|
||||
WHERE measured_at <= NOW()
|
||||
ON CONFLICT (user_id, type, source, external_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification (demo-user-scoped, all five score channels) ───────────────────
|
||||
\echo === post-apply score coverage (demo user) ===
|
||||
SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest
|
||||
FROM measurements
|
||||
WHERE user_id = :'uid'
|
||||
AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE')
|
||||
GROUP BY type
|
||||
ORDER BY type;
|
||||
\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.)
|
||||
@@ -1,198 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const ledgerArg = process.argv[2];
|
||||
if (!ledgerArg) {
|
||||
console.error("usage: node scripts/v1341-audit-ledger-check.mjs <ledger.md>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const ledgerPath = resolve(ledgerArg);
|
||||
const MAX_BYTES = 256 * 1024;
|
||||
const REQUIRED_FIELDS = [
|
||||
"Source",
|
||||
"Status",
|
||||
"Severity",
|
||||
"Category",
|
||||
"Evidence",
|
||||
"Affected path",
|
||||
"Attempted refutation",
|
||||
"Impact",
|
||||
"Requirement",
|
||||
"Planned verification",
|
||||
"Confidence",
|
||||
"Unresolved",
|
||||
];
|
||||
const ALLOWED_STATUS = new Set([
|
||||
"confirmed",
|
||||
"suspected",
|
||||
"blocked",
|
||||
"cleared",
|
||||
"refuted",
|
||||
]);
|
||||
const ALLOWED_SEVERITY = new Set(["critical", "high", "medium", "low", "info"]);
|
||||
const ALLOWED_CONFIDENCE = new Set(["high", "medium", "low"]);
|
||||
const REQUIRED_SOURCES = ["research:", "correctness:", "live:", "performance:"];
|
||||
const REQUIRED_CATEGORIES = [
|
||||
"day-boundary",
|
||||
"silent-success",
|
||||
"cache-freshness",
|
||||
"persistence",
|
||||
"outcome-honesty",
|
||||
];
|
||||
const PRIVACY_PATTERNS = [
|
||||
{
|
||||
name: "email address",
|
||||
pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
||||
},
|
||||
{
|
||||
name: "authorization credential",
|
||||
pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]+/i,
|
||||
},
|
||||
{
|
||||
name: "secret assignment",
|
||||
pattern:
|
||||
/\b(?:token|secret|password|api[_-]?key|client[_-]?secret)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{8,}/i,
|
||||
},
|
||||
{
|
||||
name: "URL or endpoint with authority",
|
||||
pattern: /\bhttps?:\/\/[^\s)>\]]+/i,
|
||||
},
|
||||
{
|
||||
name: "IPv4 address",
|
||||
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
||||
},
|
||||
{
|
||||
name: "UUID-like identifier",
|
||||
pattern:
|
||||
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i,
|
||||
},
|
||||
{
|
||||
name: "long hexadecimal identifier",
|
||||
pattern: /\b[0-9a-f]{32,}\b/i,
|
||||
},
|
||||
{
|
||||
name: "local user filesystem path",
|
||||
pattern: /(?:\/Users\/|\/home\/)[^\s`]+/,
|
||||
},
|
||||
{
|
||||
name: "explicit health value",
|
||||
pattern:
|
||||
/\b(?:glucose|blood pressure|heart rate|weight|bmi|hba1c)\s*(?:value\s*)?[:=]\s*-?\d+(?:\.\d+)?/i,
|
||||
},
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
let source;
|
||||
try {
|
||||
if (statSync(ledgerPath).size > MAX_BYTES) {
|
||||
errors.push(`ledger exceeds ${MAX_BYTES} bytes`);
|
||||
}
|
||||
source = readFileSync(ledgerPath, "utf8");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`v1341-audit-ledger-check: cannot read ledger (${error instanceof Error ? error.message : "unknown error"})`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
for (const check of PRIVACY_PATTERNS) {
|
||||
if (check.pattern.test(source))
|
||||
errors.push(`privacy-dangerous ${check.name} detected`);
|
||||
}
|
||||
|
||||
const headingPattern = /^## (AUD-\d{3}) — (.+)$/gm;
|
||||
const headings = [...source.matchAll(headingPattern)];
|
||||
if (headings.length === 0) errors.push("no finding headings found");
|
||||
if (headings.length > 200) errors.push("finding count exceeds 200");
|
||||
|
||||
const ids = new Set();
|
||||
const sourceRecords = new Set();
|
||||
const records = [];
|
||||
|
||||
for (let index = 0; index < headings.length; index += 1) {
|
||||
const match = headings[index];
|
||||
const id = match[1];
|
||||
const title = match[2].trim();
|
||||
const bodyStart = match.index + match[0].length;
|
||||
const bodyEnd = headings[index + 1]?.index ?? source.length;
|
||||
const body = source.slice(bodyStart, bodyEnd);
|
||||
const fields = new Map();
|
||||
|
||||
if (ids.has(id)) errors.push(`${id}: duplicate finding ID`);
|
||||
ids.add(id);
|
||||
if (!title) errors.push(`${id}: missing title`);
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
const fieldMatch = line.match(/^- \*\*([^*]+):\*\* (.+)$/);
|
||||
if (!fieldMatch) continue;
|
||||
const [, name, value] = fieldMatch;
|
||||
if (fields.has(name)) errors.push(`${id}: duplicate field ${name}`);
|
||||
fields.set(name, value.trim());
|
||||
}
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!fields.get(field)) errors.push(`${id}: missing ${field}`);
|
||||
}
|
||||
|
||||
const status = fields.get("Status")?.toLowerCase();
|
||||
const severity = fields.get("Severity")?.toLowerCase();
|
||||
const confidence = fields.get("Confidence")?.toLowerCase();
|
||||
const unresolved = fields.get("Unresolved")?.toLowerCase();
|
||||
const sourceField = fields.get("Source");
|
||||
|
||||
if (status && !ALLOWED_STATUS.has(status)) {
|
||||
errors.push(`${id}: invalid Status ${status}`);
|
||||
}
|
||||
if (severity && !ALLOWED_SEVERITY.has(severity)) {
|
||||
errors.push(`${id}: invalid Severity ${severity}`);
|
||||
}
|
||||
if (confidence && !ALLOWED_CONFIDENCE.has(confidence)) {
|
||||
errors.push(`${id}: invalid Confidence ${confidence}`);
|
||||
}
|
||||
if (unresolved && !["yes", "no"].includes(unresolved)) {
|
||||
errors.push(`${id}: Unresolved must be yes or no`);
|
||||
}
|
||||
if (["cleared", "refuted"].includes(status) && unresolved !== "no") {
|
||||
errors.push(`${id}: ${status} findings must set Unresolved to no`);
|
||||
}
|
||||
if (sourceField) {
|
||||
if (sourceRecords.has(sourceField))
|
||||
errors.push(`${id}: duplicate Source record`);
|
||||
sourceRecords.add(sourceField);
|
||||
}
|
||||
|
||||
records.push({ id, fields });
|
||||
}
|
||||
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const expected = `AUD-${String(index + 1).padStart(3, "0")}`;
|
||||
if (records[index].id !== expected) {
|
||||
errors.push(`${records[index].id}: expected sequential ID ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of REQUIRED_SOURCES) {
|
||||
if (
|
||||
!records.some((record) => record.fields.get("Source")?.includes(prefix))
|
||||
) {
|
||||
errors.push(`missing source family ${prefix}`);
|
||||
}
|
||||
}
|
||||
for (const category of REQUIRED_CATEGORIES) {
|
||||
if (!records.some((record) => record.fields.get("Category") === category)) {
|
||||
errors.push(`missing required category ${category}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`v1341-audit-ledger-check: FAIL (${errors.length} issue(s))`);
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`v1341-audit-ledger-check: PASS (${records.length} unique findings; privacy scan clean)`,
|
||||
);
|
||||
@@ -1,356 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { arch, cpus, platform, release } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const LIMITS = Object.freeze({
|
||||
deadlineMs: 30_000,
|
||||
warmSamples: 11,
|
||||
googlePages: 786,
|
||||
googleRowsPerPage: 32,
|
||||
reminderMedications: 2_000,
|
||||
reminderSlotsPerMedication: 4,
|
||||
reminderCleanupBacklog: 2_000,
|
||||
healthDenseDays: 42,
|
||||
healthSamplesPerDay: 1_440,
|
||||
healthSparseDays: 379,
|
||||
healthSparseSamplesPerDay: 8,
|
||||
});
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const fixtureMode = args.includes("--fixture");
|
||||
const outputIndex = args.indexOf("--output");
|
||||
const outputPath =
|
||||
outputIndex >= 0 && args[outputIndex + 1]
|
||||
? resolve(args[outputIndex + 1])
|
||||
: null;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`v1341-performance-baseline: ${message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!fixtureMode) {
|
||||
fail(
|
||||
"refusing to run without --fixture; live and production probes are unsupported",
|
||||
);
|
||||
}
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
fail("refusing to run with NODE_ENV=production");
|
||||
}
|
||||
if (!outputPath) {
|
||||
fail("--output <markdown-path> is required");
|
||||
}
|
||||
if (args.some((arg) => /^(--url|--host|--database|--token|--live)/.test(arg))) {
|
||||
fail(
|
||||
"network, database, credential, and live-target arguments are unsupported",
|
||||
);
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const deadlineAt = startedAt + LIMITS.deadlineMs;
|
||||
|
||||
function assertWithinDeadline() {
|
||||
if (performance.now() > deadlineAt) {
|
||||
throw new Error(`fixture deadline exceeded (${LIMITS.deadlineMs} ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
function seededNumber(index, salt) {
|
||||
let value = (index + 1) ^ salt;
|
||||
value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
|
||||
value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
|
||||
return (value ^ (value >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
return sorted[
|
||||
Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
||||
];
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const sign = value < 0 ? "-" : "";
|
||||
return `${sign}${(Math.abs(value) / 1024 / 1024).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
function measure(operation) {
|
||||
assertWithinDeadline();
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const before = performance.now();
|
||||
const result = operation();
|
||||
const elapsedMs = performance.now() - before;
|
||||
const heapDeltaBytes = process.memoryUsage().heapUsed - beforeHeap;
|
||||
assertWithinDeadline();
|
||||
return { elapsedMs, heapDeltaBytes, result };
|
||||
}
|
||||
|
||||
function benchmark(name, operation) {
|
||||
const cold = measure(operation);
|
||||
const warm = [];
|
||||
for (let index = 0; index < LIMITS.warmSamples; index += 1) {
|
||||
warm.push(measure(operation));
|
||||
}
|
||||
const elapsed = warm.map((sample) => sample.elapsedMs).sort((a, b) => a - b);
|
||||
const heaps = warm.map((sample) => sample.heapDeltaBytes);
|
||||
const checksums = new Set([
|
||||
cold.result.checksum,
|
||||
...warm.map((sample) => sample.result.checksum),
|
||||
]);
|
||||
if (checksums.size !== 1) {
|
||||
throw new Error(`${name} fixture produced a non-deterministic checksum`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
coldMs: cold.elapsedMs,
|
||||
coldHeapBytes: cold.heapDeltaBytes,
|
||||
medianMs: percentile(elapsed, 0.5),
|
||||
p95Ms: percentile(elapsed, 0.95),
|
||||
maxMs: elapsed.at(-1),
|
||||
maxWarmHeapBytes: Math.max(...heaps),
|
||||
checksum: cold.result.checksum,
|
||||
counters: cold.result.counters,
|
||||
};
|
||||
}
|
||||
|
||||
function googleHistoryFixture() {
|
||||
const accumulated = [];
|
||||
let checksum = 0;
|
||||
let finalTokenPresent = false;
|
||||
|
||||
for (let page = 0; page < LIMITS.googlePages; page += 1) {
|
||||
const encodedPage = JSON.stringify({
|
||||
rows: Array.from({ length: LIMITS.googleRowsPerPage }, (_, row) => ({
|
||||
t: page * LIMITS.googleRowsPerPage + row,
|
||||
v: seededNumber(page * LIMITS.googleRowsPerPage + row, 17) % 10_000,
|
||||
})),
|
||||
next: page + 1 < LIMITS.googlePages ? `fixture-page-${page + 1}` : null,
|
||||
});
|
||||
const decodedPage = JSON.parse(encodedPage);
|
||||
accumulated.push(...decodedPage.rows);
|
||||
finalTokenPresent = decodedPage.next !== null;
|
||||
}
|
||||
|
||||
for (const row of accumulated) {
|
||||
checksum = (checksum + row.t * 17 + row.v) >>> 0;
|
||||
}
|
||||
return {
|
||||
checksum,
|
||||
counters: {
|
||||
pages: LIMITS.googlePages,
|
||||
rows: accumulated.length,
|
||||
concurrency: 1,
|
||||
finalTokenPresent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reminderCohortFixture() {
|
||||
let checksum = 0;
|
||||
let dueCandidates = 0;
|
||||
let modeledQueries = 1;
|
||||
|
||||
for (let cleanup = 0; cleanup < LIMITS.reminderCleanupBacklog; cleanup += 1) {
|
||||
checksum = (checksum + seededNumber(cleanup, 31)) >>> 0;
|
||||
}
|
||||
|
||||
for (
|
||||
let medication = 0;
|
||||
medication < LIMITS.reminderMedications;
|
||||
medication += 1
|
||||
) {
|
||||
modeledQueries += 2;
|
||||
for (let slot = 0; slot < LIMITS.reminderSlotsPerMedication; slot += 1) {
|
||||
const phase = seededNumber(
|
||||
medication * LIMITS.reminderSlotsPerMedication + slot,
|
||||
43,
|
||||
);
|
||||
if (phase % 5 === 0) {
|
||||
dueCandidates += 1;
|
||||
modeledQueries += 2;
|
||||
}
|
||||
checksum = (checksum + (phase & 0xffff)) >>> 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
checksum,
|
||||
counters: {
|
||||
medications: LIMITS.reminderMedications,
|
||||
slots: LIMITS.reminderMedications * LIMITS.reminderSlotsPerMedication,
|
||||
cleanupBacklog: LIMITS.reminderCleanupBacklog,
|
||||
dueCandidates,
|
||||
modeledQueries,
|
||||
concurrency: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function healthScoreFixture() {
|
||||
const denseRows = Array.from(
|
||||
{ length: LIMITS.healthDenseDays * LIMITS.healthSamplesPerDay },
|
||||
(_, index) => ({
|
||||
day: Math.floor(index / LIMITS.healthSamplesPerDay),
|
||||
source: index % 3,
|
||||
value: seededNumber(index, 59) % 1_000,
|
||||
}),
|
||||
);
|
||||
const sparseRows = Array.from(
|
||||
{ length: LIMITS.healthSparseDays * LIMITS.healthSparseSamplesPerDay },
|
||||
(_, index) => ({
|
||||
day: Math.floor(index / LIMITS.healthSparseSamplesPerDay),
|
||||
domain: index % 8,
|
||||
value: seededNumber(index, 71) % 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const dailyCanonical = new Map();
|
||||
for (const row of denseRows) {
|
||||
const prior = dailyCanonical.get(row.day);
|
||||
if (!prior || row.source < prior.source) dailyCanonical.set(row.day, row);
|
||||
}
|
||||
const domainTotals = new Array(8).fill(0);
|
||||
const domainCounts = new Array(8).fill(0);
|
||||
for (const row of sparseRows) {
|
||||
domainTotals[row.domain] += row.value;
|
||||
domainCounts[row.domain] += 1;
|
||||
}
|
||||
let checksum = 0;
|
||||
for (const row of dailyCanonical.values())
|
||||
checksum = (checksum + row.value) >>> 0;
|
||||
for (let domain = 0; domain < domainTotals.length; domain += 1) {
|
||||
const normalized = Math.round(domainTotals[domain] / domainCounts[domain]);
|
||||
checksum = (checksum * 31 + normalized) >>> 0;
|
||||
}
|
||||
return {
|
||||
checksum,
|
||||
counters: {
|
||||
denseRows: denseRows.length,
|
||||
sparseRows: sparseRows.length,
|
||||
canonicalDays: dailyCanonical.size,
|
||||
domains: domainTotals.length,
|
||||
concurrency: 1,
|
||||
parity: "stable-checksum",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commitSha() {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
function counterSummary(result) {
|
||||
return Object.entries(result.counters)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
try {
|
||||
const results = [
|
||||
benchmark("Google history pagination shape", googleHistoryFixture),
|
||||
benchmark("Medication reminder cohort shape", reminderCohortFixture),
|
||||
benchmark("Health Score read/canonicalization shape", healthScoreFixture),
|
||||
];
|
||||
const durationMs = performance.now() - startedAt;
|
||||
const generatedAt = new Date().toISOString();
|
||||
const markdown = `# v1.34.1 Performance Baseline
|
||||
|
||||
**Generated:** ${generatedAt}
|
||||
|
||||
**Commit:** \`${commitSha()}\`
|
||||
|
||||
**Exit status:** PASS
|
||||
|
||||
**Mode:** deterministic local fixture only
|
||||
|
||||
## Safety and interpretation
|
||||
|
||||
- No network, database, provider, production, or staging endpoint was contacted.
|
||||
- Fixtures contain generated counters only. The report emits no tokens, identifiers, payloads, health values, hostnames, usernames, or working-directory paths.
|
||||
- Concurrency is fixed at one, warm sample count is ${LIMITS.warmSamples}, and the whole run has a ${LIMITS.deadlineMs / 1000}-second deadline.
|
||||
- Timing is a local code-shape baseline, not a production latency or capacity claim. Database, provider, network, framework, cache, and rendering costs remain unmeasured.
|
||||
- The Health Score fixture exercises dense/sparse row construction, source canonicalization, and a stable composite proxy. It does not replace production algorithm parity or database-backed performance tests.
|
||||
|
||||
## Environment
|
||||
|
||||
| Attribute | Value |
|
||||
| --- | --- |
|
||||
| Node | ${process.version} |
|
||||
| Platform | ${platform()} ${release()} |
|
||||
| Architecture | ${arch()} |
|
||||
| Logical CPUs | ${cpus().length} |
|
||||
| Process | single local Node process |
|
||||
| Total harness duration | ${formatMs(durationMs)} ms |
|
||||
|
||||
## Fixture scale
|
||||
|
||||
| Path shape | Fixed scale |
|
||||
| --- | --- |
|
||||
| Google history | ${LIMITS.googlePages} serial pages × ${LIMITS.googleRowsPerPage} generated rows; terminal page has no remaining token |
|
||||
| Reminder cohort | ${LIMITS.reminderMedications} medications × ${LIMITS.reminderSlotsPerMedication} slots plus ${LIMITS.reminderCleanupBacklog} cleanup records |
|
||||
| Health Score | ${LIMITS.healthDenseDays} dense days × ${LIMITS.healthSamplesPerDay} samples plus ${LIMITS.healthSparseDays} sparse days × ${LIMITS.healthSparseSamplesPerDay} samples |
|
||||
|
||||
## Measurements
|
||||
|
||||
| Local path shape | Cold | Warm median | Warm p95 | Warm max | Cold heap delta | Max warm heap delta |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
${results
|
||||
.map(
|
||||
(result) =>
|
||||
`| ${result.name} | ${formatMs(result.coldMs)} ms | ${formatMs(result.medianMs)} ms | ${formatMs(result.p95Ms)} ms | ${formatMs(result.maxMs)} ms | ${formatBytes(result.coldHeapBytes)} | ${formatBytes(result.maxWarmHeapBytes)} |`,
|
||||
)
|
||||
.join("\n")}
|
||||
|
||||
### Determinism and bounded counters
|
||||
|
||||
${results
|
||||
.map(
|
||||
(result) =>
|
||||
`- **${result.name}:** checksum \`${result.checksum.toString(16).padStart(8, "0")}\`; ${counterSummary(result)}.`,
|
||||
)
|
||||
.join("\n")}
|
||||
|
||||
## Route/path interpretation
|
||||
|
||||
| Representative path | What this fixture measures | What it does not measure |
|
||||
| --- | --- | --- |
|
||||
| Google full-sync pagination | Serial JSON page materialization and accumulation at the observed 786-page shape | Provider latency, DB writes, token refresh, rollups, or a safe universal page ceiling |
|
||||
| Medication reminder worker | Serial cohort/slot decisions, cleanup scan, and modeled query amplification | Prisma latency, locks, dispatch latency, or the live cohort size |
|
||||
| Dashboard / analytics Health Score | Dense/sparse row processing, canonical selection, and stable composite proxy | SQL plans, cache behavior, exact production score parity, or user-visible route latency |
|
||||
|
||||
## Hypotheses and unmeasured risks
|
||||
|
||||
- **Hypothesis:** Serial provider latency and write statements dominate the Google full-sync wall time; this local CPU result neither confirms nor refutes that hypothesis.
|
||||
- **Hypothesis:** Reminder query/dispatch amplification, not local loop cost, determines whether a high-water pass approaches its 15-minute cadence.
|
||||
- **Hypothesis:** Cold database row materialization and canonicalization dominate dense-user Health Score work; production impact remains unconfirmed.
|
||||
- **Unmeasured:** network and database tails, cache hit/miss ratios, cross-process invalidation, framework rendering, device/browser performance, and production cohort distributions.
|
||||
- **Do not infer:** a provider defect, a safe page cap, a production regression, or permission to optimize from this report alone.
|
||||
|
||||
## Reproduction
|
||||
|
||||
\`node scripts/v1341-performance-baseline.mjs --fixture --output .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md\`
|
||||
`;
|
||||
|
||||
writeFileSync(outputPath, markdown, { encoding: "utf8", mode: 0o600 });
|
||||
console.log(
|
||||
`v1341-performance-baseline: PASS (${results.length} fixtures, ${formatMs(durationMs)} ms)`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`v1341-performance-baseline: FAIL (${error instanceof Error ? error.message : "unknown error"})`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* v1.37.0 integration verifier — the inventories, and whether any of them is empty.
|
||||
*
|
||||
* ## What this is for
|
||||
*
|
||||
* The release is proved by inventories: a migration allocation, a set of
|
||||
* admitted mutating handlers, a notification delivery matrix, a set of security
|
||||
* principals, five accessibility states and eight journeys. Every one of them is
|
||||
* consumed by a test that iterates it, and every one of those tests passes
|
||||
* trivially when the list it iterates is empty. That is the failure this script
|
||||
* exists to catch, and it is not hypothetical — three separate checks in this
|
||||
* repository have been found green because their matcher matched nothing.
|
||||
*
|
||||
* So the rule here is that a count is asserted before it is used, and asserted
|
||||
* against a number written down rather than against "more than zero" alone.
|
||||
* A list that lost half its entries is not caught by a non-empty check.
|
||||
*
|
||||
* ## What this is NOT
|
||||
*
|
||||
* It does not run the suites and it cannot tell whether the tests that consume
|
||||
* these inventories assert anything worth asserting. It answers one question —
|
||||
* "is there something in each of these, and is it the amount there is supposed
|
||||
* to be" — and leaves the rest to the tests themselves.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* pnpm dlx tsx scripts/verify-v137-adversarial.ts # everything
|
||||
* pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-inventories # counts only
|
||||
* pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-migrations # allocation only
|
||||
*
|
||||
* Exits non-zero on the first failing assertion, and prints every check it ran
|
||||
* so a run that checked nothing is visible as a run that printed nothing.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
ACCESSIBILITY_STATES,
|
||||
SHARING_ACCESSIBILITY_STATES,
|
||||
V137_E2E_JOURNEYS,
|
||||
} from "../tests/fixtures/v137/e2e-journeys";
|
||||
import { NOTIFICATION_DELIVERY_MATRIX } from "../tests/fixtures/v137/notification-matrix";
|
||||
import { SECURITY_PRINCIPALS } from "../tests/fixtures/v137/security-principals";
|
||||
import { ADMITTED_MUTATING_HANDLERS } from "../tests/fixtures/v137/sharing-matrix";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function check(label: string, ok: boolean, detail: string): void {
|
||||
checks += 1;
|
||||
if (ok) {
|
||||
console.log(` ok ${label} — ${detail}`);
|
||||
return;
|
||||
}
|
||||
console.error(` FAIL ${label} — ${detail}`);
|
||||
failures += 1;
|
||||
}
|
||||
|
||||
function expectCount(label: string, actual: number, expected: number): void {
|
||||
check(label, actual === expected, `${actual} (expected ${expected})`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Migration allocation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The release's allocation, corrected. The synthesis said 0296-0299 and called
|
||||
* the range frozen; the release shipped seven. The list is here so a builder
|
||||
* inventing an eighth number, or a merge quietly renumbering one of these,
|
||||
* fails rather than being noticed at the release audit or not at all.
|
||||
*/
|
||||
const MIGRATION_ALLOCATION = [
|
||||
"0296_account_grant_scope_and_manage",
|
||||
"0297_managed_profile",
|
||||
"0298_notification_delivery_identity",
|
||||
"0299_medication_intake_import_actor_attribution",
|
||||
"0300_notification_event_created_at_index",
|
||||
"0301_notification_egress_authorization",
|
||||
"0302_session_record_epoch",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Triggers this release installs. Neither is modelled in the Prisma schema, so
|
||||
* `migrate diff` has nothing to compare them against and cannot report one
|
||||
* going missing — the drift check is structurally blind here. Their presence in
|
||||
* the migration text is the cheapest honest signal; the behaviour is proved by
|
||||
* the integration suites named beside each one.
|
||||
*/
|
||||
const MIGRATION_TRIGGERS: ReadonlyArray<{
|
||||
trigger: string;
|
||||
migration: string;
|
||||
provenBy: string;
|
||||
}> = [
|
||||
{
|
||||
trigger: "push_attempt_attribution_guard",
|
||||
migration: "0298_notification_delivery_identity",
|
||||
provenBy: "tests/integration/notification-attribution.test.ts",
|
||||
},
|
||||
{
|
||||
trigger: "sessions_record_epoch_bump",
|
||||
migration: "0302_session_record_epoch",
|
||||
provenBy: "tests/integration/record-session-epoch-trigger.test.ts",
|
||||
},
|
||||
];
|
||||
|
||||
function checkMigrations(): void {
|
||||
console.log("migration allocation");
|
||||
const dir = join(ROOT, "prisma/migrations");
|
||||
const present = readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
|
||||
check(
|
||||
"the migrations directory really loaded",
|
||||
present.length > 100,
|
||||
`${present.length} migrations on disk`,
|
||||
);
|
||||
|
||||
const release = present.filter((name) => /^(029[6-9]|030[0-2])_/.test(name));
|
||||
expectCount(
|
||||
"the release allocates exactly seven",
|
||||
release.length,
|
||||
MIGRATION_ALLOCATION.length,
|
||||
);
|
||||
|
||||
for (const expected of MIGRATION_ALLOCATION) {
|
||||
check(
|
||||
`allocated ${expected.slice(0, 4)}`,
|
||||
present.includes(expected),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
// Every number used once. A collision is the thing renumbering exists to
|
||||
// prevent, and two directories sharing a prefix apply in an order nobody
|
||||
// chose.
|
||||
const releaseNumbers = release.map((name) => name.slice(0, 4));
|
||||
const duplicated = releaseNumbers.filter(
|
||||
(n, i) => releaseNumbers.indexOf(n) !== i,
|
||||
);
|
||||
check(
|
||||
"no number is allocated twice in the release range",
|
||||
duplicated.length === 0,
|
||||
duplicated.length === 0 ? "none" : duplicated.join(", "),
|
||||
);
|
||||
|
||||
for (const { trigger, migration, provenBy } of MIGRATION_TRIGGERS) {
|
||||
const sql = readFileSync(join(dir, migration, "migration.sql"), "utf8");
|
||||
check(
|
||||
`${migration} installs ${trigger}`,
|
||||
sql.includes(trigger),
|
||||
`behaviour proved by ${provenBy}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Inventories */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function checkInventories(): void {
|
||||
console.log("inventories");
|
||||
|
||||
// Handlers. Every admitted mutation is meant to run level, owner, actor,
|
||||
// effect and encrypted-field cases; an empty list runs none of them and the
|
||||
// matrix test reports the same green either way.
|
||||
check(
|
||||
"the admitted mutating handlers are non-empty",
|
||||
ADMITTED_MUTATING_HANDLERS.length > 0,
|
||||
`${ADMITTED_MUTATING_HANDLERS.length} handlers`,
|
||||
);
|
||||
// Keyed on route AND action, because one route legitimately appears more
|
||||
// than once: an `[id]` route carries an update and a delete, and they are
|
||||
// different contracts with different admitted levels. Keying on the route
|
||||
// alone reported eleven false collisions on the first run of this script.
|
||||
const handlerKeys = new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map((h) => `${h.action} ${h.route}`),
|
||||
);
|
||||
expectCount(
|
||||
"no route and action pair is admitted twice",
|
||||
handlerKeys.size,
|
||||
ADMITTED_MUTATING_HANDLERS.length,
|
||||
);
|
||||
check(
|
||||
"every handler names a module that exists",
|
||||
ADMITTED_MUTATING_HANDLERS.every((h) => {
|
||||
try {
|
||||
return (
|
||||
readFileSync(join(ROOT, "src", h.handlerModule), "utf8").length > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
"each handlerModule resolves under src/",
|
||||
);
|
||||
|
||||
// The notification matrix is two records by two Guardians by four channels.
|
||||
// Written as a product, so a dropped channel shows up here as sixteen minus
|
||||
// four rather than as a suite that simply ran fewer cases.
|
||||
expectCount(
|
||||
"the notification delivery matrix is 2 records x 2 Guardians x 4 channels",
|
||||
NOTIFICATION_DELIVERY_MATRIX.length,
|
||||
16,
|
||||
);
|
||||
check(
|
||||
"no delivery in the matrix is interactive",
|
||||
NOTIFICATION_DELIVERY_MATRIX.every((row) => row.interactive === false),
|
||||
"a Guardian's notification offers no action buttons",
|
||||
);
|
||||
|
||||
// Security principals. Each names an actor, a record and a recipient; a
|
||||
// principal missing one of the three would let a test assert against
|
||||
// undefined and pass.
|
||||
const principals = Object.entries(SECURITY_PRINCIPALS);
|
||||
check(
|
||||
"the security principals are non-empty",
|
||||
principals.length > 0,
|
||||
`${principals.length} principals`,
|
||||
);
|
||||
for (const [name, principal] of principals) {
|
||||
const { actorUserId, recordUserId, recipientUserId } = principal.identities;
|
||||
check(
|
||||
`${name} names all three identities`,
|
||||
Boolean(actorUserId && recordUserId && recipientUserId),
|
||||
`${actorUserId} / ${recordUserId} / ${recipientUserId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Accessibility states and journeys. Both are enumerations the browser suite
|
||||
// is read back against, and both are exact rather than non-empty: a ninth
|
||||
// journey nobody counted and a fifth state quietly absorbed into a fourth are
|
||||
// the two failures the enumeration exists to make visible.
|
||||
expectCount(
|
||||
"exactly five accessibility states",
|
||||
ACCESSIBILITY_STATES.length,
|
||||
5,
|
||||
);
|
||||
expectCount(
|
||||
"every state carries a case",
|
||||
SHARING_ACCESSIBILITY_STATES.length,
|
||||
5,
|
||||
);
|
||||
expectCount("exactly eight journeys", V137_E2E_JOURNEYS.length, 8);
|
||||
expectCount(
|
||||
"every journey id is distinct",
|
||||
new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size,
|
||||
8,
|
||||
);
|
||||
|
||||
// Each journey id must appear in a test title somewhere under e2e/ or tests/
|
||||
// or src/__tests__/. `sharing-accessibility-states.test.tsx` pins WHICH file
|
||||
// carries each one; this leg is the cheaper cross-check that none of the
|
||||
// eight has become a claim with no test anywhere at all.
|
||||
const searched = [
|
||||
...readdirSync(join(ROOT, "e2e")).map((f) => join("e2e", f)),
|
||||
...readdirSync(join(ROOT, "tests/integration")).map((f) =>
|
||||
join("tests/integration", f),
|
||||
),
|
||||
...readdirSync(join(ROOT, "src/__tests__")).map((f) =>
|
||||
join("src/__tests__", f),
|
||||
),
|
||||
].filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
|
||||
check(
|
||||
"the test tree really loaded",
|
||||
searched.length > 50,
|
||||
`${searched.length} files scanned`,
|
||||
);
|
||||
const corpus = searched
|
||||
.map((f) => readFileSync(join(ROOT, f), "utf8"))
|
||||
.join("\n");
|
||||
for (const journey of V137_E2E_JOURNEYS) {
|
||||
check(
|
||||
`${journey.id} is marked in a test title`,
|
||||
corpus.includes(`[${journey.id}]`),
|
||||
journey.claim.slice(0, 60) + "…",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const only = argv.filter((a) => a.startsWith("--"));
|
||||
const runMigrations = only.length === 0 || only.includes("--check-migrations");
|
||||
const runInventories =
|
||||
only.length === 0 || only.includes("--check-inventories");
|
||||
|
||||
if (runMigrations) checkMigrations();
|
||||
if (runInventories) checkInventories();
|
||||
|
||||
// A run that asserted nothing is a run that proves nothing, and it would exit 0.
|
||||
if (checks === 0) {
|
||||
console.error("no checks ran — refusing to report success");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${checks} check(s), ${failures} failure(s)` +
|
||||
(failures === 0 ? " — inventories are non-empty and exact" : ""),
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user