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:
Marc-André Bombeck committed 2026-11-11 22:16:47 +01:00
1 parent 9227844f42
commit 655a1d65a5
33 files changed
+23 -13254

No files matched your search

+22 -18
View File
@@ -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
-254
View File
@@ -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
}
]
}
-25
View File
@@ -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
-254
View File
@@ -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
}
]
}
-25
View File
@@ -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
-298
View File
@@ -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
-804
View File
@@ -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 |
-22
View File
@@ -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.
-254
View File
@@ -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
}
]
}
-25
View File
@@ -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();
});
-338
View File
@@ -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());
-121
View File
@@ -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();
});
-222
View File
@@ -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();
});
-103
View File
@@ -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 -1
View File
@@ -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>
-404
View File
@@ -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())
-154
View File
@@ -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');
-216
View File
@@ -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';
-65
View File
@@ -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;
-153
View File
@@ -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.)
-198
View File
@@ -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)`,
);
-356
View File
@@ -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);
}
-305
View File
@@ -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);