Merge branch 'release/v1.45.0' into feat/v145-testkit

# Conflicts:
#	src/app/api/tokens/__tests__/route.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 06:08:47 +02:00
commit ae2636c9e5
84 files changed
+9286 -532

No files matched your search

+1
View File
@@ -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.
+254
View File
@@ -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
}
]
}
+25
View File
@@ -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 |
+478
View File
@@ -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();
});
+659
View File
@@ -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
View File
@@ -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)`,
);
}
-296
View File
@@ -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());