mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge branch 'release/v1.45.0' into feat/v145-testkit
# Conflicts: # src/app/api/tokens/__tests__/route.test.ts
This commit is contained in:
commit
ae2636c9e5
84 files changed
+9286
-532
No files matched your search
@@ -28,6 +28,7 @@ typecheck.
|
||||
- **`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`.
|
||||
- **`install-hooks.sh`** — install the local git hooks.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Request benchmark against a seeded account: latency (p50, p95, max) and
|
||||
* SQL statements per request for the hot read and write paths.
|
||||
*
|
||||
* DATABASE_URL=postgresql://… API_TOKEN_HMAC_KEY=… ENCRYPTION_KEYS=… \
|
||||
* ENCRYPTION_ACTIVE_KEY_ID=… pnpm dlx tsx scripts/bench/run.ts \
|
||||
* [--user bench-large] [--iterations 20] [--warmup 3] \
|
||||
* [--base-url http://127.0.0.1:3100] [--out scripts/bench/results/<label>]
|
||||
*
|
||||
* See docs/contributing/benchmarks.md for the whole setup (a throwaway
|
||||
* Postgres with pg_stat_statements, migrations, the seed, a production
|
||||
* build). Never point it at a database that holds real data: it mints a
|
||||
* token for the bench user and writes measurements under it.
|
||||
*
|
||||
* Without `--base-url` the script starts the production build itself
|
||||
* (`next start` on `.next`) as a web-only process under the production heap
|
||||
* ceiling, and stops it at the end.
|
||||
*
|
||||
* Statements are counted with pg_stat_statements: the sum of `calls` over
|
||||
* the database, read before and after each request, once no statement is
|
||||
* in flight and the count has stopped moving (so fire-and-forget writes a
|
||||
* request starts are counted with it). The bench's own reads of the two
|
||||
* views are excluded. Requests run one at a time, so nothing else is in the
|
||||
* delta except the server's idle background, which is measured first and
|
||||
* reported. Before the measurement the build boots once with every role, so
|
||||
* the worker creates its queues as it does in production; whatever that
|
||||
* boot scheduled is dropped.
|
||||
*
|
||||
* Each read runs twice: cold, with the user's caches evicted before every
|
||||
* request by a one-row manual-sync ingest (excluded from the numbers), so
|
||||
* the request rebuilds what it serves; and warm, back to back, which is
|
||||
* what a cache hit costs. The batch ingest posts 5,000 fresh rows as ten
|
||||
* requests of 500, the route's per-request limit, and removes them again.
|
||||
*
|
||||
* Output: `<out>.json` and `<out>.md`. Exit 2 on any non-2xx response.
|
||||
*/
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { cpus, loadavg } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function arg(name: string, fallback: string): string {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
|
||||
}
|
||||
|
||||
const USER_ID = arg("user", "bench-large");
|
||||
const ITERATIONS = Number(arg("iterations", "20"));
|
||||
const WARMUP = Number(arg("warmup", "3"));
|
||||
const PORT = Number(arg("port", "3100"));
|
||||
const BASE_URL = arg("base-url", "");
|
||||
const OUT = arg("out", join("scripts", "bench", "results", "latest"));
|
||||
const HEAP_MB = Number(arg("heap-mb", "792"));
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
|
||||
|
||||
async function statementCount(): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: string | null }>(
|
||||
`SELECT sum(calls)::bigint AS n FROM pg_stat_statements
|
||||
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
AND query NOT ILIKE '%pg_stat_statements%'
|
||||
AND query NOT ILIKE '%pg_stat_activity%'`,
|
||||
);
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Wait until the server has no statement in flight and the counter holds
|
||||
* still for 150 ms (or 10 s pass), then read it. pg_stat_statements counts a
|
||||
* statement when it ends, so a long fold in a background refresh would look
|
||||
* settled by the counter alone; pg_stat_activity says it is still running.
|
||||
*/
|
||||
async function settledCount(): Promise<number> {
|
||||
let last = -1;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const { rows } = await pool.query<{ active: string }>(
|
||||
`SELECT count(*)::int AS active FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND pid <> pg_backend_pid()
|
||||
AND state NOT IN ('idle')`,
|
||||
);
|
||||
const now = await statementCount();
|
||||
if (Number(rows[0]?.active ?? 0) === 0 && now === last) return now;
|
||||
last = now;
|
||||
await sleep(150);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const i = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, i))]!;
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
ms: number;
|
||||
statements: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
name: string;
|
||||
mode: "cold" | "warm" | "write";
|
||||
request: string;
|
||||
n: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
maxMs: number;
|
||||
statementsMedian: number;
|
||||
statementsMax: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
let token = "";
|
||||
const failures: string[] = [];
|
||||
|
||||
async function call(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Sample> {
|
||||
const before = await settledCount();
|
||||
const started = performance.now();
|
||||
const res = await fetch(`${base()}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
await res.arrayBuffer();
|
||||
const ms = performance.now() - started;
|
||||
const after = await settledCount();
|
||||
if (!res.ok) failures.push(`${method} ${path}: ${res.status}`);
|
||||
return { ms, statements: after - before, status: res.status };
|
||||
}
|
||||
|
||||
function summarise(
|
||||
name: string,
|
||||
mode: ScenarioResult["mode"],
|
||||
request: string,
|
||||
samples: Sample[],
|
||||
): ScenarioResult {
|
||||
const ms = samples.map((s) => s.ms).sort((a, b) => a - b);
|
||||
const st = samples.map((s) => s.statements).sort((a, b) => a - b);
|
||||
const r = (n: number) => Math.round(n * 10) / 10;
|
||||
return {
|
||||
name,
|
||||
mode,
|
||||
request,
|
||||
n: samples.length,
|
||||
p50Ms: r(percentile(ms, 50)),
|
||||
p95Ms: r(percentile(ms, 95)),
|
||||
maxMs: r(ms[ms.length - 1] ?? 0),
|
||||
statementsMedian: percentile(st, 50),
|
||||
statementsMax: st[st.length - 1] ?? 0,
|
||||
failures: samples.filter((s) => s.status >= 300).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-user rate limits would refuse a benchmark long before a person
|
||||
* meets them (one run sends several hundred requests in minutes), so the
|
||||
* bench user's buckets are cleared before each scenario. The limiter's own
|
||||
* cost stays in every count.
|
||||
*/
|
||||
async function resetRateLimits(): Promise<void> {
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE '%:' || $1`, [
|
||||
USER_ID,
|
||||
]);
|
||||
}
|
||||
|
||||
let bustCounter = 0;
|
||||
/**
|
||||
* Evict the user's caches with a real change, outside the numbers: a one-row
|
||||
* ingest marked as a manual sync, which hard-evicts instead of marking
|
||||
* stale, so the next read is a miss and rebuilds in the request.
|
||||
*/
|
||||
async function bust(anchorDay: string): Promise<void> {
|
||||
bustCounter += 1;
|
||||
const res = await fetch(`${base()}/api/measurements/batch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
syncTrigger: "manual",
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed",
|
||||
value: 10 + (bustCounter % 2),
|
||||
unit: "count",
|
||||
startDate: `${anchorDay}T08:00:00.000Z`,
|
||||
endDate: `${anchorDay}T18:00:00.000Z`,
|
||||
externalId: `stats:HKQuantityTypeIdentifierFlightsClimbed:${anchorDay}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
data?: { inserted?: number; updated?: number };
|
||||
};
|
||||
// A bust that wrote nothing evicted nothing, and every "cold" number
|
||||
// after it would be a cache hit.
|
||||
if ((body.data?.inserted ?? 0) + (body.data?.updated ?? 0) !== 1) {
|
||||
throw new Error(`cache bust wrote no row: ${JSON.stringify(body)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function base(): string {
|
||||
return BASE_URL || `http://127.0.0.1:${PORT}`;
|
||||
}
|
||||
|
||||
async function waitForHealth(
|
||||
timeoutMs: number,
|
||||
server: ChildProcess | null,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (server && server.exitCode !== null) {
|
||||
throw new Error(`the server exited with code ${server.exitCode}`);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base()}/api/health`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`server at ${base()} did not answer /api/health`);
|
||||
}
|
||||
|
||||
function startServer(role: "web" | "all"): ChildProcess {
|
||||
// `next start` rather than the standalone server: the standalone tree
|
||||
// lacks the externalised runtime packages the image installs separately
|
||||
// (see the Dockerfile), while the checkout has them. It serves the same
|
||||
// build output.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"node_modules/next/dist/bin/next",
|
||||
"start",
|
||||
"-p",
|
||||
String(PORT),
|
||||
"-H",
|
||||
"127.0.0.1",
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
PORT: String(PORT),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
HEALTHLOG_PROCESS_TYPE: role,
|
||||
SESSION_COOKIE_SECURE: "false",
|
||||
NODE_OPTIONS: `--max-old-space-size=${HEAP_MB}`,
|
||||
},
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
},
|
||||
);
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopServer(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null) return;
|
||||
const exited = new Promise((resolve) => child.once("exit", resolve));
|
||||
child.kill("SIGTERM");
|
||||
await Promise.race([exited, sleep(20_000)]);
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
function freshBatch(run: string, chunk: number): unknown[] {
|
||||
// 500 heart-rate readings a minute apart, on a day inside the seeded
|
||||
// window, with ids no earlier run used.
|
||||
const day = Date.UTC(2026, 0, 1) + chunk * 86_400_000;
|
||||
return Array.from({ length: 500 }, (_, i) => {
|
||||
const at = new Date(day + i * 60_000).toISOString();
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 55 + (i % 40),
|
||||
unit: "count/min",
|
||||
startDate: at,
|
||||
endDate: at,
|
||||
externalId: `bench-${run}-${chunk}-${i}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function markdown(meta: Record<string, unknown>, rows: ScenarioResult[]) {
|
||||
const lines = [
|
||||
`# Benchmark ${String(meta.label)}`,
|
||||
"",
|
||||
`Commit ${String(meta.commit)}, ${String(meta.date)}. ${String(meta.measurements)} measurement rows on the bench account, ${ITERATIONS} measured requests per row after ${WARMUP} warm-up requests, one at a time. Server: production build, web process, heap ceiling ${HEAP_MB} MB. Postgres ${String(meta.postgres)}, ${String(meta.cpus)} cores, load average ${String(meta.loadAverage)} at the end. Idle background: ${String(meta.idleStatementsPerSecond)} statements/s.`,
|
||||
"",
|
||||
"| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
...rows.map(
|
||||
(r) =>
|
||||
`| ${r.name} | ${r.mode} | ${r.n} | ${r.p50Ms} | ${r.p95Ms} | ${r.maxMs} | ${r.statementsMedian} | ${r.statementsMax} |`,
|
||||
),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
|
||||
await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements");
|
||||
await statementCount(); // fails here when the library is not preloaded
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) throw new Error(`no user ${USER_ID}; run the seed first`);
|
||||
const measurements = await prisma.measurement.count({
|
||||
where: { userId: USER_ID },
|
||||
});
|
||||
token = (
|
||||
await issueApiToken({
|
||||
userId: USER_ID,
|
||||
name: "benchmark",
|
||||
permissions: ["*"],
|
||||
expiresInDays: 1,
|
||||
})
|
||||
).token;
|
||||
|
||||
// A web-only process only sends jobs; the queues are created by the
|
||||
// worker role. Boot the full process once so every queue exists (as it
|
||||
// does in production), then run the measurement against a web-only
|
||||
// process, so the worker's polling is not in every delta.
|
||||
if (!BASE_URL) {
|
||||
const full = startServer("all");
|
||||
await waitForHealth(180_000, full);
|
||||
await stopServer(full);
|
||||
// Drop whatever that boot scheduled: nothing in the web-only process
|
||||
// runs jobs, and a fold left queued would change the fixture on the
|
||||
// next full boot.
|
||||
await pool.query("TRUNCATE pgboss.job");
|
||||
}
|
||||
const server = BASE_URL ? null : startServer("web");
|
||||
try {
|
||||
await waitForHealth(180_000, server);
|
||||
|
||||
const idleBefore = await settledCount();
|
||||
await sleep(5_000);
|
||||
const idleStatementsPerSecond =
|
||||
Math.round(((await statementCount()) - idleBefore) / 0.5) / 10;
|
||||
|
||||
const yesterday = new Date(Date.now() - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const reads: Array<[string, string]> = [
|
||||
["/api/auth/me", "/api/auth/me"],
|
||||
["dashboard summary", "/api/dashboard/summary"],
|
||||
["dashboard snapshot", "/api/dashboard/snapshot"],
|
||||
["series pulse 30 d", "/api/measurements/series?kind=pulse&days=30"],
|
||||
["series pulse 365 d", "/api/measurements/series?kind=pulse&days=365"],
|
||||
["series pulse 3650 d", "/api/measurements/series?kind=pulse&days=3650"],
|
||||
["day view", `/api/day/${yesterday}`],
|
||||
["insights health status", "/api/insights/health-status"],
|
||||
["insights correlations", "/api/insights/correlations"],
|
||||
];
|
||||
|
||||
const results: ScenarioResult[] = [];
|
||||
for (const [name, path] of reads) {
|
||||
await resetRateLimits();
|
||||
for (let i = 0; i < WARMUP; i++) await call("GET", path);
|
||||
const cold: Sample[] = [];
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await bust(yesterday);
|
||||
cold.push(await call("GET", path));
|
||||
}
|
||||
results.push(summarise(name, "cold", `GET ${path}`, cold));
|
||||
await resetRateLimits();
|
||||
const warm: Sample[] = [];
|
||||
for (let i = 0; i < ITERATIONS; i++) warm.push(await call("GET", path));
|
||||
results.push(summarise(name, "warm", `GET ${path}`, warm));
|
||||
console.log(
|
||||
`${name.padEnd(26)} cold p95 ${results.at(-2)!.p95Ms} ms / ${results.at(-2)!.statementsMedian} st, warm p95 ${results.at(-1)!.p95Ms} ms / ${results.at(-1)!.statementsMedian} st`,
|
||||
);
|
||||
}
|
||||
|
||||
// Rows an earlier run wrote would turn this run's inserts into
|
||||
// reconciles of the same natural key, so they go first, and again at
|
||||
// the end, leaving the seeded fixture as it was.
|
||||
const clean = () =>
|
||||
pool.query(
|
||||
`DELETE FROM measurements WHERE user_id = $1 AND external_id LIKE 'bench-%'`,
|
||||
[USER_ID],
|
||||
);
|
||||
await clean();
|
||||
await resetRateLimits();
|
||||
const run = Date.now().toString(36);
|
||||
const writes: Sample[] = [];
|
||||
for (let chunk = 0; chunk < 10; chunk++) {
|
||||
writes.push(
|
||||
await call("POST", "/api/measurements/batch", {
|
||||
entries: freshBatch(run, chunk),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const batch = summarise(
|
||||
"batch ingest 5k (per 500)",
|
||||
"write",
|
||||
"POST /api/measurements/batch ×10",
|
||||
writes,
|
||||
);
|
||||
results.push(batch);
|
||||
await clean();
|
||||
const totalMs = writes.reduce((a, s) => a + s.ms, 0);
|
||||
const totalStatements = writes.reduce((a, s) => a + s.statements, 0);
|
||||
console.log(
|
||||
`batch ingest 5k total ${Math.round(totalMs)} ms / ${totalStatements} st`,
|
||||
);
|
||||
|
||||
let commit = "unknown";
|
||||
try {
|
||||
commit = execSync("git rev-parse --short HEAD").toString().trim();
|
||||
} catch {
|
||||
// not a checkout
|
||||
}
|
||||
const meta = {
|
||||
label: OUT.split("/").pop(),
|
||||
commit,
|
||||
date: new Date().toISOString(),
|
||||
node: process.version,
|
||||
postgres: (
|
||||
await pool.query<{ v: string }>(
|
||||
"SELECT current_setting('server_version') AS v",
|
||||
)
|
||||
).rows[0]?.v,
|
||||
cpus: cpus().length,
|
||||
loadAverage: loadavg().map((n) => Math.round(n * 10) / 10),
|
||||
measurements,
|
||||
iterations: ITERATIONS,
|
||||
warmup: WARMUP,
|
||||
heapMb: HEAP_MB,
|
||||
idleStatementsPerSecond,
|
||||
batch5k: { totalMs: Math.round(totalMs), totalStatements },
|
||||
};
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(
|
||||
`${OUT}.json`,
|
||||
`${JSON.stringify({ meta, results }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(`${OUT}.md`, markdown(meta, results));
|
||||
console.log(`wrote ${OUT}.json and ${OUT}.md`);
|
||||
} finally {
|
||||
if (server) await stopServer(server);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`non-2xx responses:\n ${failures.join("\n ")}`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
});
|
||||
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* Seed one synthetic account the size of a heavy real one, for the benchmark
|
||||
* harness (`scripts/bench/run.ts`) and for reproducing the maintenance jobs:
|
||||
* about 1.26 million measurement rows over ten years, plus workouts, mood
|
||||
* entries and medications with their intake history.
|
||||
*
|
||||
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/bench/seed-large-account.ts \
|
||||
* [--user bench-large] [--years 10] [--anchor 2026-10-22] [--reset]
|
||||
* [--no-rollups]
|
||||
*
|
||||
* Never point this at a database that holds real data: it inserts rows under
|
||||
* the given user id and, with `--reset`, deletes that user (and, through the
|
||||
* cascades, everything it owns) first.
|
||||
*
|
||||
* Deterministic: every value and timestamp is a function of the row index and
|
||||
* the anchor day (`--anchor`, UTC midnight; default today), and ids are
|
||||
* derived from the user id, the series and the index. Two runs with the same
|
||||
* arguments write the same rows.
|
||||
*
|
||||
* The shape follows what the integrations write, with overlapping sources so
|
||||
* the canonical-source pick has work to do:
|
||||
*
|
||||
* - Apple Health dense spot rows (heart rate about every ten minutes, HRV,
|
||||
* SpO2, respiratory rate, gait metrics, audio exposure) keyed by a hashed
|
||||
* sample id;
|
||||
* - Withings morning and evening blood pressure with its pulse, and daily
|
||||
* weight, body fat and muscle mass; a weekly manual weight beside it;
|
||||
* - Oura nightly resting heart rate and RMSSD for the last three years,
|
||||
* beside Apple Health's resting heart rate;
|
||||
* - Nightscout CGM glucose every five minutes for the last 90 days;
|
||||
* - one `stats:` daily row per cumulative type and day (the export import)
|
||||
* and per-sample cumulative rows with opaque ids for the last 90 days
|
||||
* (the iOS sync);
|
||||
* - sleep-stage segments every night;
|
||||
* - about 3,000 workouts, a mood entry a day for three years, and three
|
||||
* daily medications with their intake events for three years.
|
||||
*
|
||||
* Everything goes in through set-based `INSERT … SELECT … generate_series`,
|
||||
* so a million rows take seconds rather than a million round trips. The
|
||||
* rollup tier is then folded with `recomputeUserRollups`, the same path the
|
||||
* boot backfill takes for an imported account, so reads see the state a real
|
||||
* account is in. `ANALYZE` runs at the end: a planner reading pre-seed
|
||||
* statistics picks plans no production table of this size would get.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
ALL_GRANULARITIES,
|
||||
recomputeUserRollups,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
function arg(name: string, fallback: string): string {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
|
||||
}
|
||||
|
||||
const USER_ID = arg("user", "bench-large");
|
||||
const YEARS = Number(arg("years", "10"));
|
||||
const ANCHOR = arg("anchor", new Date().toISOString().slice(0, 10));
|
||||
const RESET = process.argv.includes("--reset");
|
||||
const ROLLUPS = !process.argv.includes("--no-rollups");
|
||||
const ZONE = "Europe/Berlin";
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ANCHOR)) {
|
||||
throw new Error(`--anchor must be YYYY-MM-DD, got ${ANCHOR}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot series: rows evenly spaced over the last `days` days before the
|
||||
* anchor. `perDay` above one spaces rows through the day; `everyDays` above
|
||||
* one writes one row every that many days at `offsetSec` past midnight UTC.
|
||||
*/
|
||||
interface SpotSeries {
|
||||
type: string;
|
||||
unit: string;
|
||||
source: string;
|
||||
base: number;
|
||||
spread: number;
|
||||
days: number;
|
||||
perDay?: number;
|
||||
everyDays?: number;
|
||||
offsetSec?: number;
|
||||
/** externalId prefix; `stats:` rows are generated separately. */
|
||||
idPrefix: string;
|
||||
}
|
||||
|
||||
async function insertSpot(s: SpotSeries): Promise<number> {
|
||||
const step = s.everyDays
|
||||
? s.everyDays * 86_400
|
||||
: Math.floor(86_400 / (s.perDay ?? 1));
|
||||
const count = Math.floor((s.days * 86_400) / step);
|
||||
// Jitter stays below the step so every measured_at is distinct per type
|
||||
// and source (the (user, type, measured_at, source, sleep_stage) unique
|
||||
// index). Series that share a step and offset (BP systolic, diastolic and
|
||||
// the Withings pulse) share their timestamps, as a cuff reading does.
|
||||
const jitter = Math.max(1, Math.min(59, step - 1));
|
||||
return prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-' || $2 || '-' || $10 || '-' || g,
|
||||
$1,
|
||||
$2::measurement_type,
|
||||
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
|
||||
$5,
|
||||
$10::measurement_source,
|
||||
$11::date::timestamp - make_interval(secs => $6 * $7::bigint)
|
||||
+ make_interval(secs => g * $6 + $12 + (g * 31) % $8),
|
||||
$9 || md5($2 || $10 || g::text),
|
||||
now(), now()
|
||||
FROM generate_series(0, $7::bigint - 1) AS g
|
||||
`,
|
||||
USER_ID,
|
||||
s.type,
|
||||
s.base,
|
||||
s.spread,
|
||||
s.unit,
|
||||
step,
|
||||
count,
|
||||
jitter,
|
||||
s.idPrefix,
|
||||
s.source,
|
||||
ANCHOR,
|
||||
s.offsetSec ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function log(label: string, n: number): void {
|
||||
console.log(`${label.padEnd(40)} ${String(n).padStart(9)}`);
|
||||
}
|
||||
|
||||
async function seedMeasurements(days: number): Promise<number> {
|
||||
const recent = Math.min(days, 90);
|
||||
const threeYears = Math.min(days, 3 * 365);
|
||||
const spot: SpotSeries[] = [
|
||||
// Apple Health, the dense tier.
|
||||
{
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 147,
|
||||
base: 62,
|
||||
spread: 25,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 12,
|
||||
base: 45,
|
||||
spread: 20,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
unit: "%",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 24,
|
||||
base: 96,
|
||||
spread: 2,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
unit: "breaths/min",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 24,
|
||||
base: 14,
|
||||
spread: 3,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_SPEED",
|
||||
unit: "m/s",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 12,
|
||||
base: 1.3,
|
||||
spread: 0.2,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_STEP_LENGTH",
|
||||
unit: "m",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 12,
|
||||
base: 0.72,
|
||||
spread: 0.06,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_ASYMMETRY",
|
||||
unit: "%",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 12,
|
||||
base: 3,
|
||||
spread: 2,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_DOUBLE_SUPPORT",
|
||||
unit: "%",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 12,
|
||||
base: 28,
|
||||
spread: 3,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "AUDIO_EXPOSURE_ENV",
|
||||
unit: "dBA",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 24,
|
||||
base: 62,
|
||||
spread: 12,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "RESTING_HEART_RATE",
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 1,
|
||||
offsetSec: 6 * 3600,
|
||||
base: 56,
|
||||
spread: 4,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "TIME_IN_DAYLIGHT",
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 1,
|
||||
offsetSec: 18 * 3600,
|
||||
base: 60,
|
||||
spread: 40,
|
||||
days,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 100,
|
||||
base: 90,
|
||||
spread: 60,
|
||||
days: recent,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
unit: "kcal",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 400,
|
||||
base: 1.2,
|
||||
spread: 0.9,
|
||||
days: recent,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
unit: "m",
|
||||
source: "APPLE_HEALTH",
|
||||
perDay: 100,
|
||||
base: 70,
|
||||
spread: 50,
|
||||
days: recent,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
// Withings: a cuff reading morning and evening, and the scale.
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
unit: "mmHg",
|
||||
source: "WITHINGS",
|
||||
perDay: 2,
|
||||
offsetSec: 6 * 3600,
|
||||
base: 126,
|
||||
spread: 10,
|
||||
days,
|
||||
idPrefix: "wi-",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
unit: "mmHg",
|
||||
source: "WITHINGS",
|
||||
perDay: 2,
|
||||
offsetSec: 6 * 3600,
|
||||
base: 81,
|
||||
spread: 7,
|
||||
days,
|
||||
idPrefix: "wi-",
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
source: "WITHINGS",
|
||||
perDay: 2,
|
||||
offsetSec: 6 * 3600,
|
||||
base: 66,
|
||||
spread: 8,
|
||||
days,
|
||||
idPrefix: "wi-",
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
unit: "kg",
|
||||
source: "WITHINGS",
|
||||
perDay: 1,
|
||||
offsetSec: 5 * 3600,
|
||||
base: 82,
|
||||
spread: 1.5,
|
||||
days,
|
||||
idPrefix: "wi-",
|
||||
},
|
||||
{
|
||||
type: "BODY_FAT",
|
||||
unit: "%",
|
||||
source: "WITHINGS",
|
||||
perDay: 1,
|
||||
offsetSec: 5 * 3600,
|
||||
base: 22,
|
||||
spread: 2,
|
||||
days,
|
||||
idPrefix: "wi-",
|
||||
},
|
||||
{
|
||||
type: "MUSCLE_MASS",
|
||||
unit: "kg",
|
||||
source: "WITHINGS",
|
||||
perDay: 1,
|
||||
offsetSec: 5 * 3600,
|
||||
base: 61,
|
||||
spread: 1,
|
||||
days,
|
||||
idPrefix: "wi-",
|
||||
},
|
||||
// A weekly manual weight beside the scale.
|
||||
{
|
||||
type: "WEIGHT",
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
everyDays: 7,
|
||||
offsetSec: 19 * 3600,
|
||||
base: 82.4,
|
||||
spread: 1.5,
|
||||
days,
|
||||
idPrefix: "man-",
|
||||
},
|
||||
// Oura, nightly, the last three years.
|
||||
{
|
||||
type: "RESTING_HEART_RATE",
|
||||
unit: "bpm",
|
||||
source: "OURA",
|
||||
perDay: 1,
|
||||
offsetSec: 4 * 3600,
|
||||
base: 54,
|
||||
spread: 4,
|
||||
days: threeYears,
|
||||
idPrefix: "oura-",
|
||||
},
|
||||
{
|
||||
type: "HRV_RMSSD",
|
||||
unit: "ms",
|
||||
source: "OURA",
|
||||
perDay: 1,
|
||||
offsetSec: 4 * 3600,
|
||||
base: 48,
|
||||
spread: 15,
|
||||
days: threeYears,
|
||||
idPrefix: "oura-",
|
||||
},
|
||||
// Nightscout CGM, every five minutes, the last 90 days.
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
unit: "mg/dL",
|
||||
source: "NIGHTSCOUT",
|
||||
perDay: 288,
|
||||
base: 105,
|
||||
spread: 30,
|
||||
days: recent,
|
||||
idPrefix: "ns-",
|
||||
},
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
for (const s of spot) {
|
||||
const n = await insertSpot(s);
|
||||
total += n;
|
||||
log(`${s.type} (${s.source})`, n);
|
||||
}
|
||||
|
||||
// One export-estimate `stats:` row per cumulative type and local day, at
|
||||
// local noon — what `flushCumulativeBuckets` in the export import writes —
|
||||
// for every day older than the per-sample window.
|
||||
for (const [type, hk, unit, base] of [
|
||||
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
|
||||
[
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"HKQuantityTypeIdentifierActiveEnergyBurned",
|
||||
"kcal",
|
||||
550,
|
||||
],
|
||||
[
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
"m",
|
||||
6000,
|
||||
],
|
||||
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "flights", 9],
|
||||
] as const) {
|
||||
const n = await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id,
|
||||
aggregation_provenance, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-' || $2 || '-stats-' || d,
|
||||
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
|
||||
(($7::date - d)::timestamp + interval '12 hours')
|
||||
AT TIME ZONE $8 AT TIME ZONE 'UTC',
|
||||
'stats:' || $3 || ':' || to_char($7::date - d, 'YYYY-MM-DD'),
|
||||
'EXPORT_XML_SOURCE_MAX', now(), now()
|
||||
FROM generate_series(91, $6::int) AS d
|
||||
`,
|
||||
USER_ID,
|
||||
type,
|
||||
hk,
|
||||
unit,
|
||||
base,
|
||||
days,
|
||||
ANCHOR,
|
||||
ZONE,
|
||||
);
|
||||
total += n;
|
||||
log(`${type} (stats:)`, n);
|
||||
}
|
||||
|
||||
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
|
||||
// through the stages. Distinct measured_at per segment.
|
||||
const sleep = await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id,
|
||||
sleep_stage, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-sleep-' || d || '-' || s,
|
||||
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
|
||||
(($3::date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
|
||||
AT TIME ZONE $4 AT TIME ZONE 'UTC',
|
||||
'hk-' || md5('sleep' || d || '-' || s),
|
||||
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
|
||||
now(), now()
|
||||
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
|
||||
`,
|
||||
USER_ID,
|
||||
days,
|
||||
ANCHOR,
|
||||
ZONE,
|
||||
);
|
||||
total += sleep;
|
||||
log("SLEEP_DURATION (APPLE_HEALTH)", sleep);
|
||||
return total;
|
||||
}
|
||||
|
||||
async function seedWorkouts(days: number): Promise<number> {
|
||||
// About one workout every 29 hours, rotating through five sports.
|
||||
const count = Math.floor((days * 24) / 29);
|
||||
return prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO workouts
|
||||
(id, user_id, sport_type, started_at, ended_at, duration_sec,
|
||||
total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate,
|
||||
source, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-workout-' || g,
|
||||
$1,
|
||||
(ARRAY['walking','running','cycling','hiking','swimming'])[1 + g % 5],
|
||||
$3::date::timestamp - make_interval(hours => (g * 29)::int) + interval '17 hours',
|
||||
$3::date::timestamp - make_interval(hours => (g * 29)::int) + interval '17 hours'
|
||||
+ make_interval(secs => (1800 + (g * 97) % 3600)::int),
|
||||
(1800 + (g * 97) % 3600)::int,
|
||||
round((250 + (g * 13) % 400)::numeric, 1),
|
||||
round((3000 + (g * 211) % 9000)::numeric, 1),
|
||||
(115 + (g * 7) % 40)::int,
|
||||
(150 + (g * 5) % 35)::int,
|
||||
'APPLE_HEALTH',
|
||||
'hk-workout-' || md5($1 || g::text),
|
||||
now(), now()
|
||||
FROM generate_series(0, $2::int - 1) AS g
|
||||
`,
|
||||
USER_ID,
|
||||
count,
|
||||
ANCHOR,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedMood(days: number): Promise<number> {
|
||||
const span = Math.min(days, 3 * 365);
|
||||
return prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO mood_entries
|
||||
(id, user_id, date, mood, score, mood_a1, stress_a2, energy_a3,
|
||||
source, mood_logged_at, tz, synced_at, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-mood-' || d,
|
||||
$1,
|
||||
to_char($3::date - d, 'YYYY-MM-DD'),
|
||||
(ARRAY['LAUSIG','SCHLECHT','OKAY','GUT','SUPER_GUT'])[1 + (d * 7) % 5],
|
||||
1 + (d * 7) % 5,
|
||||
2 * ((d * 7) % 5) + 1,
|
||||
(d * 3) % 11,
|
||||
(d * 5) % 11,
|
||||
'MANUAL',
|
||||
(($3::date - d)::timestamp + interval '20 hours') AT TIME ZONE $4 AT TIME ZONE 'UTC',
|
||||
$4,
|
||||
now(), now(), now()
|
||||
FROM generate_series(1, $2::int) AS d
|
||||
`,
|
||||
USER_ID,
|
||||
span,
|
||||
ANCHOR,
|
||||
ZONE,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedMedications(days: number): Promise<number> {
|
||||
const span = Math.min(days, 3 * 365);
|
||||
const meds = [
|
||||
{ key: "a", name: "Synthetic A", dose: "5 mg", window: ["08:00", "09:00"] },
|
||||
{
|
||||
key: "b",
|
||||
name: "Synthetic B",
|
||||
dose: "100 mg",
|
||||
window: ["08:00", "09:00"],
|
||||
},
|
||||
{
|
||||
key: "c",
|
||||
name: "Synthetic C",
|
||||
dose: "20 mg",
|
||||
window: ["20:00", "21:00"],
|
||||
},
|
||||
];
|
||||
let events = 0;
|
||||
for (const m of meds) {
|
||||
const medId = `seed-${USER_ID}-med-${m.key}`;
|
||||
await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO medications
|
||||
(id, user_id, name, dose, starts_on, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, ($5::date - $6::int)::timestamp, now(), now())
|
||||
`,
|
||||
medId,
|
||||
USER_ID,
|
||||
m.name,
|
||||
m.dose,
|
||||
ANCHOR,
|
||||
span,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO medication_schedules
|
||||
(id, medication_id, window_start, window_end, schedule_type)
|
||||
VALUES ($1, $2, $3, $4, 'SCHEDULED')
|
||||
`,
|
||||
`${medId}-schedule`,
|
||||
medId,
|
||||
m.window[0],
|
||||
m.window[1],
|
||||
);
|
||||
// One intake event per day; about one in twelve skipped, the rest taken
|
||||
// a few minutes into the window.
|
||||
events += await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO medication_intake_events
|
||||
(id, user_id, medication_id, scheduled_for, taken_at, skipped,
|
||||
attribution_source, source, created_at, updated_at)
|
||||
SELECT
|
||||
$1 || '-intake-' || d,
|
||||
$2,
|
||||
$1,
|
||||
(($3::date - d)::timestamp + $5::time) AT TIME ZONE $6 AT TIME ZONE 'UTC',
|
||||
CASE WHEN d % 12 = 0 THEN NULL
|
||||
ELSE (($3::date - d)::timestamp + $5::time
|
||||
+ make_interval(mins => (d * 7) % 40)) AT TIME ZONE $6 AT TIME ZONE 'UTC'
|
||||
END,
|
||||
d % 12 = 0,
|
||||
'AUTO',
|
||||
'WEB',
|
||||
now(), now()
|
||||
FROM generate_series(1, $4::int) AS d
|
||||
`,
|
||||
medId,
|
||||
USER_ID,
|
||||
ANCHOR,
|
||||
span,
|
||||
m.window[0],
|
||||
ZONE,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const days = Math.round(YEARS * 365);
|
||||
const started = Date.now();
|
||||
|
||||
if (RESET) {
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
|
||||
}
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO users (id, username, timezone, onboarding_completed_at, updated_at)
|
||||
VALUES ($1, $1, $2, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
USER_ID,
|
||||
ZONE,
|
||||
);
|
||||
|
||||
const measurements = await seedMeasurements(days);
|
||||
log("measurements total", measurements);
|
||||
log("workouts", await seedWorkouts(days));
|
||||
log("mood entries", await seedMood(days));
|
||||
log("medication intake events", await seedMedications(days));
|
||||
|
||||
if (ROLLUPS) {
|
||||
const folded = await recomputeUserRollups(USER_ID, {
|
||||
granularities: ALL_GRANULARITIES,
|
||||
statementTimeoutMs: 600_000,
|
||||
});
|
||||
log("rollup rows", folded.rowsUpserted);
|
||||
}
|
||||
|
||||
for (const table of [
|
||||
"measurements",
|
||||
"measurement_rollups",
|
||||
"workouts",
|
||||
"mood_entries",
|
||||
"medication_intake_events",
|
||||
]) {
|
||||
await prisma.$executeRawUnsafe(`ANALYZE ${table}`);
|
||||
}
|
||||
console.log(`done in ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
+263
-128
@@ -2,55 +2,90 @@
|
||||
/**
|
||||
* Client-bundle report + budget gate for the Turbopack build.
|
||||
*
|
||||
* The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op
|
||||
* under Turbopack and the old `.next/analyze/client.json` reader had
|
||||
* nothing to read — so bundle regressions (a statically imported message
|
||||
* catalog, a duplicated recharts chunk group) shipped invisibly. This
|
||||
* script derives the signal straight from the build output instead:
|
||||
* What a reader downloads is a set of chunk files: the shared
|
||||
* `rootMainFiles` from `build-manifest.json` plus the chunks a page's
|
||||
* `*_client-reference-manifest.js` names. This script measures that set per
|
||||
* page and gates it, and separately measures how much distinct client code
|
||||
* the build holds.
|
||||
*
|
||||
* - per-route client-JS totals (gzip) from the Turbopack
|
||||
* `*_client-reference-manifest.js` files plus the shared
|
||||
* `rootMainFiles` baseline from `build-manifest.json`;
|
||||
* - the total gzip weight of every emitted client chunk;
|
||||
* - a recharts-duplication guard: the number of chunks carrying the
|
||||
* recharts library fingerprint must stay exactly 1 (the shared
|
||||
* chart-runtime chunk group);
|
||||
* - a catalog guard: no message-catalog-fingerprinted chunk may be
|
||||
* referenced by any route's client-reference manifest or by the
|
||||
* shared baseline (catalogs load only lazily / via /i18n/<locale>).
|
||||
* METHOD
|
||||
*
|
||||
* First load per page. The union of `rootMainFiles` and the page's
|
||||
* client-reference chunks, deduplicated by content hash (sha-256 of the
|
||||
* file), each file gzipped at level 9 and summed. A file that two
|
||||
* manifests list under different names counts once. Every page in the
|
||||
* build is measured, not a hand-picked few: a heavy import into a page
|
||||
* nobody watched is how Zod sat in fifty metric pages for three months.
|
||||
*
|
||||
* Across pages. p50, p95 and max of the per-page first loads.
|
||||
*
|
||||
* Unique client code. Every emitted chunk is evaluated in a `node:vm`
|
||||
* sandbox with a stub `TURBOPACK` array, which collects the module
|
||||
* factories the chunk registers without running any of them. Each factory
|
||||
* is keyed by its Turbopack module id, which is global to the build, so a
|
||||
* module the bundler copied into forty chunk groups counts once. (Not by
|
||||
* the factory's text: each chunk is minified on its own, so the copies of
|
||||
* one module differ in their mangled local names.) One copy per module,
|
||||
* from the first chunk in file-name order, without the lazy message
|
||||
* catalogs, is concatenated in id order and gzipped once. A file that
|
||||
* registers no factories (the runtime, the polyfills) counts as one unit
|
||||
* by its content hash.
|
||||
*
|
||||
* The old aggregate (every emitted chunk gzipped and summed) is still
|
||||
* printed, for continuity, but no longer gated: it counts a module once
|
||||
* per chunk group it lands in, so it rose every time a page got lighter
|
||||
* by moving code into on-demand chunks.
|
||||
*
|
||||
* GATES (bundle-budget.json)
|
||||
*
|
||||
* - routeBaselineKbGz: measured first load per page; a page fails when it
|
||||
* is more than routeDriftAllowanceKbGz over its baseline, or when it is
|
||||
* a page with no baseline (write one with --write-baselines).
|
||||
* - firstLoadP95KbGz / firstLoadMaxKbGz: caps on the distribution.
|
||||
* - uniqueClientKbGz: cap on the distinct client code.
|
||||
* - maxCatalogKbGz: per lazy message catalog.
|
||||
* - maxRechartsChunks: chunks carrying the Recharts fingerprint (one, the
|
||||
* shared chart-runtime group).
|
||||
* - no message catalog referenced by a page or the shared baseline.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm bundle-report # print the report table
|
||||
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
|
||||
* pnpm bundle-report # print the report
|
||||
* pnpm bundle-check # enforce bundle-budget.json
|
||||
* node scripts/check-bundle-budget.mjs --write-baselines
|
||||
* # rewrite routeBaselineKbGz from
|
||||
* # this build (a reviewed change)
|
||||
* node scripts/check-bundle-budget.mjs --dir <build dir> [--json <file>]
|
||||
*
|
||||
* Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip.
|
||||
*
|
||||
* Per-route the file states a MEASURED baseline and one drift allowance
|
||||
* over it, and a route fails on the delta rather than on a ceiling it is
|
||||
* already touching. Flat ceilings had been re-stated but never re-based, so
|
||||
* three routes ended up sitting within a kilobyte of theirs and any
|
||||
* unrelated dependency bump failed the e2e job on a regression nobody had
|
||||
* made. The delta is printed on every run, so a route that has crept most of
|
||||
* the way through its allowance is readable in the log before it is red.
|
||||
*
|
||||
* The gate still exists to catch step changes (a ~100 KB statically imported
|
||||
* catalog, a ~90 KB duplicate library), which are several times the
|
||||
* allowance. Re-base consciously, in the same PR that pays the cost, with
|
||||
* the reason written into `$comment`.
|
||||
* Numbers are KB gzip. A local arm64 build measures each page about 1.5 KB
|
||||
* lighter than the CI runner; the drift allowance covers that and ordinary
|
||||
* week-to-week movement, while the step changes the gate exists for (a
|
||||
* statically imported catalog, a duplicated chart library, Zod on a page
|
||||
* that had none: 60 to 100 KB each) are several times larger.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const CHECK = process.argv.includes("--check");
|
||||
const BUILD_DIR = join(process.cwd(), ".next");
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (name) => argv.includes(name);
|
||||
const option = (name) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 ? argv[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const CHECK = flag("--check");
|
||||
const WRITE_BASELINES = flag("--write-baselines");
|
||||
const BUILD_DIR = option("--dir") ?? join(process.cwd(), ".next");
|
||||
const JSON_OUT = option("--json");
|
||||
const APP_DIR = join(BUILD_DIR, "server", "app");
|
||||
const BUDGET_PATH = join(process.cwd(), "bundle-budget.json");
|
||||
|
||||
if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
|
||||
console.error(
|
||||
"check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.",
|
||||
`check-bundle-budget: no ${join(BUILD_DIR, "build-manifest.json")} — run \`pnpm build\` first.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -58,30 +93,47 @@ if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
|
||||
/** Recharts library fingerprint (present once in the shared runtime chunk). */
|
||||
const RECHARTS_MARK = "CartesianGrid";
|
||||
/**
|
||||
* Message-catalog fingerprint. The key set is identical across all six
|
||||
* locales, so a KEY-position match ("typeTimeInDaylight" directly followed
|
||||
* by a colon, in either the plain or the JSON.parse-escaped chunk encoding)
|
||||
* marks a bundled catalog. Source modules only ever carry the namespaced
|
||||
* call-site form (`measurements.typeTimeInDaylight`), which matches neither.
|
||||
* Message-catalog fingerprint. The key set is identical across all locales,
|
||||
* so a KEY-position match ("typeTimeInDaylight" directly followed by a colon,
|
||||
* in either the plain or the JSON.parse-escaped chunk encoding) marks a
|
||||
* bundled catalog. Source modules only ever carry the namespaced call-site
|
||||
* form (`measurements.typeTimeInDaylight`), which matches neither.
|
||||
*/
|
||||
const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":'];
|
||||
|
||||
const sizeCache = new Map();
|
||||
const sha = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
const gz = (buf) => gzipSync(buf, { level: 9 }).length;
|
||||
const kb = (bytes) => bytes / 1024;
|
||||
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
const fileCache = new Map();
|
||||
function chunkInfo(file) {
|
||||
if (!sizeCache.has(file)) {
|
||||
if (!fileCache.has(file)) {
|
||||
let info = {
|
||||
hash: `missing:${file}`,
|
||||
raw: 0,
|
||||
gz: 0,
|
||||
recharts: false,
|
||||
catalog: false,
|
||||
};
|
||||
try {
|
||||
const buf = readFileSync(join(BUILD_DIR, file));
|
||||
sizeCache.set(file, {
|
||||
info = {
|
||||
hash: sha(buf),
|
||||
raw: buf.length,
|
||||
gz: gzipSync(buf, { level: 9 }).length,
|
||||
gz: gz(buf),
|
||||
recharts: buf.includes(RECHARTS_MARK),
|
||||
catalog: CATALOG_MARKS.some((m) => buf.includes(m)),
|
||||
});
|
||||
buf,
|
||||
};
|
||||
} catch {
|
||||
sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false });
|
||||
// A manifest naming a file the build did not emit: counted as zero and
|
||||
// reported by its missing hash rather than crashing the report.
|
||||
}
|
||||
fileCache.set(file, info);
|
||||
}
|
||||
return sizeCache.get(file);
|
||||
return fileCache.get(file);
|
||||
}
|
||||
|
||||
function routeChunks(manifestFile) {
|
||||
@@ -97,24 +149,54 @@ function routeChunks(manifestFile) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function findRouteManifests(dir, prefix = "") {
|
||||
function findPageManifests(dir, prefix = "") {
|
||||
let out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`));
|
||||
} else if (entry.name.endsWith("_client-reference-manifest.js")) {
|
||||
out.push({
|
||||
route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`,
|
||||
file: p,
|
||||
});
|
||||
out = out.concat(findPageManifests(p, `${prefix}/${entry.name}`));
|
||||
} else if (entry.name === "page_client-reference-manifest.js") {
|
||||
out.push({ route: `${prefix}/page`, file: p });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const kb = (bytes) => bytes / 1024;
|
||||
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
|
||||
function percentile(sorted, p) {
|
||||
if (sorted.length === 0) return 0;
|
||||
const i = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.ceil((p / 100) * sorted.length) - 1,
|
||||
);
|
||||
return sorted[Math.max(0, i)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `[module id, factory source]` pairs a Turbopack chunk registers,
|
||||
* without running them: `TURBOPACK.push([script, id, factory, id, factory,
|
||||
* …])`. Null when the file is not in that shape.
|
||||
*/
|
||||
function chunkFactories(buf) {
|
||||
const sandbox = { TURBOPACK: [] };
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
try {
|
||||
vm.runInNewContext(buf.toString("utf8"), sandbox, { timeout: 5000 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const modules = [];
|
||||
for (const entry of sandbox.TURBOPACK) {
|
||||
if (!Array.isArray(entry)) continue;
|
||||
for (let i = 1; i < entry.length; i++) {
|
||||
if (typeof entry[i] !== "function") continue;
|
||||
const id = entry[i - 1];
|
||||
if (typeof id !== "number" && typeof id !== "string") continue;
|
||||
modules.push([String(id), entry[i].toString()]);
|
||||
}
|
||||
}
|
||||
return modules.length > 0 ? modules : null;
|
||||
}
|
||||
|
||||
// ── Shared baseline ─────────────────────────────────────────────────────────
|
||||
const buildManifest = JSON.parse(
|
||||
@@ -123,88 +205,135 @@ const buildManifest = JSON.parse(
|
||||
const rootFiles = buildManifest.rootMainFiles ?? [];
|
||||
let rootGz = 0;
|
||||
for (const f of rootFiles) rootGz += chunkInfo(f).gz;
|
||||
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
|
||||
|
||||
// ── Per-route totals ────────────────────────────────────────────────────────
|
||||
const manifests = findRouteManifests(APP_DIR);
|
||||
const routeTotals = new Map();
|
||||
for (const { route, file } of manifests) {
|
||||
let gz = 0;
|
||||
// ── Per-page first load, deduplicated by content hash ──────────────────────
|
||||
const pages = new Map();
|
||||
for (const { route, file } of findPageManifests(APP_DIR)) {
|
||||
const byHash = new Map();
|
||||
let catalogRef = false;
|
||||
for (const chunk of routeChunks(file)) {
|
||||
for (const chunk of [...rootFiles, ...routeChunks(file)]) {
|
||||
const info = chunkInfo(chunk);
|
||||
gz += info.gz;
|
||||
if (info.catalog) catalogRef = true;
|
||||
byHash.set(info.hash, info.gz);
|
||||
if (info.catalog && !rootFiles.includes(chunk)) catalogRef = true;
|
||||
}
|
||||
routeTotals.set(route, { gz: gz + rootGz, catalogRef });
|
||||
let total = 0;
|
||||
for (const size of byHash.values()) total += size;
|
||||
pages.set(route, { gz: total, catalogRef });
|
||||
}
|
||||
const firstLoads = [...pages.values()].map((p) => p.gz).sort((a, b) => a - b);
|
||||
const p50 = percentile(firstLoads, 50);
|
||||
const p95 = percentile(firstLoads, 95);
|
||||
const max = firstLoads[firstLoads.length - 1] ?? 0;
|
||||
|
||||
// ── Whole-build chunk scan ──────────────────────────────────────────────────
|
||||
// ── Whole build: old aggregate, catalogs, recharts, unique modules ─────────
|
||||
const chunkDir = join(BUILD_DIR, "static", "chunks");
|
||||
let totalGz = 0;
|
||||
let summedGz = 0;
|
||||
let rechartsChunks = 0;
|
||||
let catalogChunks = 0;
|
||||
let catalogGz = 0;
|
||||
let largestCatalog = { file: "-", gz: 0 };
|
||||
let largest = { file: "-", gz: 0 };
|
||||
for (const f of readdirSync(chunkDir)) {
|
||||
const uniqueUnits = new Map();
|
||||
for (const f of readdirSync(chunkDir).sort()) {
|
||||
if (!f.endsWith(".js")) continue;
|
||||
const info = chunkInfo(join("static", "chunks", f));
|
||||
totalGz += info.gz;
|
||||
summedGz += info.gz;
|
||||
if (info.recharts) rechartsChunks += 1;
|
||||
if (info.catalog) {
|
||||
catalogChunks += 1;
|
||||
catalogGz += info.gz;
|
||||
if (info.gz > largestCatalog.gz) largestCatalog = { file: f, gz: info.gz };
|
||||
continue;
|
||||
}
|
||||
const modules = info.buf ? chunkFactories(info.buf) : null;
|
||||
if (modules) {
|
||||
for (const [id, source] of modules) {
|
||||
if (!uniqueUnits.has(`m:${id}`)) uniqueUnits.set(`m:${id}`, source);
|
||||
}
|
||||
} else if (info.buf) {
|
||||
uniqueUnits.set(`f:${info.hash}`, info.buf.toString("utf8"));
|
||||
}
|
||||
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
|
||||
}
|
||||
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
|
||||
const uniqueKeys = [...uniqueUnits.keys()].sort();
|
||||
const uniqueGz = gz(
|
||||
Buffer.from(uniqueKeys.map((k) => uniqueUnits.get(k)).join("\n"), "utf8"),
|
||||
);
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
const budget = existsSync(BUDGET_PATH)
|
||||
? JSON.parse(readFileSync(BUDGET_PATH, "utf8"))
|
||||
: null;
|
||||
const baselines = budget?.routeBaselineKbGz ?? {};
|
||||
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
|
||||
|
||||
console.log(`pages measured: ${pages.size}`);
|
||||
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
|
||||
console.log(`all client chunks: ${fmt(totalGz)} gz`);
|
||||
console.log(
|
||||
` lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks)`,
|
||||
);
|
||||
console.log(` other client chunks: ${fmt(totalGz - catalogGz)} gz`);
|
||||
console.log(
|
||||
` largest message catalog: ${fmt(largestCatalog.gz)} gz (${largestCatalog.file})`,
|
||||
`first load across pages: p50 ${fmt(p50)}, p95 ${fmt(p95)}, max ${fmt(max)} gz`,
|
||||
);
|
||||
console.log(
|
||||
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
|
||||
`unique client code: ${fmt(uniqueGz)} gz (${uniqueKeys.length} distinct modules, catalogs excluded)`,
|
||||
);
|
||||
console.log(
|
||||
`lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks, largest ${fmt(largestCatalog.gz)} ${largestCatalog.file})`,
|
||||
);
|
||||
console.log(
|
||||
`summed chunks, not gated: ${fmt(summedGz - catalogGz)} gz (every emitted chunk once, catalogs excluded)`,
|
||||
);
|
||||
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
|
||||
console.log("");
|
||||
|
||||
const baselines = budget?.routeBaselineKbGz ?? {};
|
||||
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
|
||||
/** The ceiling a route's measured baseline plus the drift allowance buys it. */
|
||||
const capFor = (route) =>
|
||||
baselines[route] == null ? null : baselines[route] + allowanceKb;
|
||||
|
||||
const watched = Object.keys(baselines);
|
||||
const rows = [...routeTotals.entries()]
|
||||
.filter(([route]) => !watched.length || watched.includes(route))
|
||||
.sort((a, b) => b[1].gz - a[1].gz);
|
||||
for (const [route, { gz }] of rows.slice(0, watched.length || 15)) {
|
||||
const baseline = baselines[route];
|
||||
if (baseline == null) {
|
||||
console.log(`${fmt(gz).padStart(8)} gz ${route}`);
|
||||
continue;
|
||||
}
|
||||
const delta = kb(gz) - baseline;
|
||||
const sign = delta >= 0 ? "+" : "−";
|
||||
const rows = [...pages.entries()]
|
||||
.map(([route, { gz: size }]) => {
|
||||
const baseline = baselines[route];
|
||||
return {
|
||||
route,
|
||||
kb: round1(kb(size)),
|
||||
baseline: baseline ?? null,
|
||||
delta: baseline == null ? null : round1(kb(size) - baseline),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.delta ?? Infinity) - (a.delta ?? Infinity) || b.kb - a.kb);
|
||||
console.log("largest movers against their baseline:");
|
||||
for (const r of rows.slice(0, 15)) {
|
||||
const delta =
|
||||
r.delta == null
|
||||
? "no baseline"
|
||||
: `${r.delta >= 0 ? "+" : "−"}${Math.abs(r.delta).toFixed(1)} KB of ${allowanceKb}`;
|
||||
console.log(
|
||||
`${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` +
|
||||
`${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` +
|
||||
`cap ${capFor(route)} KB)`,
|
||||
`${`${r.kb.toFixed(0)} KB`.padStart(8)} gz ${r.route} (${delta})`,
|
||||
);
|
||||
}
|
||||
|
||||
const report = {
|
||||
pages: Object.fromEntries(rows.map((r) => [r.route, r.kb])),
|
||||
firstLoadKbGz: {
|
||||
p50: round1(kb(p50)),
|
||||
p95: round1(kb(p95)),
|
||||
max: round1(kb(max)),
|
||||
},
|
||||
uniqueClientKbGz: round1(kb(uniqueGz)),
|
||||
summedChunksKbGz: round1(kb(summedGz - catalogGz)),
|
||||
catalogKbGz: round1(kb(catalogGz)),
|
||||
largestCatalogKbGz: round1(kb(largestCatalog.gz)),
|
||||
rechartsChunks,
|
||||
};
|
||||
if (JSON_OUT) writeFileSync(JSON_OUT, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (WRITE_BASELINES) {
|
||||
if (!budget) {
|
||||
console.error("check-bundle-budget: bundle-budget.json missing.");
|
||||
process.exit(2);
|
||||
}
|
||||
budget.routeBaselineKbGz = Object.fromEntries(
|
||||
rows
|
||||
.map((r) => [r.route, Math.round(r.kb)])
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
writeFileSync(BUDGET_PATH, `${JSON.stringify(budget, null, 2)}\n`);
|
||||
console.log(`\nwrote ${rows.length} page baselines to bundle-budget.json`);
|
||||
}
|
||||
|
||||
if (!CHECK) process.exit(0);
|
||||
|
||||
// ── Budget gate ─────────────────────────────────────────────────────────────
|
||||
@@ -212,42 +341,48 @@ if (!budget) {
|
||||
console.error("check-bundle-budget: bundle-budget.json missing.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// A budget file that states no baselines gates nothing, and would do it
|
||||
// quietly — the loop below would simply have nothing to walk. Say so instead.
|
||||
if (watched.length === 0) {
|
||||
// A budget that states no page baselines gates nothing, and would do it
|
||||
// quietly. A build with no pages is a broken build, not a light one.
|
||||
if (Object.keys(baselines).length === 0 || pages.size === 0) {
|
||||
console.error(
|
||||
"check-bundle-budget: bundle-budget.json states no routeBaselineKbGz " +
|
||||
"entries, so no route is gated. Measure the eager routes and write " +
|
||||
"them in, with a routeDriftAllowanceKbGz over them.",
|
||||
"check-bundle-budget: no page baselines in bundle-budget.json, or no " +
|
||||
"pages in the build. Measure them with --write-baselines.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const [route, baselineKb] of Object.entries(baselines)) {
|
||||
const actual = routeTotals.get(route);
|
||||
if (!actual) {
|
||||
failures.push(`route ${route} not found in the build output`);
|
||||
continue;
|
||||
}
|
||||
const delta = kb(actual.gz) - baselineKb;
|
||||
if (delta > allowanceKb) {
|
||||
for (const r of rows) {
|
||||
if (r.baseline == null) {
|
||||
failures.push(
|
||||
`${route}: ${fmt(actual.gz)} gz is ${delta.toFixed(1)} KB over the ` +
|
||||
`${baselineKb} KB baseline, past the ${allowanceKb} KB drift ` +
|
||||
`allowance (cap ${capFor(route)} KB)`,
|
||||
`${r.route}: ${r.kb} KB gz and no baseline. A new page gets one with --write-baselines in the same change.`,
|
||||
);
|
||||
} else if (r.delta > allowanceKb) {
|
||||
failures.push(
|
||||
`${r.route}: ${r.kb} KB gz is ${r.delta.toFixed(1)} KB over its ${r.baseline} KB baseline, past the ${allowanceKb} KB drift allowance`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The aggregate leaves the lazy message catalogs out. A reader downloads one
|
||||
// catalog, the one for their language, so counting all of them made every new
|
||||
// interface language look like a regression. Each catalog is held to its own
|
||||
// ceiling instead.
|
||||
const nonCatalogGz = totalGz - catalogGz;
|
||||
if (budget.totalClientKbGz && kb(nonCatalogGz) > budget.totalClientKbGz) {
|
||||
for (const route of Object.keys(baselines)) {
|
||||
if (!pages.has(route)) {
|
||||
failures.push(
|
||||
`${route} has a baseline but is not in the build; remove it with --write-baselines`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (budget.firstLoadP95KbGz && kb(p95) > budget.firstLoadP95KbGz) {
|
||||
failures.push(
|
||||
`client JS without the lazy catalogs ${fmt(nonCatalogGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
|
||||
`first-load p95 ${kb(p95).toFixed(1)} KB gz exceeds the ${budget.firstLoadP95KbGz} KB budget`,
|
||||
);
|
||||
}
|
||||
if (budget.firstLoadMaxKbGz && kb(max) > budget.firstLoadMaxKbGz) {
|
||||
failures.push(
|
||||
`largest first load ${kb(max).toFixed(1)} KB gz exceeds the ${budget.firstLoadMaxKbGz} KB budget`,
|
||||
);
|
||||
}
|
||||
if (budget.uniqueClientKbGz && kb(uniqueGz) > budget.uniqueClientKbGz) {
|
||||
failures.push(
|
||||
`unique client code ${kb(uniqueGz).toFixed(1)} KB gz exceeds the ${budget.uniqueClientKbGz} KB budget`,
|
||||
);
|
||||
}
|
||||
if (budget.maxCatalogKbGz && kb(largestCatalog.gz) > budget.maxCatalogKbGz) {
|
||||
@@ -263,12 +398,12 @@ if (
|
||||
`${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`,
|
||||
);
|
||||
}
|
||||
const catalogRefRoutes = [...routeTotals.entries()]
|
||||
const catalogRefPages = [...pages.entries()]
|
||||
.filter(([, v]) => v.catalogRef)
|
||||
.map(([r]) => r);
|
||||
if (catalogRefRoutes.length > 0 || rootCatalogRef) {
|
||||
if (catalogRefPages.length > 0 || rootCatalogRef) {
|
||||
failures.push(
|
||||
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
|
||||
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefPages.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Seed one account shaped like a multi-year Apple Health `export.xml` import,
|
||||
* for reproducing the maintenance jobs at the size a real heavy account
|
||||
* reaches (issue #1031: about 1.26 million measurement rows).
|
||||
*
|
||||
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/seed-large-account.ts \
|
||||
* [--user bench-large] [--years 4] [--pulse-seconds 165]
|
||||
*
|
||||
* Never point this at a database that holds real data: it inserts rows under
|
||||
* the given user id and, with `--reset`, deletes that user first.
|
||||
*
|
||||
* The shape follows what the import and the iOS sync actually write:
|
||||
*
|
||||
* - dense spot rows (heart rate every `--pulse-seconds`, HRV, SpO2) keyed by
|
||||
* a hashed sample id, the tier the dense-intraday retention folds;
|
||||
* - high-frequency mean rows (respiratory rate, gait metrics, audio
|
||||
* exposure), the tier the daily-mean consolidation folds;
|
||||
* - one `stats:` daily row per cumulative type and day (what the export
|
||||
* import writes for steps, energy, distance and flights);
|
||||
* - per-sample cumulative rows with opaque sample ids for the most recent
|
||||
* 90 days (what the iOS sync writes, and what the cumulative drain folds);
|
||||
* - sleep-stage segments every night.
|
||||
*
|
||||
* Everything is generated inside Postgres with `generate_series`, so a million
|
||||
* rows take seconds rather than a million round trips. `ANALYZE` runs at the
|
||||
* end: a planner reading pre-seed statistics picks plans no production table
|
||||
* of this size would get.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function arg(name: string, fallback: string): string {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
|
||||
}
|
||||
|
||||
const USER_ID = arg("user", "bench-large");
|
||||
const YEARS = Number(arg("years", "4"));
|
||||
const PULSE_SECONDS = Number(arg("pulse-seconds", "165"));
|
||||
const RESET = process.argv.includes("--reset");
|
||||
|
||||
/** A spot series: `perDay` evenly spaced rows per day over the whole span. */
|
||||
interface SpotSeries {
|
||||
type: string;
|
||||
unit: string;
|
||||
perDay: number;
|
||||
base: number;
|
||||
spread: number;
|
||||
/** Days back from now the series covers. Defaults to the whole span. */
|
||||
days?: number;
|
||||
/** externalId prefix; `stats:` rows are generated separately. */
|
||||
idPrefix: string;
|
||||
}
|
||||
|
||||
async function insertSpot(s: SpotSeries, days: number): Promise<number> {
|
||||
const step = Math.floor(86_400 / s.perDay);
|
||||
const count = Math.floor((days * 86_400) / step);
|
||||
// Jitter stays below the step so every measured_at is distinct per type
|
||||
// (the (user, type, measured_at, source, sleep_stage) unique index).
|
||||
const jitter = Math.max(1, Math.min(59, step - 1));
|
||||
return prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-' || $2 || '-' || g,
|
||||
$1,
|
||||
$2::measurement_type,
|
||||
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
|
||||
$5,
|
||||
'APPLE_HEALTH',
|
||||
date_trunc('minute', now()) - make_interval(secs => $6 * $7::bigint)
|
||||
+ make_interval(secs => g * $6 + (g * 31) % $8),
|
||||
$9 || md5($2 || g::text),
|
||||
now(), now()
|
||||
FROM generate_series(0, $7::bigint - 1) AS g
|
||||
`,
|
||||
USER_ID,
|
||||
s.type,
|
||||
s.base,
|
||||
s.spread,
|
||||
s.unit,
|
||||
step,
|
||||
count,
|
||||
jitter,
|
||||
s.idPrefix,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const days = Math.round(YEARS * 365);
|
||||
|
||||
if (RESET) {
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
|
||||
}
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO users (id, username, timezone, updated_at)
|
||||
VALUES ($1, $1, 'Europe/Berlin', now())
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
const pulsePerDay = Math.floor(86_400 / PULSE_SECONDS);
|
||||
const spot: SpotSeries[] = [
|
||||
{
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
perDay: pulsePerDay,
|
||||
base: 62,
|
||||
spread: 25,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
unit: "ms",
|
||||
perDay: 12,
|
||||
base: 45,
|
||||
spread: 20,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
unit: "%",
|
||||
perDay: 24,
|
||||
base: 96,
|
||||
spread: 2,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
unit: "breaths/min",
|
||||
perDay: 48,
|
||||
base: 14,
|
||||
spread: 3,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_SPEED",
|
||||
unit: "km/h",
|
||||
perDay: 30,
|
||||
base: 4.8,
|
||||
spread: 0.8,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_STEP_LENGTH",
|
||||
unit: "cm",
|
||||
perDay: 30,
|
||||
base: 72,
|
||||
spread: 6,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_ASYMMETRY",
|
||||
unit: "%",
|
||||
perDay: 30,
|
||||
base: 3,
|
||||
spread: 2,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_DOUBLE_SUPPORT",
|
||||
unit: "%",
|
||||
perDay: 30,
|
||||
base: 28,
|
||||
spread: 3,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "AUDIO_EXPOSURE_ENV",
|
||||
unit: "dB",
|
||||
perDay: 60,
|
||||
base: 62,
|
||||
spread: 12,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
unit: "steps",
|
||||
perDay: 100,
|
||||
base: 90,
|
||||
spread: 60,
|
||||
days: 90,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
unit: "kcal",
|
||||
perDay: 400,
|
||||
base: 1.2,
|
||||
spread: 0.9,
|
||||
days: 90,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
unit: "m",
|
||||
perDay: 100,
|
||||
base: 70,
|
||||
spread: 50,
|
||||
days: 90,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
unit: "kg",
|
||||
perDay: 1,
|
||||
base: 82,
|
||||
spread: 1.5,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
for (const s of spot) {
|
||||
const n = await insertSpot(s, s.days ?? days);
|
||||
total += n;
|
||||
console.log(`${s.type.padEnd(28)} ${String(n).padStart(9)}`);
|
||||
}
|
||||
|
||||
// One export-estimate `stats:` row per cumulative type and local day, at
|
||||
// local noon — what `flushCumulativeBuckets` in the export import writes.
|
||||
for (const [type, hk, unit, base] of [
|
||||
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
|
||||
[
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"HKQuantityTypeIdentifierActiveEnergyBurned",
|
||||
"kcal",
|
||||
550,
|
||||
],
|
||||
[
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
"m",
|
||||
6000,
|
||||
],
|
||||
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "count", 9],
|
||||
] as const) {
|
||||
const n = await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id,
|
||||
aggregation_provenance, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-' || $2 || '-stats-' || d,
|
||||
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
|
||||
((current_date - d)::timestamp + interval '12 hours')
|
||||
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
|
||||
'stats:' || $3 || ':' || to_char(current_date - d, 'YYYY-MM-DD'),
|
||||
'EXPORT_XML_SOURCE_MAX', now(), now()
|
||||
FROM generate_series(91, $6::int) AS d
|
||||
`,
|
||||
USER_ID,
|
||||
type,
|
||||
hk,
|
||||
unit,
|
||||
base,
|
||||
days,
|
||||
);
|
||||
total += n;
|
||||
console.log(`${`${type} (stats:)`.padEnd(28)} ${String(n).padStart(9)}`);
|
||||
}
|
||||
|
||||
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
|
||||
// through the stages. Distinct measured_at per segment.
|
||||
const sleep = await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id,
|
||||
sleep_stage, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-sleep-' || d || '-' || s,
|
||||
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
|
||||
((current_date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
|
||||
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
|
||||
'hk-' || md5('sleep' || d || '-' || s),
|
||||
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
|
||||
now(), now()
|
||||
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
|
||||
`,
|
||||
USER_ID,
|
||||
days,
|
||||
);
|
||||
total += sleep;
|
||||
console.log(`${"SLEEP_DURATION".padEnd(28)} ${String(sleep).padStart(9)}`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
console.log(`${"total".padEnd(28)} ${String(total).padStart(9)}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in new issue
Block a user