From ab73b5ba084c01eaa76a55a3fca6b3d79877ab3c Mon Sep 17 00:00:00 2001 From: Will Manning Date: Wed, 28 Oct 2026 21:32:03 +0100 Subject: [PATCH 01/34] Plan bench's upgrade onto the site's toolchain Co-Authored-By: Claude Opus 5.5 Signed-off-by: Will Manning --- .../plans/2026-10-28-toolchain-and-cleanup.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 bench/docs/plans/2026-10-28-toolchain-and-cleanup.md diff --git a/bench/docs/plans/2026-10-28-toolchain-and-cleanup.md b/bench/docs/plans/2026-10-28-toolchain-and-cleanup.md new file mode 100644 index 0000000..2db63f0 --- /dev/null +++ b/bench/docs/plans/2026-10-28-toolchain-and-cleanup.md @@ -0,0 +1,226 @@ + + +# bench: upgrade, share the site's toolchain, clean up + +Status: design, 2026-10-28. Step 1 of 3. + +## Why + +bench (`bench/web`) came from the archived `vortex-data/benchmarks-website` +repository. It runs on a second toolchain beside the marketing site: pnpm, +ESLint, Prettier, TypeScript 5.9 and Next.js 15.5, against the site's Bun, +Biome, TypeScript 7 and Next.js 16. Its packages are behind, and two of its +largest files (`components/Chart.tsx`, 2,200 lines; `lib/chart-store.ts`, 632 +lines) are hard to change safely. + +The goal is maintainability: one toolchain and one set of supply-chain rules +for both apps, current dependencies, and smaller, clearer code. The two apps +keep separate deployments. + +This is the first of three steps: + +1. **This document.** Upgrade and clean up bench with no change in behavior. +2. A new landing page: one page, every group folded with its headline numbers, + and opening a group loads its charts in place. +3. One visual redesign across vortex.dev, bench, explore and docs. Open PR + vortex-data/vortex.dev#120 is held for that step. + +## Goals + +- bench and the site share one Bun workspace, one lockfile, one Biome config + and one TypeScript base config. +- bench is on Next.js 16, React 19.3, TypeScript 7 and current versions of its + other packages, inside the 14-day cooldown. +- The code is smaller and clearer where that is cheap: dead paths removed, the + two largest client files split into modules, and a group's charts fetched + with one query instead of one per chart. +- Behavior is exactly the same, and each PR proves it. + +## Non-goals + +- Any change a visitor can see. The landing page is step 2; the visual + redesign is step 3. +- Replacing Vitest. It stays: its 8,000 lines of tests are the main evidence + that behavior did not change. +- Porting `bench/scripts/migrate-schema.py` to Bun. That comes later and is + not urgent. +- Database schema, ingest contract (`bench/CONTRACT.md`) or AWS changes. +- Merging the two Vercel projects or changing either app's domain. + +## Anti-goals + +The work fails, even if it builds, if any of these happen: + +- A page, API response, response header, cache rule or URL changes. +- The supply-chain gates get weaker: the 14-day cooldown, explicit overrides, + the lifecycle-script allowlist, SHA-pinned actions, `ubuntu-24.04` runners. +- The marketing site's build, behavior or deploy changes, beyond installing + from a shared lockfile. +- The work lands as a long stack of small PRs. Will smoke-tests each PR by hand + in its Vercel preview, so fewer, larger PRs are better. + +## Constraints + +- **Order.** vortex-data/vortex.dev#124 (database pool lifecycle) merged on + 2026-10-28; build on it. PR 1 deletes `bench/web/pnpm-workspace.yaml`, so its + exact-version cooldown exemptions go with it. A version still inside the + window then needs a `bunfig.toml` exemption, with a removal date. +- **Supply chain.** CLAUDE.md "Supply chain hardening" is the policy. Bun + enforces the cooldown through `bunfig.toml`, so the pnpm settings move as + follows: + - The `overrides` in `bench/web/pnpm-workspace.yaml` (postcss, sharp and + zod) move to the root `package.json` `overrides`. Each entry keeps its + advisory and removal trigger. Drop postcss if the version Next.js 16 pins + is already patched. Bun applies overrides to the whole workspace, so the + zod override (`^4.6.7`, for `@vercel/functions` > `@vercel/oidc` > + `@vercel/cli-config`, which pins 4.1.11) also moves Lighthouse CI's + `chromium-bidi` from zod 3 to zod 4. That reverses a CLAUDE.md decision on + purpose: the Lighthouse job must still pass, and it also clears the site's + accepted zod advisory. If Lighthouse CI breaks, drop the override and + record bench's zod 4.1.11 as an accepted advisory instead (it parses only + local Vercel CLI config files). + - GitHub's dependency graph doesn't read `bun.lock`. Once bench leaves + `pnpm-lock.yaml`, `dependency-review` would no longer see its transitive + packages; the site already has that gap. A workflow submits a dependency + snapshot built from `bun.lock` to the Dependency Submission API on pushes + to `main` and on pull requests, so `dependency-review` sees every resolved + package for both apps. It must run before `dependency-review` reads the + pull request's dependencies. + - `allowBuilds` moves to `trustedDependencies`. Today only `sharp` and + `unrs-resolver` may build, and `unrs-resolver` goes away with ESLint. + - The bench parts of CLAUDE.md ("bench/ (pnpm)", "bench advisories") are + rewritten to match. +- **Required checks.** The `main` ruleset requires a check named + `Bench: format, lint, build, test`. Keep that job name. Renaming it needs a + ruleset change. +- **Settings changes need Will's confirmation first.** That covers GitHub, + Vercel and AWS settings. The `benchmarks-website` Vercel project builds on + the GitHub runner (`vercel build` in `.github/workflows/bench-deploy.yml`), + so the install command probably changes only in the workflow. If a project + setting must change, ask first. +- **The site's Vercel project** (`vortex`) builds through the git + integration. Adding a workspace must not make it build bench, and must not + noticeably slow its install. `scripts/vercel-ignore-build.sh` keeps skipping + bench-only commits. +- **Node 24** stays the runtime (CLAUDE.md "Node version"). +- **DCO sign-off** on every commit. Squash merges only. +- **Licensing.** New bench files carry SPDX headers: Apache-2.0 for code, + CC-BY-4.0 for Markdown. + +## Decided by Will + +- Share the toolchain and code, and keep separate deployments. +- Fewer, larger PRs, each smoke-tested in its Vercel preview. +- Keep Vitest for now. +- The Python migration runner moves to Bun eventually, not in this work. +- Close the `bun.lock` gap in dependency review with a submitted dependency + snapshot. +- Force zod 4 across the workspace and let Lighthouse CI prove it still works. + +## The work: two PRs + +### PR 1: upgrade bench and move it onto the site's toolchain + +Mechanical changes only. Commits are ordered so a reviewer can read each one +alone. + +1. **Parity check first.** Add the parity script (see "Proving nothing + changed") and run it with `main` on both sides, to show it reports no + difference. Later commits must keep it clean. +2. **Bun workspace.** The root `package.json` lists `bench/web` as a workspace. + One `bun.lock` replaces `bench/web/pnpm-lock.yaml`. `pnpm-workspace.yaml` + goes, and its settings move as described under Constraints. + `.github/workflows/bench-ci.yml`, `bench-deploy.yml`, the + `.github/actions/bench-checks` composite and Renovate's bench rule switch + from pnpm to Bun. The dependency-snapshot workflow lands in the same + commit, so `dependency-review` never loses sight of bench's packages. +3. **Output file tracing.** `bench/web/next.config.js` sets + `outputFileTracingRoot: __dirname`. Bun hoists workspace packages to the + root `node_modules`, so tracing must start at the repository root, or the + deployed functions can miss dependencies. Check the traced files of a + preview function, not only the build log. +4. **Next.js 16 and React 19.3**, with the other dependency updates + (`pg`, `chart.js`, `jsdom` 30, `@types/*`). Read the Next.js 16 upgrade + guide against bench's use of `unstable_cache`, `revalidateTag`, + `force-dynamic` pages, route handlers and `vercel.json` headers. Turbopack + becomes the default builder. +5. **Biome replaces ESLint and Prettier.** One root `biome.json` covers both + apps: remove the `!bench` exclusion. An `overrides` entry for `bench/**` + keeps bench's current Prettier style (single quotes, trailing commas, + 100 columns), so the switch reformats almost nothing. One style for both + apps can come later; `main` takes squash merges only, so a mass reformat + can't be hidden from `git blame` without also hiding the real changes in + the same squashed commit. ESLint's `next/core-web-vitals` rules have no + Biome equivalent; the site already accepts that. Map any rule bench relies + on, such as the React hooks rules, to its Biome counterpart. +6. **TypeScript 7.** bench's `tsconfig.json` extends a base shared with the + site and keeps its current strictness. Stricter flags wait for PR 2. + +### PR 2: clean up bench's code + +Logic stays the same. The parity check and the tests prove it. + +- **Remove the IAM sign-in path.** Production signs in with the `bench_read` + password; IAM auth is not available for that role as shipped (see + `bench/web/README.md`). Remove `@aws-sdk/rds-signer`, `BENCH_DB_REGION` and + the signer code, and make `BENCH_DB_PASSWORD` required. Update the README + and runbooks. +- **One query per group.** `collectGroupCharts` now runs two queries for each + chart (a seed window and the data), about 200 for a TPC-DS group of 99 + charts. Fetch a group's rows in one or two queries, keeping each chart's own + last-N-commits window (for example, with a window function partitioned by + chart). The JSON must stay byte-identical. This removes the fan-out that + `QUERY_CONCURRENCY` and the pool-wait discussion on #124 exist for. Keep the + concurrency limit unless the new code no longer needs it. +- **Split `components/Chart.tsx` and `lib/chart-store.ts`** into smaller + modules along their existing seams (fetching, formatting, zoom, the loading + queues). Move code; don't rewrite it. +- **Adopt the site's stricter TypeScript flags** + (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), with the code + changes they need. +- Remove dead code that the stricter flags and Biome expose. + +## Proving nothing changed + +Each PR passes these checks, in order: + +1. **CI**: `Bench: format, lint, build, test`, including the Postgres + integration suite. +2. **Parity, run locally.** Restore a production `pg_dump` into a local + Postgres container. Build `main` and the PR branch, serve both with + `next start` on two ports against that one database, and compare: + - JSON from `/api/groups`, `/api/group/{slug}` for every group at `n=100` + and one other window, and `/api/chart/{slug}` for every chart at `n=100` + and a sample at `n=all`. The bodies must match exactly. + - The response headers the app sets: status, `content-type`, + `cache-control`. + - The `/` and `/chart/{slug}` pages: screenshots after the charts load, at + desktop and phone widths, compared pixel by pixel with Playwright (already + a dev dependency of the site). + + Comparing against production or a Vercel preview doesn't work: the Data + Cache (`unstable_cache`, with a 24-hour backstop, flushed only by + production's `/api/revalidate`) makes a preview's cached routes lag + production after any ingest, and a shared preview cache could serve + payloads built by older code. Two local servers start with empty caches + and read the same frozen data, so any difference comes from the code. + This needs no token and no Vercel setting. Will's Mac holds a dump from + 2026-10-28 in `~/Backups/vortex-bench/`. +3. **The deployed preview.** `bench-deploy.yml` already runs + `bench/scripts/verify-web-deploy.mjs` against each preview's public read + paths. It catches what only a Vercel build shows, such as file tracing. + `vercel.json`, which carries the CDN cache headers, must not change. +4. **Will's smoke test** in the preview. + +## Left to the builder + +- The parity script's exact sample, and how it waits for charts before a + screenshot. +- The Biome rule mapping, and how to apply the site's style. +- Whether TypeScript 7 or Next.js 16 needs code changes, and what they are. +- The module boundaries for the `Chart.tsx` and `chart-store.ts` split. +- The SQL for the group query, as long as the output is byte-identical. +- Whether to install the whole workspace or only one app in each workflow. From 45e667a8680b22a5ff5e95b658ef13b4f84c7de7 Mon Sep 17 00:00:00 2001 From: Will Manning Date: Wed, 28 Oct 2026 21:37:08 +0100 Subject: [PATCH 02/34] Plan bench PR 1: the toolchain move Co-Authored-By: Claude Opus 5.5 Signed-off-by: Will Manning --- .../plans/2026-10-28-toolchain-pr1-plan.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 bench/docs/plans/2026-10-28-toolchain-pr1-plan.md diff --git a/bench/docs/plans/2026-10-28-toolchain-pr1-plan.md b/bench/docs/plans/2026-10-28-toolchain-pr1-plan.md new file mode 100644 index 0000000..bbd07e4 --- /dev/null +++ b/bench/docs/plans/2026-10-28-toolchain-pr1-plan.md @@ -0,0 +1,239 @@ + + +# bench PR 1: upgrade and move onto the site's toolchain — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** bench builds, tests, lints and deploys with the site's Bun, Biome and TypeScript 7 toolchain on Next.js 16, and nothing a visitor or API client sees changes. + +**Architecture:** The repository root becomes a Bun workspace whose members are the site (root) and `bench/web`, with one `bun.lock`. A local parity harness serves `main` and the branch side by side against a restored production dump and diffs every route and screenshot. A submitted dependency snapshot keeps `dependency-review` able to see every package in `bun.lock`. + +**Tech Stack:** Bun 1.4.3, Next.js 16.4.1, React 19.3.1, TypeScript 7, Biome 2.5, Vitest 4 (stays), Playwright 1.64, Postgres 16 in Docker, GitHub Actions. + +**Spec:** `bench/docs/plans/2026-10-28-toolchain-and-cleanup.md` (read it first; this plan covers its "PR 1"). + +## Global Constraints + +- Behavior is exactly the same: pages, API JSON, response headers, cache rules, URLs. `bench/web/vercel.json` headers and crons do not change. +- Cooldown: `bunfig.toml` `minimumReleaseAge = 1209600` applies to every install. A version still inside the window needs a `minimumReleaseAgeExcludes` entry with an advisory reason and a removal date. +- Root `package.json` `overrides`: keep `"sharp": "^0.35.5"`; add `"zod": "^4.6.7"`; add `"postcss": "^8.5.30"` only if the postcss that Next.js 16.4.1 resolves to is below a patched version for GHSA-6g55-p6wh-862q and GHSA-r28c-9q8g-f849. +- `trustedDependencies` stays `["esbuild", "sharp"]` unless `bun pm untrusted` shows a bench package that must build; justify any addition in the commit message. +- The job name `Bench: format, lint, build, test` does not change (the `main` ruleset requires it). +- Actions are SHA-pinned with a `# vX.Y.Z` comment; runners are `ubuntu-24.04`; Node comes from `.nvmrc` (24). Reuse `oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0` with `bun-version: 1.4.3`. +- No GitHub, Vercel or AWS **settings** change without asking Will first. Files in the repository (workflows, `vercel.json`) are fine. +- Vitest stays the bench test runner; the Python migration runner and its uv-based tests stay as they are. +- bench's code style stays: single quotes, trailing commas everywhere, 100 columns, 2-space indent. +- Every commit: `git commit -s`, ending with `Co-Authored-By: Claude Opus 5.5 `. New code files under `bench/` start with `// SPDX-License-Identifier: Apache-2.0` and `// SPDX-FileCopyrightText: Copyright the Vortex contributors`. +- Root `tsconfig.json` and `biome.json` stop excluding `bench/` only where a task says so. + +## Review Focus + +1. **A deployed function misses a hoisted package.** With Bun hoisting `pg` and `@vercel/functions` to the root `node_modules`, a preview whose file tracing starts at `bench/web` returns 500 on every API route. Task 3 asserts the traced files include them. +2. **Ingest stops flushing the cache.** Next.js 16 changes `revalidateTag`; the emitter's `POST /api/revalidate` must still expire cached data at once. Task 4 pins the second argument in a test. +3. **The site's deploy gets slower or breaks.** The `vortex` Vercel project runs `bun install --frozen-lockfile` at the root, which now installs bench too. Task 3 times it and checks the site still builds. +4. **Dependency review passes because it saw nothing.** If the snapshot isn't submitted before `dependency-review` reads the pull request, the check passes blind. Task 2 makes it wait, and Task 7 reads its log. +5. **A root-level change skips bench CI.** `changed-paths` treats `package.json` and `bun.lock` as site-only today; after the move they change bench too. Task 3 reclassifies them and checks sample paths. + +--- + +### Task 1: Parity harness + +**Files:** +- Create: `bench/scripts/parity/run.sh` +- Create: `bench/scripts/parity/compare-api.ts` +- Create: `bench/scripts/parity/compare-api.test.ts` +- Create: `bench/scripts/parity/screenshots.spec.ts` +- Create: `bench/scripts/parity/playwright.config.ts` +- Create: `bench/scripts/parity/README.md` (CC-BY-4.0 header) + +**Interfaces:** +- Produces: `bash bench/scripts/parity/run.sh ` — exits 0 when the two builds match, non-zero with a printed diff otherwise. Base is a git ref; head is the current working tree. +- Produces: `diffJson(base: string, head: string): string | null` and `compareApi(baseUrl: string, headUrl: string): Promise` in `compare-api.ts` (the array holds one line per mismatch). + +- [ ] **Step 1: Write the failing test** in `compare-api.test.ts` (run with `bun test`): + +```ts +import { expect, test } from 'bun:test'; +import { diffJson } from './compare-api'; + +test('identical bodies match', () => { + expect(diffJson('{"a":[1,2]}', '{"a":[1,2]}')).toBeNull(); +}); +test('a changed value is reported with its path', () => { + expect(diffJson('{"a":[1,2]}', '{"a":[1,3]}')).toContain('a[1]'); +}); +test('key order counts, because clients see bytes', () => { + expect(diffJson('{"a":1,"b":2}', '{"b":2,"a":1}')).not.toBeNull(); +}); +``` + +- [ ] **Step 2: Run it:** `bun test bench/scripts/parity/compare-api.test.ts` — expect FAIL (module missing). +- [ ] **Step 3: Implement `diffJson`** (byte comparison first; on mismatch, parse both and return the first differing path) and **`compareApi`**. `compareApi` reads `/api/groups` from the base, then requests from both servers: `/api/groups`; `/api/group/{slug}?n=100` and `?n=50` for every group; `/api/chart/{slug}?n=100` for every chart; `/api/chart/{slug}?n=all` for every 25th chart. It compares status, `content-type`, `cache-control` and the body. +- [ ] **Step 4: Run the test again** — expect PASS. +- [ ] **Step 5: Write `screenshots.spec.ts` and its config.** Pages: `/` and `/chart/{slug}` for five slugs taken from `/api/groups` (the first chart of the first five groups). Projects: desktop 1440×900 and phone 390×844, Chromium only. The config reads `BASE_URL` and writes snapshots under `PARITY_OUT`. The run takes baselines from the base server with `--update-snapshots`, then compares the head server with `maxDiffPixels: 0`. How to wait for charts, and what to mask, are the implementer's call. +- [ ] **Step 6: Write `run.sh`.** + - Start `postgres:16-alpine` (the image the integration suite uses) as container `bench-parity-pg` on port 55432. + - Restore with `pg_restore --no-owner --no-privileges -j 4` from the dump directory, mounted read-only. Skip the restore if the container already holds the `commits` table. + - Export the base with `git archive | tar -x -C "$TMPDIR/bench-parity/base"`; no worktree. + - Build each side with its own toolchain: `pnpm install --frozen-lockfile && pnpm build` when `bench/web/pnpm-lock.yaml` exists, otherwise `bun install --frozen-lockfile` at the root and `bun run --cwd bench/web build`. + - Serve base on 3101 and head on 3102 with `next start`, with `BENCH_DB_HOST=127.0.0.1`, `BENCH_DB_PORT=55432`, `BENCH_DB_NAME=vortex_bench`, `BENCH_DB_USER=postgres`, `BENCH_DB_PASSWORD=parity` and `BENCH_DB_SSL=disable`. + - Run `compareApi`, then the screenshots, and stop the servers on exit (`trap`). +- [ ] **Step 7: Prove it reports no difference on equal code:** `bash bench/scripts/parity/run.sh origin/main ~/Backups/vortex-bench/vortex_bench-20261028T171815Z` from a clean checkout of `main`'s tree — expect exit 0 and `0 mismatches`. +- [ ] **Step 8: Prove it catches a difference:** temporarily change one number in a `/api/groups` response (for example, a summary explanation string in `bench/web/lib/summary.ts`), rerun, expect a non-zero exit naming that path; revert. +- [ ] **Step 9: Commit:** `Add a local parity check for bench builds`. + +### Task 2: Dependency snapshot from `bun.lock` + +**Files:** +- Create: `scripts/dependency-snapshot.ts` +- Create: `scripts/dependency-snapshot.test.ts` +- Create: `.github/workflows/dependency-snapshot.yml` +- Modify: `.github/workflows/ci.yml` (the `dependency-review` job) +- Modify: `CLAUDE.md` (supply-chain layer 5) + +**Interfaces:** +- Produces: `parseBunLock(text: string): BunLock`, `buildSnapshot(lock: BunLock, meta: SnapshotMeta): Snapshot`, with `SnapshotMeta = { sha: string; ref: string; correlator: string; jobId: string; scanned: string }`. `Snapshot` follows GitHub's Dependency Submission API (`version: 0`, `detector`, `manifests[*].resolved[purl] = { package_url, relationship, scope, dependencies }`). + +- [ ] **Step 1: Write the failing tests.** A fixture lock string with trailing commas holds: + - root workspace `vortex`: dependency `a`, devDependency `d`; + - workspace `bench/web`, named `benchmarks-web`: dependency `@s/x`; + - packages: `a@1.0.0` depends on `b` and `c`; nested `a/c@2.0.0`; `d@1.0.0` depends on `b` and `c`; `b@1.0.0`; `c@1.0.0`; `@s/x@1.0.0`. + + Assertions: + +```ts +const snap = buildSnapshot(parseBunLock(FIXTURE), META); +const root = snap.manifests['bun.lock (vortex)'].resolved; +expect(root['pkg:npm/a@1.0.0']).toMatchObject({ relationship: 'direct', scope: 'runtime' }); +expect(root['pkg:npm/b@1.0.0'].scope).toBe('runtime'); // reachable from a +expect(root['pkg:npm/c@2.0.0'].scope).toBe('runtime'); // a resolves the nested a/c +expect(root['pkg:npm/c@1.0.0'].scope).toBe('development'); // only d reaches top-level c +expect(root['pkg:npm/a@1.0.0'].dependencies).toEqual(['pkg:npm/b@1.0.0', 'pkg:npm/c@2.0.0']); +const bench = snap.manifests['bun.lock (bench/web)'].resolved; +expect(bench['pkg:npm/%40s/x@1.0.0']).toMatchObject({ relationship: 'direct', scope: 'runtime' }); +expect(snap.manifests['bun.lock (bench/web)'].file.source_location).toBe('bun.lock'); +``` + +- [ ] **Step 2: Run:** `bun test scripts/dependency-snapshot.test.ts` — expect FAIL. +- [ ] **Step 3: Implement.** + - Parse by removing trailing commas before `}` or `]`, then `JSON.parse`. + - Resolve dependency `N` of package key `K` by trying `K/N`, then `N` under each shorter ancestor of `K`, then `N`. A key is a chain of package names, where a scoped name is two segments. + - Resolve a workspace's own dependency `N` by trying `/N`, then `N`. + - Follow `dependencies`, `optionalDependencies` and `peerDependencies`, and skip names absent from the lock (platform-specific optional packages). + - A package is `runtime` if a workspace's `dependencies` or `optionalDependencies` reach it, and `development` otherwise. It is `direct` if the workspace lists it. + - The CLI (`import.meta.main`) reads `bun.lock` and `POST`s to `/repos/$GITHUB_REPOSITORY/dependency-graph/snapshots`, using `GITHUB_TOKEN`, `SNAPSHOT_SHA` and `SNAPSHOT_REF`. Anything but 201 fails. +- [ ] **Step 4: Run the tests** — expect PASS. Then run against the real lock without submitting (`DRY_RUN=1 bun scripts/dependency-snapshot.ts | head`) and check that `lighthouse`, `puppeteer-core` and `chromium-bidi` appear with scope `development`, and `next` with scope `runtime`. +- [ ] **Step 5: Write the workflow.** + - Triggers: `push` to `main`, and `pull_request` from the same repository only. Fork PRs have no write token. + - `permissions: contents: write`. + - Steps: checkout (`persist-credentials: false`), setup-bun, run the tests, then submit. + - `SNAPSHOT_SHA` and `SNAPSHOT_REF` are `github.sha` and `github.ref` on push, and `github.event.pull_request.head.sha` and `refs/heads/${{ github.head_ref }}` on pull requests. +- [ ] **Step 6: Make `dependency-review` wait for the snapshot:** add `retry-on-snapshot-warnings: true` and `retry-on-snapshot-warnings-timeout: 300` to its `with:`. Leave `fail-on-severity: high` and the default `fail-on-scopes` (runtime) as they are. +- [ ] **Step 7: Update CLAUDE.md** layer 5: dependency review now sees `bun.lock` through the submitted snapshot. It fails on runtime-scope advisories, and dev-only advisories stay in `bun audit` and the "Audit advisories" list. Rewrite the "bench advisories" sentence about pnpm lockfiles to match. +- [ ] **Step 8: Commit:** `Submit bun.lock to the dependency graph`. + +### Task 3: One Bun workspace for the site and bench + +**Files:** +- Modify: `package.json` (`workspaces: ["bench/web"]`, `overrides`), `bun.lock` (regenerated) +- Delete: `bench/web/pnpm-lock.yaml`, `bench/web/pnpm-workspace.yaml` +- Modify: `bench/web/package.json` (drop `packageManager`; scripts call Bun-installed binaries) +- Modify: `bench/web/next.config.js` (`outputFileTracingRoot`) +- Modify: `.github/actions/bench-checks/action.yml`, `.github/workflows/bench-ci.yml`, `.github/workflows/bench-deploy.yml`, `.github/actions/changed-paths/action.yml`, `renovate.json` +- Modify: `CLAUDE.md` ("bench/ (pnpm)" → Bun; "bench advisories"; "Repository layout" toolchain line), `bench/web/README.md`, `bench/AGENTS.md`, `bench/docs/architecture/deploy-and-infra.md` + +**Interfaces:** +- Produces: `bun install --frozen-lockfile` at the root installs both apps; `bun run --cwd bench/web `, { + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, + ...init, + }); +} + +type Override = (request: Request) => Response | undefined | Promise; + +/** + * A fake bench server. It answers `/api/groups` with `groups`; every other URL goes to `override` + * first and, when that returns nothing, gets the answer the app gives: 200 for the data and the + * pages, 404 for the unknown chart, 400 from the API and 404 from the page for the malformed slug. + */ +function api(override?: Override, groups: unknown = GROUPS): Handler { + return async (request) => { + const { pathname } = new URL(request.url); + if (pathname === '/api/groups') { + return json(groups); + } + const answer = await override?.(request); + if (answer !== undefined) { + return answer; + } + const isApi = pathname.startsWith('/api/'); + if (pathname.endsWith(`/${MALFORMED_SLUG}`)) { + return isApi + ? json({ error: 'bad_request' }, { status: 400 }) + : html(NOT_FOUND_TITLE, { status: 404 }); + } + if (pathname.endsWith(`/${UNKNOWN_CHART_SLUG}`)) { + return isApi + ? json({ error: 'not_found' }, { status: 404 }) + : html(NOT_FOUND_TITLE, { status: 404 }); + } + return isApi ? json({ value: 1 }) : html(TITLE); + }; } +/** The data URLs, the two pages and the five error probes planned for {@link GROUPS}. */ +const PLANNED = 5 + 2 + 5; + test('servers that answer alike have no mismatches', async () => { const result = await compareApiDetailed(serve(api()), serve(api())); expect(result.mismatches).toEqual([]); - // /api/groups, two windows for the group, and the chart at n=100 and n=all. - expect(result.requests).toBe(5); + // /api/groups, two windows for the group, the chart at n=100 and n=all, the landing page, the + // chart page, and the five error probes. + expect(result.requests).toBe(PLANNED); }); test('the result tallies the statuses and the body bytes that were compared', async () => { const result = await compareApiDetailed(serve(api()), serve(api())); - expect(result.statuses).toEqual({ '200': 10 }); - const groupsBytes = JSON.stringify(GROUPS).length; - const valueBytes = JSON.stringify({ value: 1 }).length; - expect(result.bytes).toEqual({ - base: groupsBytes + 4 * valueBytes, - head: groupsBytes + 4 * valueBytes, - }); + expect(result.statuses).toEqual({ '200': 14, '404': 6, '400': 4 }); + const length = async (response: Response) => (await response.text()).length; + const perSide = + JSON.stringify(GROUPS).length + + 4 * JSON.stringify({ value: 1 }).length + + 2 * (await length(html(TITLE))) + + 2 * (await length(html(NOT_FOUND_TITLE))) + + JSON.stringify({ error: 'not_found' }).length + + 2 * JSON.stringify({ error: 'bad_request' }).length; + expect(result.bytes).toEqual({ base: perSide, head: perSide }); }); test('a different body, status, content-type and cache-control are each reported', async () => { const base = serve(api()); const head = serve( - api((request) => - new URL(request.url).pathname === '/api/group/g' - ? json({ value: 2 }, { status: 404 }) - : new Response(JSON.stringify({ value: 1 }), { + api((request) => { + const { pathname } = new URL(request.url); + if (pathname === '/api/group/g') { + return json({ value: 2 }, { status: 404 }); + } + return pathname === '/api/chart/c' + ? new Response(JSON.stringify({ value: 1 }), { headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' }, - }), - ), + }) + : undefined; + }), ); const lines = await compareApi(base, head); expect(lines).toContain('/api/group/g?n=100: status 200 != 404'); @@ -153,7 +266,7 @@ test('a 404 on both sides fails, because every planned URL comes from the base', const missing = api((request) => new URL(request.url).pathname === '/api/chart/c' ? json({ error: 'not_found' }, { status: 404 }) - : json({ value: 1 }), + : undefined, ); const result = await compareApiDetailed(serve(missing), serve(missing)); expect(result.mismatches).toContain( @@ -163,7 +276,57 @@ test('a 404 on both sides fails, because every planned URL comes from the base', `/api/chart/c?n=100: head answered 404 instead of 200, ${NOTHING_COMPARED}`, ); expect(result.mismatches.filter((line) => !line.startsWith('/api/chart/c?n='))).toEqual([]); - expect(result.statuses).toEqual({ '200': 6, '404': 4 }); + expect(result.statuses).toEqual({ '200': 10, '404': 10, '400': 4 }); +}); + +test('an expected error status passes, and any other status on that probe fails', async () => { + const page = `/chart/${UNKNOWN_CHART_SLUG}`; + const found = api((request) => + new URL(request.url).pathname === page ? html(TITLE) : undefined, + ); + const result = await compareApiDetailed(serve(api()), serve(found)); + expect(result.mismatches).toEqual([ + `${page}: head answered 200 instead of 404, ${NOTHING_COMPARED}`, + `${page}: status 404 != 200`, + `${page}: title ${JSON.stringify(NOT_FOUND_TITLE)} != ${JSON.stringify(TITLE)}`, + ]); +}); + +test('an API error probe compares its body too', async () => { + const path = `/api/group/${MALFORMED_SLUG}`; + const other = api((request) => + new URL(request.url).pathname === path + ? json({ error: 'invalid' }, { status: 400 }) + : undefined, + ); + const lines = await compareApi(serve(api()), serve(other)); + expect(lines).toEqual([`${path}?n=100: body differs $.error: "bad_request" != "invalid"`]); +}); + +test('a page compares its title, not the rest of its HTML', async () => { + const rebuilt = api((request) => + ['/', '/chart/c'].includes(new URL(request.url).pathname) + ? html(TITLE, {}, 'build-2') + : undefined, + ); + expect(await compareApi(serve(api()), serve(rebuilt))).toEqual([]); + + const retitled = api((request) => + new URL(request.url).pathname === '/' ? html('Benchmarks') : undefined, + ); + expect(await compareApi(serve(api()), serve(retitled))).toEqual([ + `/: title ${JSON.stringify(TITLE)} != "Benchmarks"`, + ]); +}); + +test('a page without a title fails, because nothing was compared', async () => { + const untitled = api((request) => + new URL(request.url).pathname === '/chart/c' ? html(null) : undefined, + ); + expect(await compareApi(serve(untitled), serve(untitled))).toEqual([ + `/chart/c: base sent no , ${NOTHING_COMPARED}`, + `/chart/c: head sent no <title>, ${NOTHING_COMPARED}`, + ]); }); test('a redirect or an empty 200 on both sides fails too', async () => { @@ -184,15 +347,17 @@ test('a redirect or an empty 200 on both sides fails too', async () => { test('a slug that needs encoding reaches the servers encoded', async () => { const groups = { groups: [{ slug: 'we ird/slug?x', charts: [{ slug: 'c d#e' }] }] }; const seen: string[] = []; + const app = api(undefined, groups); const handler: Handler = (request) => { const { pathname, search } = new URL(request.url); seen.push(pathname + search); - return pathname === '/api/groups' ? json(groups) : json({ value: 1 }); + return app(request); }; const result = await compareApi(serve(handler), serve(handler)); expect(result).toEqual([]); expect(seen).toContain('/api/group/we%20ird%2Fslug%3Fx?n=100'); expect(seen).toContain('/api/chart/c%20d%23e?n=all'); + expect(seen).toContain('/chart/c%20d%23e'); expect(seen.filter((path) => path.includes(' ') || path.includes('#'))).toEqual([]); }); @@ -201,7 +366,7 @@ test('an unreachable head is reported for every URL', async () => { const head = serve(api()); await servers[servers.length - 1].stop(true); const lines = await compareApi(base, head); - expect(lines).toHaveLength(5); + expect(lines).toHaveLength(PLANNED); expect(lines[0]).toContain('head request failed'); }); @@ -209,25 +374,29 @@ test('requests that fill the group cache never overlap, even across the two serv const groups = { groups: ['g0', 'g1', 'g2'].map((slug) => ({ slug, charts: [{ slug: `${slug}-chart` }] })), }; + const app = api(undefined, groups); let fills = 0; let mostFills = 0; let mostRequests = 0; let requests = 0; const handler: Handler = async (request) => { const url = new URL(request.url); - const fill = url.pathname === '/api/groups' || url.search === '?n=100'; - const isGroupPath = url.pathname.startsWith('/api/group'); + // The landing page reads every group through the same cache as /api/groups. + const fill = + url.pathname === '/' || + url.pathname === '/api/groups' || + (url.pathname.startsWith('/api/group/') && url.search === '?n=100'); requests++; mostRequests = Math.max(mostRequests, requests); - if (fill && isGroupPath) { + if (fill) { mostFills = Math.max(mostFills, ++fills); } await Bun.sleep(5); - if (fill && isGroupPath) { + if (fill) { fills--; } requests--; - return url.pathname === '/api/groups' ? json(groups) : json({ value: 1 }); + return app(request); }; const result = await compareApiDetailed(serve(handler), serve(handler)); expect(result.mismatches).toEqual([]); diff --git a/bench/scripts/parity/compare-api.ts b/bench/scripts/parity/compare-api.ts index c3159ff..c7eee50 100644 --- a/bench/scripts/parity/compare-api.ts +++ b/bench/scripts/parity/compare-api.ts @@ -2,7 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors /** - * Compare the read API of two bench servers that share one database. + * Compare the responses of two bench servers that share one database: the read API, and the + * status, headers and title of the pages. * * Run as `bun compare-api.ts <base-url> <head-url>`; it prints one line per mismatch and a * summary, and exits non-zero when anything differs. `run.sh` calls it after it starts both @@ -15,6 +16,22 @@ const DEFAULT_CONCURRENCY = 4; const FULL_HISTORY_EVERY = 25; /** `?n=all` can read the whole history of a chart, so the deadline is generous. */ const REQUEST_TIMEOUT_MS = 180_000; +/** Chart pages to load: the first chart of each of the first this many groups, as the screenshots. */ +const CHART_PAGES = 5; + +/** + * A chart slug that the app parses (a compression-time chart, in the slug format of + * `bench/web/lib/slug.ts`) but that names no chart, so the API and the page answer 404. + */ +export const UNKNOWN_CHART_SLUG = `ct.${Buffer.from( + JSON.stringify({ + k: 'CompressionTime', + dataset: 'parity-check-no-such-dataset', + dataset_variant: null, + }), +).toString('base64url')}`; +/** A slug the app cannot parse: the API answers 400 and the chart page 404. */ +export const MALFORMED_SLUG = 'parity-check-malformed'; /** The parts of `GET /api/groups` that decide which URLs to request. */ interface GroupLinks { @@ -22,6 +39,18 @@ interface GroupLinks { charts: { slug: string }[]; } +/** One URL to request from both servers, and what both must answer. */ +export interface PlannedRequest { + path: string; + /** The status both servers must answer. */ + status: number; + /** + * What to compare besides the status, `content-type` and `cache-control`: the body byte for + * byte, or a page's `<title>` only, because its HTML names the build's own script files. + */ + compare: 'body' | 'title'; +} + const NOTHING_COMPARED = 'so nothing was compared for this route'; /** The parts of a response the parity check compares. */ @@ -141,6 +170,33 @@ export function diffJson(base: string, head: string): string | null { ); } +const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; + +function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (entity, name: string) => { + if (name[0] === '#') { + const hex = name[1] === 'x' || name[1] === 'X'; + return String.fromCodePoint(Number.parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10)); + } + return ENTITIES[name.toLowerCase()] ?? entity; + }); +} + +/** + * The page's title as a browser shows it: the first `<title>` outside inline SVG (an SVG's title + * labels the icon), with entities decoded and whitespace collapsed. + * + * @returns null when the page has no title. + */ +export function pageTitle(html: string): string | null { + const outsideSvg = html.replace(/<svg[\s>][\s\S]*?<\/svg>/gi, ''); + const match = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i.exec(outsideSvg); + if (match === null) { + return null; + } + return decodeEntities(match[1]).replace(/\s+/g, ' ').trim(); +} + async function capture(origin: string, path: string): Promise<Captured> { const response = await fetch(new URL(path, origin), { redirect: 'manual', @@ -162,7 +218,12 @@ async function captureOrError(origin: string, path: string): Promise<Captured | } } -function compareCaptured(path: string, base: Captured | Error, head: Captured | Error): string[] { +function compareCaptured( + planned: PlannedRequest, + base: Captured | Error, + head: Captured | Error, +): string[] { + const { path } = planned; const lines: string[] = []; for (const [side, result] of [ ['base', base], @@ -170,12 +231,19 @@ function compareCaptured(path: string, base: Captured | Error, head: Captured | ] as const) { if (result instanceof Error) { lines.push(`${path}: ${side} request failed: ${result.message}`); - } else if (result.status !== 200) { - // Every planned URL comes from the base's own /api/groups, so it must answer 200. Two equal - // 404s, 500s or redirects would otherwise match each other and hide that no data was read. - lines.push(`${path}: ${side} answered ${result.status} instead of 200, ${NOTHING_COMPARED}`); + } else if (result.status !== planned.status) { + // Every data URL comes from the base's own /api/groups, so it must answer 200, and each + // error probe must answer its own status. Two equal 404s, 500s or redirects would otherwise + // match each other and hide that nothing was read. + lines.push( + `${path}: ${side} answered ${result.status} instead of ${planned.status}, ${NOTHING_COMPARED}`, + ); } else if (result.body.length === 0) { - lines.push(`${path}: ${side} answered 200 with an empty body, ${NOTHING_COMPARED}`); + lines.push( + `${path}: ${side} answered ${result.status} with an empty body, ${NOTHING_COMPARED}`, + ); + } else if (planned.compare === 'title' && pageTitle(result.body) === null) { + lines.push(`${path}: ${side} sent no <title>, ${NOTHING_COMPARED}`); } } if (base instanceof Error || head instanceof Error) { @@ -190,9 +258,17 @@ function compareCaptured(path: string, base: Captured | Error, head: Captured | if (base.cacheControl !== head.cacheControl) { lines.push(`${path}: cache-control ${show(base.cacheControl)} != ${show(head.cacheControl)}`); } - const bodyDifference = diffJson(base.body, head.body); - if (bodyDifference !== null) { - lines.push(`${path}: body differs ${bodyDifference}`); + if (planned.compare === 'title') { + const baseTitle = pageTitle(base.body); + const headTitle = pageTitle(head.body); + if (baseTitle !== headTitle) { + lines.push(`${path}: title ${show(baseTitle)} != ${show(headTitle)}`); + } + } else { + const bodyDifference = diffJson(base.body, head.body); + if (bodyDifference !== null) { + lines.push(`${path}: body differs ${bodyDifference}`); + } } return lines; } @@ -210,22 +286,41 @@ async function readGroups(baseUrl: string): Promise<GroupLinks[]> { return groups as GroupLinks[]; } -/** The URLs to request from each server, in a stable order. */ -export function planRequests(groups: GroupLinks[]): string[] { - const paths = ['/api/groups']; +/** + * The URLs to request from each server, in a stable order: the read API for every group and chart, + * the landing page and the chart pages the screenshots use, then the error probes. + */ +export function planRequests(groups: GroupLinks[]): PlannedRequest[] { + const data = (path: string): PlannedRequest => ({ path, status: 200, compare: 'body' }); + const page = (path: string, status = 200): PlannedRequest => ({ path, status, compare: 'title' }); + const plan = [data('/api/groups')]; for (const group of groups) { const slug = encodeURIComponent(group.slug); - paths.push(`/api/group/${slug}?n=100`, `/api/group/${slug}?n=50`); + plan.push(data(`/api/group/${slug}?n=100`), data(`/api/group/${slug}?n=50`)); } const chartSlugs = [...new Set(groups.flatMap((group) => group.charts.map((c) => c.slug)))]; for (const [index, chartSlug] of chartSlugs.entries()) { const slug = encodeURIComponent(chartSlug); - paths.push(`/api/chart/${slug}?n=100`); + plan.push(data(`/api/chart/${slug}?n=100`)); if (index % FULL_HISTORY_EVERY === 0) { - paths.push(`/api/chart/${slug}?n=all`); + plan.push(data(`/api/chart/${slug}?n=all`)); } } - return paths; + plan.push(page('/')); + for (const group of groups.slice(0, CHART_PAGES)) { + const first = group.charts[0]; + if (first !== undefined) { + plan.push(page(`/chart/${encodeURIComponent(first.slug)}`)); + } + } + plan.push( + page(`/chart/${UNKNOWN_CHART_SLUG}`, 404), + { path: `/api/chart/${UNKNOWN_CHART_SLUG}?n=100`, status: 404, compare: 'body' }, + page(`/chart/${MALFORMED_SLUG}`, 404), + { path: `/api/chart/${MALFORMED_SLUG}?n=100`, status: 400, compare: 'body' }, + { path: `/api/group/${MALFORMED_SLUG}?n=100`, status: 400, compare: 'body' }, + ); + return plan; } async function mapPool<T, R>( @@ -249,10 +344,11 @@ async function mapPool<T, R>( * Whether a request fills the group cache. Those fills (`cachedGroups` and * `cachedDefaultGroupCharts` in `lib/data-cache.ts`) take one PostgreSQL advisory lock for the whole * database, and a fill that finds the lock taken fails with a 500 after a few retries. Both servers - * share one database, so two fills at once, even on different servers, can fail. + * share one database, so two fills at once, even on different servers, can fail. The landing page + * reads `cachedGroups` too. */ function fillsGroupCache(path: string): boolean { - return path === '/api/groups' || /^\/api\/group\/[^?]+\?n=100$/.test(path); + return path === '/' || path === '/api/groups' || /^\/api\/group\/[^?]+\?n=100$/.test(path); } /** Runs one piece of work at a time, in the order requested. */ @@ -271,12 +367,13 @@ export async function compareApiDetailed( headUrl: string, options: CompareOptions = {}, ): Promise<ApiComparison> { - const paths = planRequests(await readGroups(baseUrl)); + const plan = planRequests(await readGroups(baseUrl)); const oneFillAtATime = serialQueue(); const statuses: Record<string, number> = {}; const bytes = { base: 0, head: 0 }; let done = 0; - const lines = await mapPool(paths, options.concurrency ?? DEFAULT_CONCURRENCY, async (path) => { + const lines = await mapPool(plan, options.concurrency ?? DEFAULT_CONCURRENCY, async (planned) => { + const { path } = planned; const fetchBoth = async (): Promise<[Captured | Error, Captured | Error]> => fillsGroupCache(path) ? oneFillAtATime(async () => [ @@ -293,18 +390,20 @@ export async function compareApiDetailed( statuses[status] = (statuses[status] ?? 0) + 1; bytes[side] += result instanceof Error ? 0 : Buffer.byteLength(result.body); } - options.onProgress?.(++done, paths.length); - return compareCaptured(path, base, head); + options.onProgress?.(++done, plan.length); + return compareCaptured(planned, base, head); }); - return { requests: paths.length, mismatches: lines.flat(), statuses, bytes }; + return { requests: plan.length, mismatches: lines.flat(), statuses, bytes }; } /** - * Request the read API from both servers and compare status, `content-type`, `cache-control` and - * body: `/api/groups`; `/api/group/{slug}` at `?n=100` and `?n=50` for every group; - * `/api/chart/{slug}?n=100` for every chart; and `/api/chart/{slug}?n=all` for every 25th chart. - * The group and chart slugs come from the base server's `/api/groups`, so every URL must answer 200 - * with a body on both servers: anything else is a mismatch, even when both servers agree on it. + * Request the planned URLs ({@link planRequests}) from both servers and compare status, + * `content-type`, `cache-control`, and the body (API) or the `<title>` (pages): `/api/groups`; + * `/api/group/{slug}` at `?n=100` and `?n=50` for every group; `/api/chart/{slug}?n=100` for every + * chart; `/api/chart/{slug}?n=all` for every 25th chart; `/` and the first chart page of each of the + * first five groups; and an unknown and a malformed slug. The group and chart slugs come from the + * base server's `/api/groups`, so each of those URLs must answer 200 with a body on both servers, + * and each error probe its own status: anything else is a mismatch, even when both servers agree. * * @returns one line per mismatch; empty when the servers agree. */ @@ -335,7 +434,7 @@ if (import.meta.main) { .join(', '); const megabytes = (count: number) => `${(count / 1_000_000).toFixed(1)} MB`; console.log( - `API: ${requests} URLs on each server, ${mismatches.length} mismatches; ` + + `HTTP: ${requests} URLs on each server, ${mismatches.length} mismatches; ` + `statuses ${tally}; bodies ${megabytes(bytes.base)} from base, ${megabytes(bytes.head)} from head; ${seconds}s`, ); process.exit(mismatches.length === 0 ? 0 : 1); diff --git a/bench/scripts/parity/playwright.config.ts b/bench/scripts/parity/playwright.config.ts index d219cbe..1302b0f 100644 --- a/bench/scripts/parity/playwright.config.ts +++ b/bench/scripts/parity/playwright.config.ts @@ -14,6 +14,8 @@ function requiredEnv(name: string): string { } const out = requiredEnv('PARITY_OUT'); +const DESKTOP = { viewport: { width: 1440, height: 900 } }; +const PHONE = { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }; /** * Pixel comparison of the pages of one bench server. `run.sh` runs it twice: once against the base @@ -38,17 +40,17 @@ export default defineConfig({ use: { baseURL: requiredEnv('BASE_URL'), browserName: 'chromium', - colorScheme: 'light', deviceScaleFactor: 1, locale: 'en-US', reducedMotion: 'reduce', timezoneId: 'UTC', }, + // Each page in both themes. bench follows `prefers-color-scheme` when no theme is stored, and + // the screenshots store none. projects: [ - { name: 'desktop', use: { viewport: { width: 1440, height: 900 } } }, - { - name: 'phone', - use: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }, - }, + { name: 'desktop', use: { ...DESKTOP, colorScheme: 'light' } }, + { name: 'phone', use: { ...PHONE, colorScheme: 'light' } }, + { name: 'desktop-dark', use: { ...DESKTOP, colorScheme: 'dark' } }, + { name: 'phone-dark', use: { ...PHONE, colorScheme: 'dark' } }, ], }); diff --git a/bench/scripts/parity/run.sh b/bench/scripts/parity/run.sh index 91aa114..44d70a0 100755 --- a/bench/scripts/parity/run.sh +++ b/bench/scripts/parity/run.sh @@ -3,7 +3,8 @@ # SPDX-FileCopyrightText: Copyright the Vortex contributors # # Serve two builds of bench against one restored production database, then compare their API -# responses and their pages. Exits 0 when nothing differs. +# responses, their pages' status, headers and titles, and screenshots of the pages in the light and +# dark themes. Exits 0 when nothing differs. # # bash bench/scripts/parity/run.sh <base-ref> <dump-dir> # @@ -262,10 +263,10 @@ wait_ready head "$HEAD_URL" "${SERVER_PIDS[1]}" rm -rf "$OUT" mkdir -p "$OUT" -log "comparing the API" +log "comparing the API and the pages' status, headers and titles" api_status=0 bun "$HERE/compare-api.ts" "$BASE_URL" "$HEAD_URL" 2>&1 | tee "$OUT/api.log" || api_status=$? -api_mismatches=$(sed -n 's/^API: .* \([0-9][0-9]*\) mismatches.*/\1/p' "$OUT/api.log") +api_mismatches=$(sed -n 's/^HTTP: .* \([0-9][0-9]*\) mismatches.*/\1/p' "$OUT/api.log") api_mismatches=${api_mismatches:-"unknown"} run_playwright() { @@ -295,7 +296,7 @@ echo "parity: $(tail -n 1 "$OUT/api.log")" echo "parity: $shots_total screenshots, $shots_mismatches differ" echo "parity: wall time $((SECONDS / 60))m $((SECONDS % 60))s" if [[ $api_status -ne 0 ]]; then - echo "parity: API differences are listed above and in $OUT/api.log" + echo "parity: response differences are listed above and in $OUT/api.log" fi if [[ $shots_status -ne 0 ]]; then echo "parity: screenshots that differ; each folder holds the expected, actual and diff images:" @@ -307,5 +308,5 @@ if [[ $api_status -eq 0 && $shots_status -eq 0 ]]; then echo "parity: 0 mismatches" exit 0 fi -echo "parity: FAILED (API mismatches: $api_mismatches, screenshots that differ: $shots_mismatches)" +echo "parity: FAILED (response mismatches: $api_mismatches, screenshots that differ: $shots_mismatches)" exit 1 From a810be660c8075324967d103f55f88d346264e6a Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 00:50:09 +0100 Subject: [PATCH 16/34] Correct the linker and JSX comments for the shared toolchain bunfig.toml gave a reason for the isolated linker that no longer holds, now that both apps share TypeScript and ESLint is gone. State the reason that does hold. vitest.config.ts said Next.js owns tsconfig's jsx setting; it now comes from tsconfig.base.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/web/vitest.config.ts | 9 +++++---- bunfig.toml | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bench/web/vitest.config.ts b/bench/web/vitest.config.ts index 23f342d..8eaf4bb 100644 --- a/bench/web/vitest.config.ts +++ b/bench/web/vitest.config.ts @@ -5,10 +5,11 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - // Next.js owns tsconfig's `jsx` setting (it was `"preserve"` before Next.js 16, - // and Vite (rolldown/oxc) leaves preserved JSX untransformed, which fails the - // component tests). Transform JSX with the React automatic runtime for tests - // explicitly (no `import React` needed; pulls from `react/jsx-runtime`). + // Transform JSX with the React automatic runtime for tests explicitly (no + // `import React` needed; pulls from `react/jsx-runtime`), so the component + // tests do not depend on tsconfig's `jsx` setting. That comes from the shared + // `tsconfig.base.json` (`"react-jsx"`); it was `"preserve"` before Next.js 16, + // and Vite (rolldown/oxc) leaves preserved JSX untransformed. // Vite's transform option is `oxc`, not `esbuild`, under rolldown-vite; the // typed form of the automatic runtime is the `{ runtime: 'automatic' }` // object (the bare `'react-jsx'` string is runtime-valid but not in the type). diff --git a/bunfig.toml b/bunfig.toml index d577972..aa8dee9 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,10 +1,11 @@ # Bun configuration. Project-scoped — Bun merges this with $HOME/.bunfig.toml. [install] -# Give every package its own resolved dependencies, as pnpm did for bench, -# instead of one hoisted tree, so one app's tools never load the other's copy -# of a package. The site and bench/web share Next.js, React and TypeScript at -# the same versions. This is also Bun's default for a workspace. +# Link only the dependencies each package declares, as pnpm did for bench, +# instead of hoisting every package to the top of node_modules: no code can +# load a transitive dependency that its own package does not declare. This is +# also Bun's default for a workspace; it is set here so that the install +# layout does not change with that default. linker = "isolated" # Refuse to install package versions younger than 14 days. Mirrors the From 83d27f95c63dfb8653fc932472db9e42a79614b3 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 00:54:39 +0100 Subject: [PATCH 17/34] Cache Bun's packages and test the parity check in bench CI bench-checks lost its dependency cache when it moved from pnpm to Bun. Restore it with the same cache step as ci.yml. Run the parity check's own tests there too, by explicit path, so its planning and comparison logic is checked on every bench change. Correct a comment that said Biome checks bench's imports; import sorting is off for bench. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- .github/actions/bench-checks/action.yml | 34 +++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/actions/bench-checks/action.yml b/.github/actions/bench-checks/action.yml index 65e50ef..695bda0 100644 --- a/.github/actions/bench-checks/action.yml +++ b/.github/actions/bench-checks/action.yml @@ -1,8 +1,8 @@ name: Bench checks description: >- - bench's full check suite: infrastructure trust policies, format, lint, build, - the migration-runner and deployment-verification tests, and the web tests with - their Postgres integration suite. Bench CI runs it on every change to bench, + bench's full check suite: infrastructure trust policies, the parity check's + tests, format, lint, build, the migration-runner and deployment-verification + tests, and the web tests with their Postgres integration suite. Bench CI runs it on every change to bench, and bench-deploy runs it on the exact commit it deploys. Run it after a checkout. @@ -31,17 +31,35 @@ runs: # @types/node in bench/web/package.json on the same major. node-version-file: .nvmrc + # Bun's package store, keyed on the lockfile, as in ci.yml. The restore + # key falls back to any earlier cache from this OS. + - name: Cache Bun install cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + # At the root, where bun.lock and bunfig.toml live. This installs both apps. - name: Install dependencies shell: bash run: bun install --frozen-lockfile + # The parity check's own logic: request planning, the expected statuses + # and the comparisons. The explicit path keeps `bun test` from also + # collecting bench's Vitest files. + - name: Test the parity check + shell: bash + run: bun test bench/scripts/parity/compare-api.test.ts + # Biome over all of bench (web, scripts, parity harness) with the root - # biome.json, in CI mode: format, lint and import checks, no writes. It - # replaces the bench/web `format:check` and `lint` scripts, which cover only - # bench/web and would leave bench/scripts unchecked on bench-only changes, - # where ci.yml skips the root `check:ci`. `bun run` uses the workspace's - # pinned Biome and never downloads one. + # biome.json, in CI mode: format and lint, no writes. Import sorting is off + # for bench (see bench/AGENTS.md). It replaces the bench/web `format:check` + # and `lint` scripts, which cover only bench/web and would leave + # bench/scripts unchecked on bench-only changes, where ci.yml skips the + # root `check:ci`. `bun run` uses the workspace's pinned Biome and never + # downloads one. - name: Format and lint shell: bash run: bun run biome ci bench From bfd712c02ef49a206c1a14d33afdaf0e1b30aa68 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 00:54:55 +0100 Subject: [PATCH 18/34] Fail dependency review when the bun.lock snapshot is missing Dependency review passed without bun.lock's packages when the snapshot job failed, was skipped for a fork, or finished after the action's retry window. A guard now runs before the action. For a pull request from this repository, it waits up to 10 minutes for the snapshot job on the head commit and fails unless that job succeeded. For a fork, which gets no snapshot, it fails when the pull request changes a bun.lock. The job gains checks: read, and its checkout no longer keeps the token. The snapshot job now checks out the pull request's head commit, so the graph it files under that commit holds the head's own bun.lock. CLAUDE.md describes the guard, says that development-scope advisories (bench's included) no longer block merge and show in bun audit, and gives the real fallback for the zod override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- .github/workflows/ci.yml | 76 +++++++++++++++++++++-- .github/workflows/dependency-snapshot.yml | 14 +++-- CLAUDE.md | 9 +-- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5645d65..d2956fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,19 +228,87 @@ jobs: # (severity `high` and above) rather than the whole pre-existing tree. # # GitHub's dependency graph does not read bun.lock. The dependency-snapshot - # workflow submits it, and that run races this job, so the retry inputs - # make this one wait up to five minutes for the snapshot instead of - # reviewing a graph without the transitive packages. `fail-on-scopes` stays - # at the action's default, `runtime`: a development-only advisory does not + # workflow submits it, and the action would pass without it, so a guard + # runs first (see its comment). The retry inputs then cover the short time + # GitHub takes to process a submitted snapshot. `fail-on-scopes` stays at + # the action's default, `runtime`: a development-only advisory does not # block merge and stays with `bun audit` and CLAUDE.md "Audit advisories". dependency-review: if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 permissions: contents: read + # Implies read, which the guard uses to list a fork's changed files. pull-requests: write + # The guard reads the snapshot job's check run. + checks: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The action reviews whatever graph it finds. If the snapshot job + # failed, was skipped or finished late, that graph lacks bun.lock's + # packages and the review passes without them. So: for a pull request + # from this repository, wait for that job on the head commit and fail + # unless it succeeded. A fork's pull request gets no snapshot (its token + # cannot submit one), so fail it if it changes a bun.lock; if it does + # not, it adds no locked package. + - name: Require the bun.lock snapshot + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + FROM_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + # The `name:` of the job in dependency-snapshot.yml. + SNAPSHOT_CHECK: Submit bun.lock + WAIT_SECONDS: 600 + run: | + if [ "$FROM_FORK" = true ]; then + files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" --jq '.[].filename') + # The API lists at most 3000 files; a longer pull request could hide a bun.lock. + listed=$(grep -c . <<<"$files" || true) + if [ "$listed" -lt "$CHANGED_FILES" ]; then + echo "::error::Listed $listed of the pull request's $CHANGED_FILES changed files, so a bun.lock change could go unseen." + exit 1 + fi + if grep -Eq '(^|/)bun\.lock$' <<<"$files"; then + echo "::error::This pull request comes from a fork and changes bun.lock. A fork gets no dependency snapshot, so dependency review cannot see the locked packages. Make the lock change from a branch in this repository." + exit 1 + fi + echo "Fork pull request without a bun.lock change: it adds no locked package." + exit 0 + fi + + name=$(jq -rn --arg name "$SNAPSHOT_CHECK" '$name | @uri') + deadline=$((SECONDS + WAIT_SECONDS)) + while :; do + # The newest run of the job (highest id), so a successful rerun counts. + if run=$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs?check_name=$name&per_page=100" \ + --jq '(.check_runs | max_by(.id)) // {} | "\(.status // "missing") \(.conclusion // "none")"'); then + read -r status conclusion <<<"$run" + if [ "$status" = completed ]; then + if [ "$conclusion" = success ]; then + echo "The '$SNAPSHOT_CHECK' job submitted the snapshot for $HEAD_SHA." + exit 0 + fi + echo "::error::The '$SNAPSHOT_CHECK' job concluded '$conclusion', so dependency review would not see bun.lock's packages. Rerun it, then rerun this job." + exit 1 + fi + echo "'$SNAPSHOT_CHECK' on $HEAD_SHA: $status" + else + echo "::warning::Could not read the check runs of $HEAD_SHA; trying again." + fi + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::The '$SNAPSHOT_CHECK' job did not finish on $HEAD_SHA within $WAIT_SECONDS seconds." + exit 1 + fi + sleep 15 + done + - name: Dependency Review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: diff --git a/.github/workflows/dependency-snapshot.yml b/.github/workflows/dependency-snapshot.yml index 6c627ad..4d0686a 100644 --- a/.github/workflows/dependency-snapshot.yml +++ b/.github/workflows/dependency-snapshot.yml @@ -28,8 +28,11 @@ jobs: snapshot: name: Submit bun.lock # Pull requests from forks get a read-only token, which the submission API - # rejects, so they are skipped. Dependency review waits out its retry - # window for such a pull request, then proceeds with a snapshot warning. + # rejects, so they are skipped. Dependency review then fails such a pull + # request if it changes bun.lock; with bun.lock unchanged, it adds no + # locked package. For every other pull request, dependency review waits + # for this job by its name and fails unless it succeeded, so rename both + # together. if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -37,11 +40,12 @@ jobs: # The submission API wants `contents: write`; nothing else. contents: write steps: - # A pull request run checks out the merge commit: the lock that would - # land, filed below under the head commit that dependency-review - # compares. + # A pull request run checks out the head commit, not the merge commit, + # so the snapshot it files under that head commit (below) holds the + # head's own bun.lock, the one dependency-review compares with main's. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index bbe4ad2..6e7d894 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,8 +83,9 @@ Defense in depth against the npm-worm class (Shai-Hulud, mini-Shai-Hulud, the Ma - **Document and accept** — if no upstream fix exists yet, add an entry under "Audit advisories" below with: GHSA ID, vulnerable range, package, why exposure is acceptable (e.g. dev-only, not in client bundle), and a removal trigger. The job carries no `--ignore` flags: with nothing to keep green, suppressing an advisory only hides it, so this file is where an accepted advisory is recorded. - When a parent dep eventually patches its own transitive, drop the corresponding `overrides` entry — leaving stale overrides means we keep deduping a fix that was already merged upstream. 5. **`actions/dependency-review-action`** runs on every PR and **hard-fails** the build if the PR introduces a new advisory at severity `high` or above in a runtime-scope package. With `bun audit` informational, this is the only advisory check that blocks merge — deliberately scoped to what the PR itself adds, not the whole pre-existing tree. - - **It sees `bun.lock` through a submitted snapshot.** GitHub's dependency graph does not read `bun.lock`, so `.github/workflows/dependency-snapshot.yml` runs `scripts/dependency-snapshot.ts` on every push to main and every same-repository PR. It posts one manifest per workspace to the Dependency Submission API, listing every package that workspace reaches with a scope: `runtime` if a workspace's `dependencies` or `optionalDependencies` reach it, `development` otherwise. An optional peer, like Next.js's Playwright, does not pull its package into `runtime`. `dependency-review` waits up to 300 seconds for the snapshot (`retry-on-snapshot-warnings`). A fork PR gets no snapshot, because its token cannot write, so its review proceeds with a snapshot warning. - - **It fails on runtime scope only.** `fail-on-scopes` is left at the action's default, `runtime` (`action.yml` declares no default; the action's schema and README give `runtime`). A development-only advisory, such as the `@lhci/cli` tree, does not block merge: it stays in `bun audit` and the "Audit advisories" list below. Runtime means reachable from `dependencies`, which includes build-time tools the site lists there (velite, tailwindcss, `@tailwindcss/typography`). + - **It sees `bun.lock` through a submitted snapshot.** GitHub's dependency graph does not read `bun.lock`, so `.github/workflows/dependency-snapshot.yml` runs `scripts/dependency-snapshot.ts` on every push to main and on the head commit of every same-repository PR. It posts one manifest per workspace to the Dependency Submission API, listing every package that workspace reaches with a scope: `runtime` if a workspace's `dependencies` or `optionalDependencies` reach it, `development` otherwise. An optional peer, like Next.js's Playwright, does not pull its package into `runtime`. + - **A guard fails the review when the snapshot is missing.** The action reviews whatever graph it finds, so without the snapshot it would pass without `bun.lock`'s packages. Before the action, the job's "Require the bun.lock snapshot" step waits up to 10 minutes for the snapshot job (`Submit bun.lock`) on the PR's head commit, and fails unless that job succeeded. A fork PR gets no snapshot, because its token cannot write, so the guard fails a fork PR that changes `bun.lock` (make that change from a branch in this repository) and passes one that does not, because it adds no locked package. After the guard, the action still waits up to 300 seconds (`retry-on-snapshot-warnings`) while GitHub processes the snapshot. If you rename the snapshot job, change the guard's `SNAPSHOT_CHECK` with it. + - **It fails on runtime scope only.** `fail-on-scopes` is left at the action's default, `runtime` (`action.yml` declares no default; the action's schema and README give `runtime`). An advisory in a `development`-scope package does not block merge. That includes bench's development tools (Vitest, testcontainers, jsdom and what they pull in), whose advisories blocked bench PRs under pnpm. `bun audit` is where those advisories show: it reports both apps' whole trees on every run, and the "Audit advisories" list below records the accepted ones. Runtime means reachable from `dependencies`, which includes build-time tools the site lists there (velite, tailwindcss, `@tailwindcss/typography`). 6. **`trufflesecurity/trufflehog`** secret scan runs on every PR and push to main. Defense-in-depth on top of GitHub's push protection; catches verified secrets that slipped past push protection (low-entropy formats, detector patterns added after the secret was committed, or push-protection bypass). 7. **OpenSSF Scorecard** (`.github/workflows/scorecard.yml`) grades the repo weekly on supply-chain hygiene (pinned actions, branch protection, token permissions, dangerous workflow patterns). SARIF posts to the Security tab; aggregate score publishes to https://scorecard.dev. 8. **All third-party GitHub Actions are SHA-pinned**, not tag-pinned. A tag can be moved to point at a malicious commit (and has been, in prior supply-chain incidents); a SHA can't. Renovate's `helpers:pinGitHubActionDigests` preset enforces this on auto-bump PRs. When bumping an action manually, update both the SHA and the trailing `# vX.Y.Z` comment in the same diff. @@ -121,7 +122,7 @@ Keep them in lockstep when bumping. Raising `.nvmrc` alone makes CI validate aga - **sharp `<0.35.5`** (GHSA-wq5f-xc86-pv6w, high — librsvg CVE-2026-96889; also GHSA-rgj7-g3m4-5g8c `<0.35.4` and GHSA-f88m-g3jw-g9cj `<0.35.0`). `next@16.4.1` asks for `sharp@^0.35.5`, but `velite@0.4.0` declares `sharp@^0.34.5`, a range that cannot reach 0.35.x, so the tree would carry a vulnerable second copy. `overrides.sharp = "^0.35.5"` pulls velite onto the patched line and dedupes to one copy. Crossing 0.34 → 0.35 is a breaking bump on sharp's 0.x line; verified safe because velite's image pipeline runs during `bun run build` and the build passes. Drop the override once velite ships a release declaring `sharp@^0.35.5` or later — 0.4.0 is still the latest. -- **zod `<4.6.6`** (GHSA-mrhx-c7xc-m3w5, high memory exhaustion on oversized constrained collections). Two paths: bench's `@vercel/functions > @vercel/oidc > @vercel/cli-config` pins `zod@4.1.11` exactly, and the site's `@lhci/cli > lighthouse > puppeteer-core > chromium-bidi` declares `zod@^3.24.1`, a line with no fix. `overrides.zod = "^4.6.7"` puts both on 4.6.7. bench only imports `attachDatabasePool`, which does not load the OIDC code that uses zod. For chromium-bidi the override crosses zod 3 → 4 under a library we do not control, on purpose, and the Lighthouse CI job must keep passing. chromium-bidi still parses valid WebDriver BiDi messages under zod 4, but its error path reads zod 3's `errors` field, so an invalid message throws a TypeError instead of a protocol error. puppeteer-core loads chromium-bidi only for the BiDi protocol; lhci drives Chrome over CDP. If it breaks, drop the override and accept bench's zod 4.1.11 instead (it parses only local Vercel CLI config files). Drop the override once `@vercel/cli-config` and chromium-bidi both accept zod 4.6.6 or later. +- **zod `<4.6.6`** (GHSA-mrhx-c7xc-m3w5, high memory exhaustion on oversized constrained collections). Two paths: bench's `@vercel/functions > @vercel/oidc > @vercel/cli-config` pins `zod@4.1.11` exactly, and the site's `@lhci/cli > lighthouse > puppeteer-core > chromium-bidi` declares `zod@^3.24.1`, a line with no fix. `overrides.zod = "^4.6.7"` puts both on 4.6.7. bench only imports `attachDatabasePool`, which does not load the OIDC code that uses zod. For chromium-bidi the override crosses zod 3 → 4 under a library we do not control, on purpose, and the Lighthouse CI job must keep passing. chromium-bidi still parses valid WebDriver BiDi messages under zod 4, but its error path reads zod 3's `errors` field, so an invalid message throws a TypeError instead of a protocol error. puppeteer-core loads chromium-bidi only for the BiDi protocol; lhci drives Chrome over CDP. If it breaks, drop the override, accept bench's zod 4.1.11 (it parses only local Vercel CLI config files), and record it under "Accepted" here. The snapshot scopes bench's zod `runtime`, because `@vercel/functions` is in bench's `dependencies`, so dependency review would block the PR that drops the override: add `allow-ghsas: GHSA-mrhx-c7xc-m3w5` to the Dependency Review step in `ci.yml` in the same PR. Drop the override once `@vercel/cli-config` and chromium-bidi both accept zod 4.6.6 or later. **Accepted — fix exists but is inside the 14-day cooldown.** These clear on their own: Renovate's `lockFileMaintenance` (or a manual `bun update`) picks up the patched version once it is 14 days old, because each parent's range already admits it. @@ -143,7 +144,7 @@ The `audit` job in `.github/workflows/ci.yml` runs a bare `bun audit` and never #### bench advisories -`bun audit` at the root reports bench's advisories with the site's. Dependency review sees bench's packages through the snapshot's `bun.lock (bench/web)` manifest, scoped like the site's (layer 5). State as of 2026-10-28: `bun audit` reports two for bench, both moderate. bench's move to Next.js 16.4.1 (postcss 8.5.23) and vitest 5.0.4 cleared the other six. +`bun audit` at the root reports bench's advisories with the site's. Dependency review sees bench's packages through the snapshot's `bun.lock (bench/web)` manifest, scoped like the site's (layer 5), so an advisory in one of bench's development tools no longer blocks a PR. State as of 2026-10-28: `bun audit` reports two for bench, both moderate. bench's move to Next.js 16.4.1 (postcss 8.5.23) and vitest 5.0.4 cleared the other six. - **Accepted, dev-only — protobufjs 7.6.2** (GHSA-f38q-mgvj-vph7, fixed in 7.6.3, and GHSA-j3f2-48v5-ccww, fixed in 7.6.5; both moderate; via testcontainers > dockerode > @grpc/proto-loader). The move to Bun kept pnpm's version. It runs only in bench's tests. Drop this entry when a lockfile refresh reaches protobufjs 7.6.5. - **Resolved by `overrides` in the root `package.json`:** zod (see "Audit advisories"). sharp needs no override for bench: next 16.4.1 asks for `^0.35.5` itself. From f92f33abfeef338d4c82a00136a34618339eedc5 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 00:54:55 +0100 Subject: [PATCH 19/34] Correct two bench notes in CLAUDE.md Give the publish times behind the statement that next 16.4.1 and react 19.3.1 were past the cooldown when bench moved to them, and say how the "13 days old" for the site differs. Rewrite the vite note, which still described pnpm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e7d894..23ecbcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,6 @@ The `audit` job in `.github/workflows/ci.yml` runs a bare `bun audit` and never - **Accepted, dev-only — protobufjs 7.6.2** (GHSA-f38q-mgvj-vph7, fixed in 7.6.3, and GHSA-j3f2-48v5-ccww, fixed in 7.6.5; both moderate; via testcontainers > dockerode > @grpc/proto-loader). The move to Bun kept pnpm's version. It runs only in bench's tests. Drop this entry when a lockfile refresh reaches protobufjs 7.6.5. - **Resolved by `overrides` in the root `package.json`:** zod (see "Audit advisories"). sharp needs no override for bench: next 16.4.1 asks for `^0.35.5` itself. -- **Resolved in the lockfile:** @grpc/grpc-js, brace-expansion, js-yaml and source-map-js, by refreshing within their parents' ranges. vite is a direct dev dependency (`^8.3.4`): it is vitest's peer, and pnpm kept the vulnerable locked 8.0.16 even with an override. -- **Cooldown exemptions:** `undici` in `bunfig.toml`, until 2026-10-29 15:06 UTC (see "Cooldown exemptions" above). next 16.4.1, its `@next/*` packages and react 19.3.1 were already past the window when bench moved to them. +- **Resolved in the lockfile:** @grpc/grpc-js, brace-expansion, js-yaml and source-map-js, by refreshing within their parents' ranges. vite is a direct dev dependency of bench (`^8.3.4`), because it is vitest's peer: as a direct dependency its range sets the version the lockfile holds, so the vulnerable 8.0.16 cannot come back. (Under pnpm, an override alone left 8.0.16 locked.) +- **Cooldown exemptions:** `undici` in `bunfig.toml`, until 2026-10-29 15:06 UTC (see "Cooldown exemptions" above). next 16.4.1, its `@next/*` packages and react 19.3.1 were already past the window when bench moved to them, at 2026-10-28 22:31 UTC: npm published next and its `@next/*` packages on 2026-10-14 between 17:28 and 17:43 UTC, and react and react-dom at 16:47 and 16:49 UTC. The "13 days old" in "Cooldown exemptions" above was their age when the site adopted them. - **Accepted, the Vercel CLI (64.0.2) in `bench-deploy.yml`:** tar (one critical), undici, path-to-regexp, minimatch, js-yaml, smol-toml, zod and @fastify/busboy. The CLI and its framework builders pin these exactly; fixing undici alone means a 5 → 6 major inside the CLI's HTTP client. It runs only in CI, on our own build output. It is installed without a lockfile, so dependency review never sees it, and these advisories live only here. Re-audit when Renovate bumps `VERCEL_CLI_VERSION`: install it with `npm install --package-lock-only vercel@<version>` in a scratch directory and run `npm audit`. From ecfd46292b80822c1e4032004c029a9cf1c640fd Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 01:05:46 +0100 Subject: [PATCH 20/34] Plan bench PR 2: clean up bench's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- .../docs/plans/2026-10-29-cleanup-pr2-plan.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 bench/docs/plans/2026-10-29-cleanup-pr2-plan.md diff --git a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md new file mode 100644 index 0000000..30466da --- /dev/null +++ b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md @@ -0,0 +1,424 @@ +<!-- +SPDX-License-Identifier: CC-BY-4.0 +SPDX-FileCopyrightText: Copyright the Vortex contributors +--> + +# bench PR 2: clean up bench's code — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** bench's code gets smaller and clearer: no IAM sign-in path, two queries per group instead of two per chart, `Chart.tsx` and `chart-store.ts` split into modules, and the site's stricter TypeScript flags. Nothing a visitor or API client sees changes. + +**Architecture:** One branch, `claude/bench-cleanup`, stacked on PR 1's `claude/bench-toolchain`, with one or two commits per task so a parity difference can be bisected. The group route builds every chart of a group from two statements run in parallel: the shared last-`n` commits, and the group's fact rows with each chart's commit total. It slices each chart's own window from the shared one. `chartPayload` keeps its per-chart SQL for `/api/chart/{slug}` and is the oracle the group tests compare against. The split moves code into `components/chart/` and `lib/chart-store/` without rewriting it. + +**Tech Stack:** Bun 1.4.3 workspace, Next.js 16.4.1, React 19.3.1, TypeScript 7, Biome 2.5, Vitest 4, node-postgres 8, Chart.js 4, Postgres 16 (testcontainers in CI; Homebrew `postgresql@16` 16.15 in `/opt/homebrew/opt/postgresql@16/bin` locally). + +**Spec:** `bench/docs/plans/2026-10-28-toolchain-and-cleanup.md` (read it first; this plan covers its "PR 2"). The style reference is `bench/docs/plans/2026-10-28-toolchain-pr1-plan.md`. + +**Starts from:** PR 1 has landed underneath (branch `claude/bench-toolchain`). `bench/web` is a member of the root Bun workspace, with one `bun.lock`. Bench scripts run as `bun run --cwd bench/web <script>`. Biome lints and formats bench, and `bench/web/tsconfig.json` extends `tsconfig.base.json`. `bash bench/scripts/parity/run.sh <base-ref> <dump-dir>` compares two builds (see `bench/scripts/parity/README.md`). This Mac has no Docker, so the integration suite skips locally until Task 1. + +## Global Constraints + +- "No change in behavior that a person could notice, and each PR proves it. Small bug fixes and subtle changes from library upgrades are acceptable if they are negligible; every difference the parity check finds is listed and explained in the PR." For PR 2: "Logic stays the same. The parity check and the tests prove it." +- Fail if "a person can notice a change in a page, API response, response header, cache rule or URL". `bench/web/vercel.json` "must not change". +- Group route: "The JSON must stay byte-identical." So `GROUP_PAYLOAD_CACHE_VERSION` in `lib/data-cache.ts` stays `'v9'`. +- `QUERY_CONCURRENCY`: "Keep the concurrency limit unless the new code no longer needs it." (It stays; see Task 3.) +- Splitting `Chart.tsx` and `chart-store.ts`: "Move code; don't rewrite it." +- Parity runs once, in Task 6, comparing the PR's base with the whole branch. Tasks have no parity step. On a difference, bisect the task commits with `run.sh` as the test. +- Vitest stays the test runner. No database schema, ingest contract (`bench/CONTRACT.md`) or AWS changes. The marketing site's build, behavior and deploy do not change. +- The supply-chain gates must not get weaker: "the 14-day cooldown, explicit overrides, the lifecycle-script allowlist, SHA-pinned actions, `ubuntu-24.04` runners". +- The `main` ruleset requires the job name `Bench: format, lint, build, test`. Keep it. +- "Settings changes need Will's confirmation first. That covers GitHub, Vercel and AWS settings." Deleting `BENCH_DB_REGION` from the Vercel project is such a change: list it in the PR, don't do it. +- Node 24 stays the runtime. +- bench keeps its style: single quotes, trailing commas everywhere, 100 columns, 2-space indent. +- Every commit uses `git commit -s` and ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Squash merges only. +- New code files under `bench/` start with `// SPDX-License-Identifier: Apache-2.0` and `// SPDX-FileCopyrightText: Copyright the Vortex contributors`. Markdown files use CC-BY-4.0. + +## Review Focus + +1. **A chart whose window differs from the rest of its group.** It has fewer commits than `n`, or its first row is on a commit whose timestamp ties with an earlier one, or it has no rows inside the window. `/api/group/{slug}` must still send it with the same bytes as `/api/chart/{slug}`. Task 3 pins this with `group payloads match chartPayload byte for byte at ?n=%s` and `gives each chart its own window`. +2. **A group takes rows from a sibling group.** Groups that share a dataset (another storage, or a NULL against a `'1'` scale factor) must stay apart, so the group statement must pin every column the chart statement pins. Task 3 pins this with `keeps sibling groups apart`. +3. **An environment without `BENCH_DB_PASSWORD`.** A Preview that has only `BENCH_DB_REGION` must fail at the first query, with an error that names `BENCH_DB_PASSWORD`. It must not try IAM. Task 2 pins this with `requires BENCH_DB_PASSWORD, even when BENCH_DB_REGION is set`. +4. **`/api/group/{slug}?n=all` on the 99-chart TPC-DS group.** One result set now holds the group's whole history (about 870,000 rows). The request must still finish within the 30 s `statement_timeout` and the function's memory. Task 3 Step 7 measures old and new code on the restored dump, and sets the fallback. +5. **A remounted chart after the split.** If a moved wheel or range-strip listener loses the controller's abort signal, it keeps panning a destroyed chart. If the moved config loses an option, the chart changes. Task 4 pins both with `pans once per wheel event and stops after unmount`. + +--- + +### Task 1: Run the integration suite against a local Postgres + +**Files:** +- Modify: `bench/web/lib/test-harness.ts` +- Create: `bench/web/lib/test-harness.test.ts` +- Modify: `bench/web/lib/db.test.ts`, `lib/queries.test.ts`, `lib/groups.test.ts`, `lib/health.test.ts`, `lib/server-smoke.test.ts` (imports, skip gate, type) +- Modify: `bench/web/README.md` ("Local development"), `CLAUDE.md` (the bench line that says the suite needs Docker) + +**Interfaces:** +- Produces in `lib/test-harness.ts`: + - `export interface BenchDatabase { stop(): Promise<void> }` + - `export interface TestDatabaseSettings { host: string; port: number; user: string; password: string; adminDatabase: string }` + - `export function testDatabaseSettings(url: string): TestDatabaseSettings` + - `export function integrationDatabaseAvailable(env: NodeJS.ProcessEnv = process.env, docker: () => boolean = dockerAvailable): boolean`. `dockerAvailable` stops being exported. + - `export async function startBenchDatabase(options: { applySchema?: boolean } = {}): Promise<BenchDatabase>` replaces `startBenchContainer`. + - Unchanged: `seedChartFixture`, `COMMITS`, `TREE_SHA`, `commitUrl`. +- Env var `BENCH_TEST_DATABASE_URL`: a `postgres://` URL of an existing server, as a superuser, naming its admin database. + +- [ ] **Step 1: Write the failing tests** in `lib/test-harness.test.ts`: + +```ts +describe('testDatabaseSettings', () => { + it('reads host, port, user and admin database, with a placeholder password', () => { + expect(testDatabaseSettings('postgres://postgres@127.0.0.1:55433/postgres')).toEqual({ + host: '127.0.0.1', port: 55433, user: 'postgres', password: 'trust', adminDatabase: 'postgres', + }); + }); + it('decodes the password and defaults the port and admin database', () => { + expect(testDatabaseSettings('postgresql://will:p%40ss@localhost')).toEqual({ + host: 'localhost', port: 5432, user: 'will', password: 'p@ss', adminDatabase: 'postgres', + }); + }); + it.each(['mysql://u@h/db', 'postgres://h/db'])('rejects %s', (url) => { + expect(() => testDatabaseSettings(url)).toThrow('BENCH_TEST_DATABASE_URL'); + }); +}); +describe('integrationDatabaseAvailable', () => { + it('prefers BENCH_TEST_DATABASE_URL, then Docker', () => { + expect(integrationDatabaseAvailable({ BENCH_TEST_DATABASE_URL: 'postgres://u@h/db' }, () => false)).toBe(true); + expect(integrationDatabaseAvailable({}, () => true)).toBe(true); + expect(integrationDatabaseAvailable({}, () => false)).toBe(false); + }); + it('fails loudly in CI when no database is available', () => { + expect(() => integrationDatabaseAvailable({ CI: 'true' }, () => false)).toThrow('BENCH_TEST_DATABASE_URL'); + }); +}); +``` + +- [ ] **Step 2: Run** `bun run --cwd bench/web test lib/test-harness.test.ts`. Expect FAIL: the exports are missing. +- [ ] **Step 3: Implement.** + - `testDatabaseSettings` uses `new URL()`. It accepts only the `postgres:` and `postgresql:` schemes and requires a user. The port defaults to 5432 and the admin database to `postgres`. + - With no password in the URL, the password is the literal `'trust'`. `BENCH_DB_PASSWORD` must not be empty (and is required after Task 2), and trust auth ignores the value. + - `integrationDatabaseAvailable` returns true when `BENCH_TEST_DATABASE_URL` is set and not empty, otherwise `docker()`. When `env.CI === 'true'` and both are missing, it throws an error that names Docker and `BENCH_TEST_DATABASE_URL`. That stops GitHub Actions from passing with the database suites skipped. + - When the URL is set, `startBenchDatabase` opens an admin `pg.Client` and takes `pg_advisory_lock(0x62656e63, 0x74657374)`. Under the lock it runs `CREATE DATABASE "bench_test_<pid>_<8 random hex>"` and, unless `applySchema === false`, the same `uv run --no-project migrate-schema.py apply` as today, with `PG*` pointing at the new database. Then it unlocks. The lock is needed because the migrations create cluster-wide roles, and two first applies at once race on `CREATE ROLE`. + - It then sets `BENCH_DB_HOST`, `_PORT`, `_NAME`, `_USER`, `_PASSWORD` and `BENCH_DB_SSL=disable`. `stop()` runs `DROP DATABASE IF EXISTS "<name>" WITH (FORCE)` from a fresh admin client. If a migration fails, drop the database and rethrow. + - Without the URL, it keeps today's testcontainers path and wraps it as `{ stop: async () => { await container.stop(); } }`. +- [ ] **Step 4: Update the five DB suites.** `dockerAvailable()` becomes `integrationDatabaseAvailable()`, `startBenchContainer` becomes `startBenchDatabase`, and the `StartedPostgreSqlContainer` type becomes `BenchDatabase`. `server-smoke.test.ts` keeps its `|| !BUILD_PRESENT`. +- [ ] **Step 5: Document the local database.** In the README's "Local development", add these commands, and change the `test` comment to "needs Docker, or `BENCH_TEST_DATABASE_URL`, and uv": + +```bash +PG=/opt/homebrew/opt/postgresql@16/bin +$PG/initdb -D "$TMPDIR/bench-test-pg" -U postgres -A trust -E UTF8 --locale=C +$PG/pg_ctl -D "$TMPDIR/bench-test-pg" -l "$TMPDIR/bench-test-pg.log" -w start \ + -o "-p 55433 -c listen_addresses=127.0.0.1 -c unix_socket_directories= -c fsync=off" +export BENCH_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres +``` + + Port 55433 stays clear of the parity cluster on 55432. `--locale=C` matches the CI container's collation. Update CLAUDE.md's bench line to match. +- [ ] **Step 6: Verify.** + - `bun run --cwd bench/web test lib/test-harness.test.ts` passes. + - Run `bun run --cwd bench/web build`, then the whole suite with the URL exported. Every file passes, and the summary line shows no `skipped`. + - `psql -h 127.0.0.1 -p 55433 -U postgres -Atc "SELECT count(*) FROM pg_database WHERE datname LIKE 'bench_test_%'"` prints `0`. + - Unset the URL and run again: the database suites skip, as before. + - Record the pass count; later tasks compare against it. +- [ ] **Step 7: Commit:** `Let bench's integration tests use a local Postgres`. + +### Task 2: Remove the IAM sign-in path + +**Files:** +- Modify: `bench/web/lib/db.ts`, `bench/web/lib/db.test.ts` +- Modify: `bench/web/package.json` (remove `@aws-sdk/rds-signer`), `bun.lock` +- Modify: `bench/web/README.md` (env table; "Deploys" step 3 "Auth" bullet), `bench/docs/runbooks/deploy-secrets-setup.md` (env table) + +**Interfaces:** +- Produces: `getPool()` reads `BENCH_DB_PASSWORD` through `requireEnv`. When it is unset or empty, `getPool()` throws ``Missing required environment variable `BENCH_DB_PASSWORD` for the benchmarks DB connection.`` at first use. `next build` still never reads it. +- Removed: `passwordProvider`, `DbConfig.region`, `DbConfig.staticPassword` and the `Signer` import. `DbConfig` gains `password: string` and stops being exported. The pool receives the password as a string. + +- [ ] **Step 1: Write the failing tests** in the `singleton pool configuration` block of `db.test.ts`. Keep `BENCH_DB_REGION` in `ENV_KEYS` so it is restored afterwards. + +```ts +it('requires BENCH_DB_PASSWORD, even when BENCH_DB_REGION is set', () => { + configurePoolEnvironment(); + process.env.BENCH_DB_REGION = 'us-east-1'; + for (const value of [undefined, '']) { + if (value === undefined) delete process.env.BENCH_DB_PASSWORD; + else process.env.BENCH_DB_PASSWORD = value; + expect(() => getPool()).toThrow( + 'Missing required environment variable `BENCH_DB_PASSWORD` for the benchmarks DB connection.', + ); + } +}); +it('hands the password to pg as a string', () => { + configurePoolEnvironment(); + const pool = getPool() as unknown as { options: { password?: unknown } }; + expect(pool.options.password).toBe('fixture-pw'); +}); +``` + +- [ ] **Step 2: Run** `bun run --cwd bench/web test lib/db.test.ts -t 'BENCH_DB_PASSWORD|password to pg'`. Expect both to FAIL: the first gets a pool from the mocked signer, and the second sees a function. +- [ ] **Step 3: Implement.** In `readConfig`, set `password: requireEnv('BENCH_DB_PASSWORD')` right after `user`. In `createPool`, pass `password: config.password`. Rewrite the `DbConfig` doc comment: the app signs in with a password, and production uses the read-only `bench_read` role. In the test file, delete the `db IAM auth path (mocked rds-signer)` block, the `vi.hoisted` mock and the `vi.mock('@aws-sdk/rds-signer')`. +- [ ] **Step 4: Drop the package.** Remove the dependency and run `bun install` at the root. Check that `git diff --stat bun.lock` shows only removed lines (the signer and its `@aws-sdk`, `@aws-crypto` and `@smithy` packages), and that `grep -c rds-signer bun.lock` prints `0`. +- [ ] **Step 5: Update the docs.** + - In the README env table, the `BENCH_DB_PASSWORD` row becomes required: "Password for the role in `BENCH_DB_USER` (`bench_read` in production)." Delete the `BENCH_DB_REGION` row. Fix the stale `SUMMARY_CONCURRENCY` in the `BENCH_DB_POOL_MAX` row to `QUERY_CONCURRENCY`. + - Rewrite the "Auth" bullet: the app signs in only with `BENCH_DB_PASSWORD`. `bench_read` has no `rds_iam` grant (migration `005`). Moving the read path to IAM would need app code, a migration and AWS credentials in the function runtime. + - In the runbook, `BENCH_DB_PASSWORD` becomes "Password for the `bench_read` role (required)". Delete the `BENCH_DB_REGION` row. +- [ ] **Step 6: Verify.** + - The suite passes with your Task 1 baseline minus 3 (the IAM tests) plus 2. + - `bun run --cwd bench/web build` succeeds. + - `grep -rn -e rds-signer -e BENCH_DB_REGION -e passwordProvider bench/web bench/docs/runbooks bench/docs/architecture CLAUDE.md` prints nothing. +- [ ] **Step 7: Commit:** `Remove bench's unused IAM sign-in path`. Say in the message that `BENCH_DB_REGION` on the Vercel project is now unused, and that deleting it is Will's settings change. + +### Task 3: One query per group + +**Files:** +- Modify: `bench/web/lib/queries.ts` +- Create: `bench/web/lib/group-charts.test.ts` +- Modify: `bench/docs/architecture/performance.md` (section "Parallel group fan-out") + +**Interfaces:** +- Consumes from Task 1: `integrationDatabaseAvailable()`, `startBenchDatabase()`, `seedChartFixture()`, `TREE_SHA`, `commitUrl()`. +- Produces in `lib/queries.ts`: + - `export function historyFor(totalCommits: number, window: CommitWindow): ChartHistory` + - `export type FactRow = { commit_sha: string; value: number; engine?: string; format?: string; op?: string; open_mode?: 'cached' | 'reopen'; flavor?: string }` + - `export function seriesFor(kind: ChartKind, row: FactRow): { key: string; engine: string | undefined; format: string | undefined }` + - `export async function groupChartPayloads(group: GroupKey, charts: readonly ChartKey[], window: CommitWindow): Promise<(ChartResponse | null)[]>`. The result follows the order of `charts`. A chart with no commits gives `null`. Every key must belong to `group`. + - New private helpers: `chartDisplayName(key: ChartKey): string`, `chartUnitKind(key: ChartKey): UnitKind`, `windowCommits(window: CommitWindow): Promise<CommitPoint[]>`, `groupRowsQuery(group: GroupKey, charts: readonly ChartKey[], window: CommitWindow): { text: string; values: unknown[] }`. +- Unchanged signatures: `chartPayload`, `collectGroupCharts`, `collectGroups`. `QUERY_CONCURRENCY` and `mapWithConcurrency` stay and now bound only the summary fan-out in `collectGroups`. + +- [ ] **Step 1: Write the unit tests** (no database) in `group-charts.test.ts`: + +```ts +it('bounds a long history to the window', () => { + expect(historyFor(125, { kind: 'last', n: 100 })).toEqual({ total_commits: 125, start_index: 25, loaded_commits: 100, complete: false }); +}); +it('keeps a short history whole', () => { + expect(historyFor(3, { kind: 'last', n: 100 })).toEqual({ total_commits: 3, start_index: 0, loaded_commits: 3, complete: true }); +}); +it('loads everything for ?n=all', () => { + expect(historyFor(125, { kind: 'all' })).toEqual({ total_commits: 125, start_index: 0, loaded_commits: 125, complete: true }); +}); +it('reports an empty history as complete', () => { + expect(historyFor(0, { kind: 'last', n: 100 })).toEqual({ total_commits: 0, start_index: 0, loaded_commits: 0, complete: true }); +}); +it.each([ + ['QueryMeasurement', { engine: 'duckdb', format: 'parquet' }, { key: 'duckdb:parquet', engine: 'duckdb', format: 'parquet' }], + ['CompressionTime', { format: 'parquet', op: 'encode' }, { key: 'parquet:encode', engine: undefined, format: 'parquet' }], + ['CompressionSize', { format: 'lance' }, { key: 'lance', engine: undefined, format: 'lance' }], + ['RandomAccess', { format: 'parquet', open_mode: 'reopen' }, { key: 'parquet:cold', engine: undefined, format: 'parquet' }], + ['VectorSearch', { flavor: 'vortex-turboquant' }, { key: 'vortex-turboquant', engine: undefined, format: undefined }], +] as const)('names the %s series', (kind, cols, expected) => { + expect(seriesFor(kind, { commit_sha: 'a', value: 1, ...cols })).toEqual(expected); +}); +``` + +- [ ] **Step 2: Write the integration tests.** There are two `describe.skipIf(!integrationDatabaseAvailable())` blocks, each with its own `startBenchDatabase()`. `expectedCharts(group, window)` builds the oracle: `JSON.stringify` of `{ name, slug, ...payload }` from `chartPayload(chartKeyFromSlug(link.slug), window)` for each link, nulls dropped. + - **Canonical fixture** (`seedChartFixture`): + - `it.each(['1', '2', '100', 'all'])('every group matches chartPayload byte for byte at ?n=%s')` checks `JSON.stringify((await collectGroupCharts(groupKeyFromSlug(g.slug), w))?.charts ?? []) === await expectedCharts(g, w)` for every group from `collectGroups()`. + - `it('issues two statements for a group of any size')` wraps `vi.spyOn(getPool(), 'query')` around `groupChartPayloads` for the TPC-H group and expects `toHaveBeenCalledTimes(2)`. + - `it('returns null in place of a chart with no rows')` passes `[Q1, { ...Q1, query_idx: 99 }]`. It expects `[1]` to be `null`, and `[0]` to match `chartPayload(Q1)` byte for byte. + - **Window-edge fixture.** Commits in order: `C0` 06-01, `C1` 06-02, `C2` 06-03, `D` and `E` both 06-04T00:00Z (SHAs `'d'.repeat(40)` and `'e'.repeat(40)`), `F` 06-05. Query rows have `commit_timestamp` stamped as the writers do. + - Group G is `('grp', NULL, NULL, 'nvme')`. Q1 has `e1:f1` = 10, 20, …, 60 and `e2:f1` on every commit. Q2 has `e1:f1` on E and F only. Q3 has `e1:f1` on C0 only. + - Group S is `('grp', NULL, '1', 'nvme')`, with Q1 `e1:f1` = 100, …, 105. + - Group T is `('grp', NULL, NULL, 's3')`, with Q1 `e1:f1` = 200, …, 205. + - Tests on the window-edge fixture: + - `it.each(['1', '2', '3', '4', 'all'])('group payloads match chartPayload byte for byte at ?n=%s')` covers G, S and T. + - `it('gives each chart its own window')` checks G at `?n=4`: + - Q1's history is `{6, 2, 4, false}`. + - Q2's history is `{3, 0, 3, true}`, and its commit SHAs are `[D, E, F]`. D is included because it ties with Q2's first row. + - Q3's series is `{}` and its history is `{6, 2, 4, false}`. + - `it('keeps sibling groups apart')`: at `?n=all`, G's Q1 `e1:f1` is `[10, 20, 30, 40, 50, 60]` and S's is `[100, 101, 102, 103, 104, 105]`. +- [ ] **Step 3: Run** `bun run --cwd bench/web test lib/group-charts.test.ts` with `BENCH_TEST_DATABASE_URL` set. Expect FAIL: `groupChartPayloads`, `historyFor` and `seriesFor` are not exported. +- [ ] **Step 4: Implement.** + - Lift `chartDisplayName`, `chartUnitKind` and `seriesFor` out of the five collectors, and make the collectors call them. Their SQL does not change. + - `historyFor`: `loaded = limit === null ? total : Math.min(limit, total)`, `start_index = Math.max(0, total - loaded)`, `complete = loaded === total`. + - `windowCommits` runs the following, with `$1 = commitWindowLimit(window)`. For `?n=all` that is `null`, which Postgres reads as no limit. + +```sql +SELECT commit_sha, to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS"+00"') AS ts_text, + COALESCE(message, '') AS message, url + FROM (SELECT * FROM commits ORDER BY timestamp DESC, commit_sha DESC LIMIT $1) w + ORDER BY timestamp ASC, commit_sha ASC +``` + + - `groupRowsQuery` builds one row per (chart, fact row in its window). A chart with no rows in the window still gets one row with NULL fact columns. Here is the `QueryGroup` shape; build each pin with `sargableDimEq`. + +```sql +SELECT ch.ord::int AS ord, t.total_commits, d.commit_sha, d.engine, d.format, d.value + FROM unnest($1::int[]) WITH ORDINALITY AS ch(query_idx, ord) + CROSS JOIN LATERAL ( + SELECT (SELECT count(*) FROM commits c + WHERE c.timestamp >= (SELECT MIN(q2.commit_timestamp) FROM query_measurements q2 + WHERE q2.dataset = $2 AND <q2 variant pin> AND <q2 scale pin> + AND q2.storage = $n AND q2.query_idx = ch.query_idx))::int AS total_commits + ) t + LEFT JOIN LATERAL ( + SELECT q.commit_sha, q.engine, q.format, q.value_ns::float8 AS value + FROM query_measurements q + WHERE q.dataset = $2 AND <q variant pin> AND <q scale pin> AND q.storage = $n + AND q.query_idx = ch.query_idx<queryMeasurementWindowFilter(params, window)> + ) d ON true +``` + + The other families use the same shape. Copy each chart collector's own `MIN` subquery and row query, and replace its per-chart binds with `ch.<column>`: + + | Group kind | `ch` columns (bound as arrays) | Pins from the group key | Row columns | Window filter | + |---|---|---|---|---| + | `QueryGroup` | `query_idx int[]` | dataset, dataset_variant, scale_factor (sargable), storage | engine, format | `queryMeasurementWindowFilter` | + | `CompressionTimeGroup` | `dataset text[]`, `dataset_variant text[]` | none | format, op | `factWindowFilter`, with `JOIN commits c USING (commit_sha)` | + | `CompressionSizeGroup` | same | none | format | same | + | `RandomAccessGroup` | `dataset text[]` | none (the datasets carry the storage) | format, open_mode | same | + | `VectorSearchGroup` | `threshold float8[]` | dataset, layout | flavor | same | + + - In the compression families, `dataset_variant` varies per chart, so match it with `IS NOT DISTINCT FROM ch.dataset_variant`. `dataset` still leads the index, and those tables are small (380K and 170K rows). + - The rows statement has no `ORDER BY`. The output doesn't depend on row order: each (series, commit) cell is unique by the fact tables' natural keys, and `finish()` sorts series keys. + - `groupChartPayloads` returns `[]` for no charts. Otherwise it runs `Promise.all([windowCommits(window), getPool().query(groupRowsQuery(...))])` and groups the rows by `ord`. For each chart: + - `total === 0` gives `null`. + - Otherwise compute `history = historyFor(total, window)`, then seed `commits.slice(commits.length - history.loaded_commits)` into a fresh `SeriesAccumulator`. + - For each row with a non-null `commit_sha` whose SHA is seeded, call `record` and `tag` with `seriesFor(key.k, row)`. + - Finish with `chartDisplayName(key)`, `chartUnitKind(key)` and `history`. + - Why this is correct: a chart's eligible commits (timestamp at or after its first row) are a suffix of all commits in (timestamp, sha) order. So its last-`n` window is the last `min(n, total)` entries of the shared last-`n` window, ties included, which is exactly what `seededCommitsInWindow` returns. + - `collectGroupCharts` replaces its `mapWithConcurrency` fan-out with one `groupChartPayloads(key, group.charts.map((l) => chartKeyFromSlug(l.slug)), window)`. It zips the result with the links as `{ name, slug, ...payload }` and drops nulls. Update the doc comments of `QUERY_CONCURRENCY` and `collectGroupCharts`. +- [ ] **Step 5: Run** the new file, then the whole suite. Expect PASS at the Task 2 count plus the new tests. `groups.test.ts`'s `keeps a group fill within the pool size` (peak at most 7) still passes. +- [ ] **Step 6: Check bytes and timing on the restored dump.** + - Start the parity cluster: `/opt/homebrew/opt/postgresql@16/bin/pg_ctl -D "$TMPDIR/bench-parity/pgdata" -l "$TMPDIR/bench-parity/postgres.log" -w start -o "-p 55432 -c listen_addresses=127.0.0.1 -c unix_socket_directories="`. + - Save the script below as `$TMPDIR/group-check.ts`. From `bench/web`, run it with `env BENCH_DB_HOST=127.0.0.1 BENCH_DB_PORT=55432 BENCH_DB_NAME=vortex_bench BENCH_DB_USER=postgres BENCH_DB_PASSWORD=parity BENCH_DB_SSL=disable N=100 bun -e "$(cat "$TMPDIR/group-check.ts")"`. If Bun can't resolve `./lib` from `-e`, copy the script into `bench/web` for the run and delete it afterwards. + +```ts +import { createHash } from 'node:crypto'; +import { resetPool } from './lib/db.ts'; +import { chartPayload, collectGroups, groupChartPayloads } from './lib/queries.ts'; +import { chartKeyFromSlug, groupKeyFromSlug } from './lib/slug.ts'; +import { parseCommitWindow } from './lib/window.ts'; +const w = parseCommitWindow(process.env.N ?? '100'); +const mode = process.env.MODE ?? 'both'; +const digest = (v: unknown) => createHash('sha256').update(JSON.stringify(v)).digest('hex').slice(0, 12); +for (const g of await collectGroups()) { + if (process.env.GROUP && g.name !== process.env.GROUP) continue; + const keys = g.charts.map((l) => chartKeyFromSlug(l.slug)); + const line: unknown[] = [g.name, keys.length]; + if (mode !== 'new') { + const t = performance.now(); const old: unknown[] = []; + for (let i = 0; i < keys.length; i += 7) old.push(...(await Promise.all(keys.slice(i, i + 7).map((k) => chartPayload(k, w))))); + line.push(`old ${Math.round(performance.now() - t)} ms ${digest(old)}`); + } + if (mode !== 'old') { + const t = performance.now(); const now = await groupChartPayloads(groupKeyFromSlug(g.slug), keys, w); + line.push(`new ${Math.round(performance.now() - t)} ms ${digest(now)}`); + } + console.log(line.join(' ')); +} +await resetPool(); +``` + + - What passes: run `N=100` and `N=50` twice each and read the second run, which is warm. On every line the old and new digests match. For the five TPC-DS groups and Clickbench, new ms is no more than old ms. + - Then, with `GROUP='TPC-DS (NVMe) (SF=1)' N=all`, run `MODE=old` and `MODE=new` separately under `/usr/bin/time -l`. The digests match, and new's wall time and "maximum resident set size" are each at most 1.5× old's. + - If not, run the rows statement in slices of 25 charts (`GROUP_ROWS_CHUNK = 25`), one after another, and measure again. + - Stop the cluster with `pg_ctl -D "$TMPDIR/bench-parity/pgdata" -m fast stop`, and keep the numbers for the docs and the PR. +- [ ] **Step 7: Update `performance.md`.** Rename "Parallel group fan-out" to "One query per group". In about eight lines, cover the two statements, the window slicing, `QUERY_CONCURRENCY` now bounding only the summaries, and the Step 6 numbers. +- [ ] **Step 8: Verify** `bun run --cwd bench/web lint`, `format:check`, `bunx tsc --noEmit -p bench/web` and `bun run --cwd bench/web build`. **Commit:** `Fetch a bench group's charts with two queries, not two per chart`. + +### Task 4: Split `Chart.tsx` and `chart-store.ts` into modules + +**Files:** +- Create: `bench/web/lib/chart-store/queues.ts`, `bundles.ts`, `global-filter.ts`, `group-store.ts`, `index.ts` +- Delete: `bench/web/lib/chart-store.ts` +- Create: `bench/web/components/chart/types.ts`, `datasets.ts`, `tooltip.ts`, `config.ts`, `card-dom.ts`, `pan-zoom.ts`, `controller.ts` +- Modify: `bench/web/components/Chart.tsx` +- Test: `bench/web/components/Chart.loading.test.tsx` (one new test) + +**Interfaces:** +- `'@/lib/chart-store'` keeps its exact export surface through `lib/chart-store/index.ts`, which holds `export * from` each module. No importer or test changes; `vi.spyOn(hydrationQueue, 'schedule')` still sees the same object. +- Produces in `components/chart/` (no `'use client'`; `Chart.tsx` stays the client boundary): + - `types.ts`: `export interface BenchDataset`, `CardState`, `CardElements`, `CardCallbacks` (unchanged bodies) + - `datasets.ts`: `export function benchDatasets(chart: ChartJs): BenchDataset[]`; `export function buildDatasets(payload: NormalizedChartPayload): BenchDataset[]` + - `tooltip.ts`: `export function externalTooltipHandler(state: CardState, host: HTMLDivElement | null): (context: { chart: ChartJs; tooltip: TooltipModel<'line'> }) => void` + - `config.ts`: `GRID_COLOR`, `crosshairPlugin`, and `export function lineChartConfig(input: { Chart: typeof ChartJs; state: CardState; tooltipHost: HTMLDivElement | null; labels: string[]; datasets: BenchDataset[]; range: VisibleRange; legendPosition: 'top' | 'bottom'; onZoom: (chart: ChartJs) => void }): ChartConfiguration<'line', (number | null)[], string>` + - `card-dom.ts`: `renderWindowChip(chip: HTMLButtonElement | null, state: CardState): void`; `renderDownsampleBadge(badge: HTMLSpanElement | null, keptCommits: number, visibleCommits: number, anyDownsampled: boolean): void`; `setSliderFromRange(slider: HTMLInputElement | null, visibleCommits: number): void`; `setSliderBounds(slider: HTMLInputElement | null, commitCount: number): void` + - `pan-zoom.ts`: `attachWheelPan(canvas: HTMLCanvasElement, chart: ChartJs, state: CardState, rebuild: (chart: ChartJs) => void, signal: AbortSignal): void`; `bindRangeStrip(chart: ChartJs, els: Pick<CardElements, 'strip' | 'stripWindow'>, state: CardState, rebuildRange: (chart: ChartJs, min: number, max: number) => void, signal: AbortSignal): void` + - `controller.ts`: `export class ChartController`, with the same constructor and public methods + +Where code goes. Line numbers are as of `702aeeb`; PR 1's reformat may shift them, so the symbol names decide. + +| Destination | Moves | +|---|---| +| `lib/chart-store/queues.ts` | `QueueEntry`, `InternalEntry`, `TaskQueue`, `makeQueue`, `hydrationQueue`, `fullHistoryQueue`, `bundleQueue` (37–118) | +| `lib/chart-store/bundles.ts` | payload cache, `BundleResult`, settled and in-flight maps, `subscribeGroupBundleRetry`, `retryGroupBundle`, `resetPayloadCache`, `ensureGroupBundle`, `abortGroupBundle` (120–306) | +| `lib/chart-store/global-filter.ts` | `GlobalFilterSnapshot` through `toggleGlobalFilterValue` (308–388) | +| `lib/chart-store/group-store.ts` | `GroupSnapshot` through `noteGroupSeries` (390–632) | +| `components/chart/types.ts` | 108–178 | +| `components/chart/config.ts` | `GRID_COLOR`, `crosshairPlugin` (180–217), and the config literal of `new Chart(canvas, {...})` in `maybeConstruct` (915–1044) | +| `components/chart/datasets.ts` | `benchDatasets`, `buildDatasets` (219–270) | +| `components/chart/tooltip.ts` | `externalTooltipHandler` (272–395) | +| `components/chart/card-dom.ts` | bodies of `syncSliderFromRange`, `syncDownsampleBadge`, `syncWindowChip`, `syncSliderBounds` (1223–1326, 1379–1397) | +| `components/chart/pan-zoom.ts` | bodies of `attachWheelPan`, `bindRangeStrip` (1399–1607) | +| `components/chart/controller.ts` | the rest of `ChartController` (397–1753) | +| `components/Chart.tsx` keeps | `'use client'`, the architecture comment, `ChartIslandProps`, `Chart` (1755–2200) | + +Moves are verbatim, with their comments. The only other edits allowed: +- `export` keywords and import lists. +- In moved methods, `this.state`, `this.els()` and `this.aborter.signal` become parameters. +- Each moved private method leaves a one-line delegator with the same name, so no call site inside the controller changes. +- `maybeConstruct` calls `lineChartConfig({...})`, and the `state_` alias goes. +- In `group-store.ts`, the two reads of the module variable `globalSnapshot` become `getGlobalFilterSnapshot()`. + +- [ ] **Step 1: Write the pin test** in `Chart.loading.test.tsx`: `it('pans once per wheel event and stops after unmount')`. + - Render one card in an open group under `<StrictMode>`. The bundle returns 404, and the chart fetch returns `completePayload(20)`. + - Resolve `loadChartJs` to a `StubChart` that keeps `config.options` and has `pan = vi.fn()`, `update()` and `destroy()`. + - After construction, assert `stub.options.plugins.tooltip.enabled === false` and that `stub.options.plugins.zoom.limits.x` equals `{ min: 0, max: 19, minRange: 4 }`. + - Dispatch `new WheelEvent('wheel', { deltaY: 40, cancelable: true })` on the canvas. Expect `pan` to be called once, with `({ x: 20 }, undefined, 'none')`. + - Unmount, dispatch again on the same canvas node, and expect still one call. +- [ ] **Step 2: Run it on the unsplit code.** Expect PASS: it pins today's behavior. +- [ ] **Step 3: Split `chart-store.ts`** following the table, and run the whole suite. Expect the Task 3 count. Lint and type-check. **Commit:** `Split bench's chart store into modules`. +- [ ] **Step 4: Split `Chart.tsx`** following the table. +- [ ] **Step 5: Verify.** + - The suite passes at the Task 3 count plus 1, and lint, type-check and build pass. + - `wc -l` shows `Chart.tsx` under 600 lines, `controller.ts` under 950, and every other new module under 300. + - `git diff --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change HEAD -- bench/web/components` dims everything except imports, exports, delegators and the parameter substitutions. +- [ ] **Step 6: Commit:** `Split bench's chart island into modules`. + +### Task 5: Remove test-only helpers and adopt the site's stricter flags + +**Files:** +- Modify: `bench/web/lib/chart-format.ts` and `lib/chart-format.test.ts` (delete `PALETTE`, `colorFor`, `assignStableColors`, `labelForCommit` and their tests); `lib/window.ts` and `lib/window.test.ts` (delete `commitWindowUrlValue` and its test) +- Modify: `tsconfig.base.json` (add `"noUncheckedIndexedAccess": true`, `"exactOptionalPropertyTypes": true`), `tsconfig.json` (remove both; it inherits them) +- Modify: the files tsc reports. Measured on 2026-10-28 with TS 5.9 on the unsplit tree: 138 + 7 = 145 errors. + - About 33 are in app code: `lib/chart-format.ts` 20, `lib/queries.ts` 2, the four from `Chart.tsx` (now in `components/chart/*`), `app/page.tsx` 2, `app/chart/[slug]/page.tsx` 1, `components/GroupSection.tsx` 1, `lib/random-access.ts` 1, `lib/db.ts` 1, `lib/test-harness.ts` 1. + - About 112 are in tests: `groups.test.ts` 36, `Chart.lazy-hydration.test.tsx` 29, `summary.test.ts` 23, `Chart.loading.test.tsx` 9, `cache-fill.test.ts` 5, and a few in others. + +**Interfaces:** +- Consumes: Task 3's `queries.ts` and Task 4's modules. This task goes last so its edits land on their final locations. + +- [ ] **Step 1: Delete the helpers only tests use.** Check with `grep -rlw -e assignStableColors -e colorFor -e PALETTE -e labelForCommit -e commitWindowUrlValue bench/web/app bench/web/components bench/web/lib`: only the defining files and their tests may appear. Delete the helpers and their tests. Run the suite. **Commit:** `Remove bench helpers that only tests used`. +- [ ] **Step 2: Move the flags into the base.** `bunx tsc --showConfig -p .` must list both flags as `true` before and after. Then run `bunx tsc --noEmit -p bench/web` and record the error count, about 145. +- [ ] **Step 3: Fix without changing behavior.** + - For `exactOptionalPropertyTypes`, add `| undefined` to the optional property's declared type (`summary?: Summary | undefined`, `benchMeta`, `__benchDbPool?: Pool | undefined`). Never change which keys an object carries. + - For `noUncheckedIndexedAccess` in app code, use `!` only where the index is in range by construction: a loop bound, a prior length check, `% length`, or `split()[0]`. Otherwise add an explicit `=== undefined` check whose branch does what today's code does with `undefined`. Never add a new `?? 0`, `?? ''` or `?? []`. + - In tests, use `!` or the existing `expectDefined`. +- [ ] **Step 4: Verify.** + - `bunx tsc --noEmit -p bench/web` and the root `bun run typecheck` both report 0 errors. + - The suite passes at the count after Step 1. + - `bun run check:ci`, `bun run --cwd bench/web build` and `bun run build` pass. +- [ ] **Step 5: Commit:** `Type-check bench with the site's stricter flags`. + +### Task 6: Verify, push and open the draft PR + +- [ ] **Step 1: Run the local gates.** + - Build, then run the whole suite with `BENCH_TEST_DATABASE_URL` set. Everything passes, with nothing skipped. + - `bun run check:ci`, `bunx tsc --noEmit -p bench/web`, `bun run typecheck`, `bun run --cwd bench/web build` and `bun run build` pass. + - `uvx reuse lint` passes. + - `git diff --exit-code origin/claude/bench-toolchain -- bench/web/vercel.json` exits 0. +- [ ] **Step 2: Run parity once.** `bash bench/scripts/parity/run.sh origin/claude/bench-toolchain ~/Backups/vortex-bench/vortex_bench-20261028T171815Z` must print `parity: 0 mismatches`. + - If PR 1 has merged, merge `origin/main` into this branch (never rebase: Auto-fix watches it) and use `origin/main` as the base. + - On a difference, run `git bisect start HEAD <base>` and then `git bisect run bash bench/scripts/parity/run.sh <base> <dump>`. `run.sh` exits 2 on setup errors, which bisect counts as bad, so read the log of the commit it names. + - Fix the cause, or explain in the PR why it is negligible. +- [ ] **Step 3: Push** with `git push -u origin claude/bench-cleanup`. +- [ ] **Step 4: Open a draft PR.** Run `gh pr create --draft --base claude/bench-toolchain` (or `main`). + - Write the body, about 100 words in Simplified Technical English, with the spiral:pr-and-issue-voice skill. + - It names the parity result and the Task 3 timings, and links the design doc. + - It says that `BENCH_DB_REGION` on the Vercel project is now unused, and that deleting it is Will's call. + - It ends with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + - Show the body in the session before creating the PR (Will pre-authorized opening it as a draft). +- [ ] **Step 5: Check the CI evidence.** + - `Bench: format, lint, build, test` passes, and its log shows the database suites ran. + - `Bench Deploy` builds the preview, and `verify-web-deploy.mjs` passes. +- [ ] **Step 6: Hand to Will** for the smoke test in the preview: + - Open TPC-DS (NVMe) (SF=1) and use Expand All. + - Click a chart's "load all" chip. + - Open one permalink page. From dd82f685b959b92ef9ce6daefd2103a85358da26 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 01:09:49 +0100 Subject: [PATCH 21/34] Let bench's integration tests use a local Postgres The database suites needed Docker, so they skipped on a Mac without it. Set BENCH_TEST_DATABASE_URL to a Postgres server and each suite creates a throwaway bench_test_* database there, applies the migrations, and drops the database when it ends. A Postgres advisory lock serializes the first applies, because the migrations create cluster-wide roles. Without the URL, the suites use testcontainers as before. When CI=true and neither Docker nor the URL is available, the suites now throw instead of skipping, so a broken runner cannot pass with them off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- CLAUDE.md | 2 +- bench/web/README.md | 19 ++- bench/web/lib/db.test.ts | 15 +- bench/web/lib/groups.test.ts | 26 +-- bench/web/lib/health.test.ts | 15 +- bench/web/lib/queries.test.ts | 26 +-- bench/web/lib/server-smoke.test.ts | 17 +- bench/web/lib/test-harness.test.ts | 47 ++++++ bench/web/lib/test-harness.ts | 250 ++++++++++++++++++++++++----- 9 files changed, 332 insertions(+), 85 deletions(-) create mode 100644 bench/web/lib/test-harness.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 23ecbcf..0859703 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `bun lint` - Run Biome lint - `bun start` - Start production server -bench is a member of the root Bun workspace. `bun install` at the root installs both apps; run bench's scripts with `bun run --cwd bench/web <script>`: `dev`, `build`, `lint`, `format`, `format:check`, `test`. `lint` and `format` are Biome, as at the root. The Postgres integration suite needs Docker and skips without it. +bench is a member of the root Bun workspace. `bun install` at the root installs both apps; run bench's scripts with `bun run --cwd bench/web <script>`: `dev`, `build`, `lint`, `format`, `format:check`, `test`. `lint` and `format` are Biome, as at the root. The Postgres integration suite needs Docker or a local Postgres named by `BENCH_TEST_DATABASE_URL` (`bench/web/README.md` has the commands), and skips without either, except when `CI=true`, where it fails. ## Repository layout diff --git a/bench/web/README.md b/bench/web/README.md index e54006b..3a90f65 100644 --- a/bench/web/README.md +++ b/bench/web/README.md @@ -17,9 +17,26 @@ bun run --cwd bench/web dev # needs BENCH_DB_* pointing at a database bun run --cwd bench/web format:check # biome format bun run --cwd bench/web lint # biome lint bun run --cwd bench/web build # next build; deliberately works WITHOUT a database -bun run --cwd bench/web test # vitest; the Postgres integration suite needs Docker and uv +bun run --cwd bench/web test # vitest; the Postgres integration suite needs Docker, or BENCH_TEST_DATABASE_URL, and uv ``` +Without Docker, run the integration suite against a local Postgres. This starts a throwaway +cluster in `$TMPDIR` on port 55433, which stays clear of the parity cluster on 55432. `--locale=C` +matches the collation of the CI container: + +```bash +PG=/opt/homebrew/opt/postgresql@16/bin +$PG/initdb -D "$TMPDIR/bench-test-pg" -U postgres -A trust -E UTF8 --locale=C +$PG/pg_ctl -D "$TMPDIR/bench-test-pg" -l "$TMPDIR/bench-test-pg.log" -w start \ + -o "-p 55433 -c listen_addresses=127.0.0.1 -c unix_socket_directories= -c fsync=off" +export BENCH_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres +``` + +`BENCH_TEST_DATABASE_URL` names an existing server, as a superuser, and its admin database. Each +suite creates a `bench_test_*` database there and drops it when it ends. Stop the cluster with +`$PG/pg_ctl -D "$TMPDIR/bench-test-pg" stop`. In CI (`CI=true`), a runner with neither Docker nor +the variable fails the suite instead of skipping it. + `next build` never touches the database: every page and route is request-rendered (`force-dynamic` or request-URL-dependent), so builds are reproducible with no `BENCH_DB_*` configured. Keep it that way; the CI `test` job builds with no database on purpose. diff --git a/bench/web/lib/db.test.ts b/bench/web/lib/db.test.ts index 3b9b081..4e2e924 100644 --- a/bench/web/lib/db.test.ts +++ b/bench/web/lib/db.test.ts @@ -2,9 +2,12 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { Signer } from '@aws-sdk/rds-signer'; -import { dockerAvailable, startBenchContainer } from './test-harness'; +import { + type BenchDatabase, + integrationDatabaseAvailable, + startBenchDatabase, +} from './test-harness'; import { buildQuery, getPool, @@ -34,15 +37,17 @@ vi.mock('@aws-sdk/rds-signer', () => ({ }), })); -describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres)', () => { - let container: StartedPostgreSqlContainer; +const databaseAvailable = integrationDatabaseAvailable(); + +describe.skipIf(!databaseAvailable)('db pool roundtrip (testcontainers Postgres)', () => { + let container: BenchDatabase; beforeAll(async () => { // The pool roundtrip needs no schema; the BENCH_DB_PASSWORD fixture path // set by the harness means IAM token generation is bypassed. vi.stubEnv('BENCH_DB_POOL_MAX', '1'); vi.stubEnv('BENCH_DB_CONNECTION_TIMEOUT_MS', '1000'); - container = await startBenchContainer({ applySchema: false }); + container = await startBenchDatabase({ applySchema: false }); }); afterAll(async () => { diff --git a/bench/web/lib/groups.test.ts b/bench/web/lib/groups.test.ts index 5ed7769..a6c66fc 100644 --- a/bench/web/lib/groups.test.ts +++ b/bench/web/lib/groups.test.ts @@ -13,7 +13,6 @@ vi.mock('next/cache', () => ({ revalidateTag: () => {}, })); -import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { collectGroupCharts, collectGroups, @@ -26,10 +25,11 @@ import { READ_API_CACHE_CONTROL } from './cache'; import { collectGroupSummary } from './summary'; import { chartKeyToSlug, groupKeyFromSlug, groupKeyToSlug } from './slug'; import { + type BenchDatabase, commitUrl, - dockerAvailable, + integrationDatabaseAvailable, seedChartFixture, - startBenchContainer, + startBenchDatabase, TREE_SHA, } from './test-harness'; import { parseCommitWindow } from './window'; @@ -44,16 +44,18 @@ function expectDefined<T>(value: T | null | undefined, message: string): T { return value; } +const databaseAvailable = integrationDatabaseAvailable(); + // The canonical fixture's summary maths (ratio 2.0, geomean 0.5, the query // missing-series penalty) reproduce the v2-contract values asserted by // `server/tests/group_api.rs`; see `seedChartFixture` in `./test-harness`. -describe.skipIf(!dockerAvailable())( +describe.skipIf(!databaseAvailable)( 'collectGroups + group endpoints (testcontainers Postgres)', () => { - let container: StartedPostgreSqlContainer; + let container: BenchDatabase; beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); await seedChartFixture(getPool()); }); @@ -363,8 +365,8 @@ describe.skipIf(!dockerAvailable())( // Query-summary penalty model: // - the 300_000 ns penalty floor is exercised (all runtimes below it) so a // missing query flips the ranking, pinning the floor + ratio formula. -describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postgres)', () => { - let container: StartedPostgreSqlContainer; +describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postgres)', () => { + let container: BenchDatabase; let measurementId = 0; function nextId(): number { @@ -415,7 +417,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg } beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); }); afterAll(async () => { @@ -858,16 +860,16 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg // NULL-stamped row must not beat an older stamped row; an all-NULL series must // still appear via the fallback arm), and most of the discovery successor's // NULL-partition branches never execute. This block seeds exactly those shapes. -describe.skipIf(!dockerAvailable())( +describe.skipIf(!databaseAvailable)( 'read-path skip-scan fidelity (testcontainers Postgres)', () => { - let container: StartedPostgreSqlContainer; + let container: BenchDatabase; const COMMIT_A = 'a'.repeat(40); const COMMIT_B = 'b'.repeat(40); beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); const pool = getPool(); // COMMIT_B is NEWER than COMMIT_A: the NULLS-LAST probe must still prefer // COMMIT_A's stamped row over COMMIT_B's unstamped one. diff --git a/bench/web/lib/health.test.ts b/bench/web/lib/health.test.ts index 285f42e..b6a351b 100644 --- a/bench/web/lib/health.test.ts +++ b/bench/web/lib/health.test.ts @@ -2,16 +2,21 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { collectHealth } from './health'; import { resetPool } from './db'; -import { dockerAvailable, startBenchContainer } from './test-harness'; +import { + type BenchDatabase, + integrationDatabaseAvailable, + startBenchDatabase, +} from './test-harness'; -describe.skipIf(!dockerAvailable())('collectHealth (testcontainers Postgres)', () => { - let container: StartedPostgreSqlContainer; +const databaseAvailable = integrationDatabaseAvailable(); + +describe.skipIf(!databaseAvailable)('collectHealth (testcontainers Postgres)', () => { + let container: BenchDatabase; beforeAll(async () => { - container = await startBenchContainer({ applySchema: false }); + container = await startBenchDatabase({ applySchema: false }); vi.stubEnv('VERCEL_GIT_COMMIT_SHA', undefined); }); diff --git a/bench/web/lib/queries.test.ts b/bench/web/lib/queries.test.ts index f9280e2..8a4ea81 100644 --- a/bench/web/lib/queries.test.ts +++ b/bench/web/lib/queries.test.ts @@ -13,15 +13,15 @@ vi.mock('next/cache', () => ({ revalidateTag: () => {}, })); -import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { READ_API_CACHE_CONTROL } from './cache'; import { chartPayload, collectFilterUniverse, compareGroupSortKey } from './queries'; import { chartKeyToSlug, type ChartKey } from './slug'; import { + type BenchDatabase, commitUrl, - dockerAvailable, + integrationDatabaseAvailable, seedChartFixture, - startBenchContainer, + startBenchDatabase, TREE_SHA, } from './test-harness'; import { parseCommitWindow } from './window'; @@ -37,11 +37,13 @@ const QUERY_Q1: ChartKey = { query_idx: 1, }; -describe.skipIf(!dockerAvailable())('chartPayload (testcontainers Postgres)', () => { - let container: StartedPostgreSqlContainer; +const databaseAvailable = integrationDatabaseAvailable(); + +describe.skipIf(!databaseAvailable)('chartPayload (testcontainers Postgres)', () => { + let container: BenchDatabase; beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); await seedChartFixture(getPool()); }); @@ -414,13 +416,13 @@ describe('GET /api/chart/[slug] input validation (no DB)', () => { // A long synthetic history exercises the bounded-vs-full window placement math // (`history.start_index` / `total_commits` / `complete`), mirroring // `chart_api.rs::chart_api_reports_virtual_history_for_bounded_and_full_windows`. -describe.skipIf(!dockerAvailable())('chartPayload history placement (long synthetic run)', () => { - let container: StartedPostgreSqlContainer; +describe.skipIf(!databaseAvailable)('chartPayload history placement (long synthetic run)', () => { + let container: BenchDatabase; const N = 125; const RANDOM_ACCESS: ChartKey = { k: 'RandomAccess', dataset: 'taxi' }; beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); const pool = getPool(); let id = 0; for (let i = 0; i < N; i += 1) { @@ -491,8 +493,8 @@ describe.skipIf(!dockerAvailable())('chartPayload history placement (long synthe // shared fixture (every commit has every row) cannot exercise: the null-gap // x-axis seeding, pre-history exclusion, IS NOT DISTINCT FROM NULL-dim equality, // and the commit_sha tie-break under identical timestamps. -describe.skipIf(!dockerAvailable())('chartPayload seeded-window semantics', () => { - let container: StartedPostgreSqlContainer; +describe.skipIf(!databaseAvailable)('chartPayload seeded-window semantics', () => { + let container: BenchDatabase; // Distinct SHAs. C is pre-history purely by its earlier timestamp (its SHA // rank is irrelevant); the tie-break test relies only on E > D lexically. const C = 'c'.repeat(40); @@ -502,7 +504,7 @@ describe.skipIf(!dockerAvailable())('chartPayload seeded-window semantics', () = const E = 'e'.repeat(40); beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); const pool = getPool(); // C is pre-history for the `qn`/`qnull` charts; D and E share a timestamp. const commits: ReadonlyArray<readonly [string, string]> = [ diff --git a/bench/web/lib/server-smoke.test.ts b/bench/web/lib/server-smoke.test.ts index b4c07bf..d725316 100644 --- a/bench/web/lib/server-smoke.test.ts +++ b/bench/web/lib/server-smoke.test.ts @@ -4,12 +4,16 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { getPool, resetPool } from './db'; import { chartKeyToSlug, groupKeyToSlug, type ChartKey, type GroupKey } from './slug'; -import { dockerAvailable, seedChartFixture, startBenchContainer } from './test-harness'; +import { + type BenchDatabase, + integrationDatabaseAvailable, + seedChartFixture, + startBenchDatabase, +} from './test-harness'; // End-to-end smoke: boot the REAL production server (`next start` over the // existing `.next` build) against a seeded testcontainers Postgres and assert @@ -22,6 +26,7 @@ import { dockerAvailable, seedChartFixture, startBenchContainer } from './test-h // (`bun run --cwd bench/web build` first); both are environment, not code, conditions. const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const databaseAvailable = integrationDatabaseAvailable(); const BUILD_PRESENT = existsSync(`${WEB_ROOT}/.next/BUILD_ID`); const PORT = 4319; const BASE = `http://127.0.0.1:${PORT}`; @@ -66,18 +71,18 @@ async function waitForServer(url: string, timeoutMs: number): Promise<void> { throw new Error(`server did not become ready at ${url}: ${String(lastErr)}`); } -describe.skipIf(!dockerAvailable() || !BUILD_PRESENT)( +describe.skipIf(!databaseAvailable || !BUILD_PRESENT)( 'production-server smoke (next start + seeded Postgres)', () => { - let container: StartedPostgreSqlContainer; + let container: BenchDatabase; let server: ChildProcess | null = null; beforeAll(async () => { - container = await startBenchContainer(); + container = await startBenchDatabase(); await seedChartFixture(getPool()); server = spawn('node_modules/.bin/next', ['start', '-p', String(PORT)], { cwd: WEB_ROOT, - // `startBenchContainer` exported the BENCH_DB_* connection env on + // `startBenchDatabase` exported the BENCH_DB_* connection env on // process.env; the server child inherits it. env: { ...process.env, NODE_ENV: 'production' }, stdio: 'ignore', diff --git a/bench/web/lib/test-harness.test.ts b/bench/web/lib/test-harness.test.ts new file mode 100644 index 0000000..b7903db --- /dev/null +++ b/bench/web/lib/test-harness.test.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { describe, expect, it } from 'vitest'; +import { integrationDatabaseAvailable, testDatabaseSettings } from './test-harness'; + +describe('testDatabaseSettings', () => { + it('reads host, port, user and admin database, with a placeholder password', () => { + expect(testDatabaseSettings('postgres://postgres@127.0.0.1:55433/postgres')).toEqual({ + host: '127.0.0.1', + port: 55433, + user: 'postgres', + password: 'trust', + adminDatabase: 'postgres', + }); + }); + + it('decodes the password and defaults the port and admin database', () => { + expect(testDatabaseSettings('postgresql://will:p%40ss@localhost')).toEqual({ + host: 'localhost', + port: 5432, + user: 'will', + password: 'p@ss', + adminDatabase: 'postgres', + }); + }); + + it.each(['mysql://u@h/db', 'postgres://h/db'])('rejects %s', (url) => { + expect(() => testDatabaseSettings(url)).toThrow('BENCH_TEST_DATABASE_URL'); + }); +}); + +describe('integrationDatabaseAvailable', () => { + it('prefers BENCH_TEST_DATABASE_URL, then Docker', () => { + expect( + integrationDatabaseAvailable({ BENCH_TEST_DATABASE_URL: 'postgres://u@h/db' }, () => false), + ).toBe(true); + expect(integrationDatabaseAvailable({}, () => true)).toBe(true); + expect(integrationDatabaseAvailable({}, () => false)).toBe(false); + }); + + it('fails loudly in CI when no database is available', () => { + expect(() => integrationDatabaseAvailable({ CI: 'true' }, () => false)).toThrow( + 'BENCH_TEST_DATABASE_URL', + ); + }); +}); diff --git a/bench/web/lib/test-harness.ts b/bench/web/lib/test-harness.ts index ad77e89..2463f8b 100644 --- a/bench/web/lib/test-harness.ts +++ b/bench/web/lib/test-harness.ts @@ -2,24 +2,75 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors /** - * Shared testcontainers harness for the DB-backed vitest suites. This module - * is imported only by `*.test.ts` files; it never reaches a production bundle. + * Shared harness for the DB-backed vitest suites. This module is imported only + * by `*.test.ts` files; it never reaches a production bundle. * * It centralizes the pieces the suites previously copy-pasted (and that had - * already begun to drift in name): the Docker probe, the migration runner, the - * container-boot + `BENCH_DB_*` env wiring, and the canonical three-commit + * already begun to drift in name): the database probe, the migration runner, + * the database boot + `BENCH_DB_*` env wiring, and the canonical three-commit * chart fixture mirroring `server/tests/common/mod.rs`. + * + * A suite gets its database from one of two places. With + * `BENCH_TEST_DATABASE_URL` set it gets a throwaway database on that server; + * otherwise it gets a throwaway Postgres container from Docker. */ import { execFileSync, execSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { fileURLToPath } from 'node:url'; -import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import type { Pool } from 'pg'; +import { PostgreSqlContainer } from '@testcontainers/postgresql'; +import { Client, type Pool } from 'pg'; + +/** A disposable Postgres database. Callers own teardown: resetPool(), then stop(). */ +export interface BenchDatabase { + stop(): Promise<void>; +} + +/** How to reach the server named by `BENCH_TEST_DATABASE_URL`. */ +export interface TestDatabaseSettings { + host: string; + port: number; + user: string; + password: string; + adminDatabase: string; +} + +const URL_VARIABLE = 'BENCH_TEST_DATABASE_URL'; -// Mirror the repo's Python `_docker_available()` precedent: the integration -// tests need a Docker daemon, so they are skipped (not failed) when one is -// absent. -export function dockerAvailable(): boolean { +/** Trust auth ignores the password, but `BENCH_DB_PASSWORD` must not be empty. */ +const TRUST_PASSWORD = 'trust'; + +/** Serializes first applies of the migrations: they create cluster-wide roles, and two at once + * race on `CREATE ROLE`. The two keys spell "benc" and "test". */ +const MIGRATION_LOCK_KEYS: readonly [number, number] = [0x62656e63, 0x74657374]; + +/** Read the server address from a `postgres://user[:password]@host[:port][/database]` URL. */ +export function testDatabaseSettings(url: string): TestDatabaseSettings { + const invalid = new Error( + `${URL_VARIABLE} must be a postgres:// URL that names a user, such as ` + + 'postgres://postgres@127.0.0.1:55433/postgres', + ); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw invalid; + } + if ((parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') || !parsed.username) { + throw invalid; + } + return { + host: parsed.hostname.replace(/^\[|\]$/g, ''), + port: parsed.port === '' ? 5432 : Number(parsed.port), + user: decodeURIComponent(parsed.username), + password: parsed.password === '' ? TRUST_PASSWORD : decodeURIComponent(parsed.password), + adminDatabase: decodeURIComponent(parsed.pathname.slice(1)) || 'postgres', + }; +} + +// Mirror the repo's Python `_docker_available()` precedent: without a database +// URL, the integration tests need a Docker daemon. +function dockerAvailable(): boolean { try { execSync('docker info', { stdio: 'ignore' }); return true; @@ -28,48 +79,161 @@ export function dockerAvailable(): boolean { } } -/** Start a disposable Postgres instance and apply the actual migration runner. - * Callers own teardown: resetPool(), then container.stop(). Requires uv on PATH. +/** Whether the integration suites can get a database: `BENCH_TEST_DATABASE_URL` is set, or Docker + * runs. The suites skip (not fail) when neither is true, except in CI, where a skip would let a + * broken runner pass with the database suites silently off, so it throws. + */ +export function integrationDatabaseAvailable( + env: Readonly<Record<string, string | undefined>> = process.env, + docker: () => boolean = dockerAvailable, +): boolean { + const url = env[URL_VARIABLE]; + if (url !== undefined && url !== '') { + return true; + } + if (docker()) { + return true; + } + if (env.CI === 'true') { + throw new Error( + `The integration tests need Docker or ${URL_VARIABLE}, and CI has neither. Refusing to skip them.`, + ); + } + return false; +} + +/** Apply the actual migration runner to one database. Requires uv on PATH. */ +function applyMigrations(settings: { + host: string; + port: number; + database: string; + user: string; + password: string; +}): void { + execFileSync( + 'uv', + [ + 'run', + '--no-project', + fileURLToPath(new URL('../../scripts/migrate-schema.py', import.meta.url)), + 'apply', + ], + { + env: { + ...process.env, + PGHOST: settings.host, + PGPORT: String(settings.port), + PGDATABASE: settings.database, + PGUSER: settings.user, + PGPASSWORD: settings.password, + PGSSLMODE: 'disable', + }, + stdio: 'pipe', + }, + ); +} + +function exportConnectionEnv(settings: { + host: string; + port: number; + database: string; + user: string; + password: string; +}): void { + process.env.BENCH_DB_HOST = settings.host; + process.env.BENCH_DB_PORT = String(settings.port); + process.env.BENCH_DB_NAME = settings.database; + process.env.BENCH_DB_USER = settings.user; + process.env.BENCH_DB_PASSWORD = settings.password; + process.env.BENCH_DB_SSL = 'disable'; +} + +async function withAdminClient<T>( + settings: TestDatabaseSettings, + run: (admin: Client) => Promise<T>, +): Promise<T> { + const admin = new Client({ + host: settings.host, + port: settings.port, + user: settings.user, + password: settings.password, + database: settings.adminDatabase, + ssl: false, + }); + await admin.connect(); + try { + return await run(admin); + } finally { + await admin.end(); + } +} + +/** Create a throwaway database on the server `BENCH_TEST_DATABASE_URL` names. */ +async function startDatabaseOnServer( + settings: TestDatabaseSettings, + applySchema: boolean, +): Promise<BenchDatabase> { + const name = `bench_test_${process.pid}_${randomBytes(4).toString('hex')}`; + const drop = (): Promise<void> => + withAdminClient(settings, async (admin) => { + await admin.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + }); + + await withAdminClient(settings, async (admin) => { + await admin.query('SELECT pg_advisory_lock($1, $2)', [...MIGRATION_LOCK_KEYS]); + try { + await admin.query(`CREATE DATABASE "${name}"`); + if (applySchema) { + try { + applyMigrations({ ...settings, database: name }); + } catch (error) { + await drop(); + throw error; + } + } + } finally { + await admin.query('SELECT pg_advisory_unlock($1, $2)', [...MIGRATION_LOCK_KEYS]); + } + }); + exportConnectionEnv({ ...settings, database: name }); + return { stop: drop }; +} + +/** Start a disposable Postgres database and apply the actual migration runner. With + * `BENCH_TEST_DATABASE_URL` set it creates a database on that server; otherwise it starts a + * container. Callers own teardown: resetPool(), then stop(). Requires uv on PATH. */ -export async function startBenchContainer( +export async function startBenchDatabase( options: { applySchema?: boolean } = {}, -): Promise<StartedPostgreSqlContainer> { +): Promise<BenchDatabase> { + const applySchema = options.applySchema !== false; + const url = process.env[URL_VARIABLE]; + if (url !== undefined && url !== '') { + return startDatabaseOnServer(testDatabaseSettings(url), applySchema); + } + const container = await new PostgreSqlContainer('postgres:16-alpine').start(); - process.env.BENCH_DB_HOST = container.getHost(); - process.env.BENCH_DB_PORT = String(container.getPort()); - process.env.BENCH_DB_NAME = container.getDatabase(); - process.env.BENCH_DB_USER = container.getUsername(); - process.env.BENCH_DB_PASSWORD = container.getPassword(); - process.env.BENCH_DB_SSL = 'disable'; - if (options.applySchema !== false) { + const settings = { + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }; + exportConnectionEnv(settings); + if (applySchema) { try { - execFileSync( - 'uv', - [ - 'run', - '--no-project', - fileURLToPath(new URL('../../scripts/migrate-schema.py', import.meta.url)), - 'apply', - ], - { - env: { - ...process.env, - PGHOST: container.getHost(), - PGPORT: String(container.getPort()), - PGDATABASE: container.getDatabase(), - PGUSER: container.getUsername(), - PGPASSWORD: container.getPassword(), - PGSSLMODE: 'disable', - }, - stdio: 'pipe', - }, - ); + applyMigrations(settings); } catch (error) { await container.stop(); throw error; } } - return container; + return { + stop: async () => { + await container.stop(); + }, + }; } // The canonical web UI fixture contains three oldest-first commits. Vortex and From 1d4e41b1259d691b393f7fd61424eb5c67d7d8e1 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 01:17:15 +0100 Subject: [PATCH 22/34] Remove bench's unused IAM sign-in path Production signs in with a static BENCH_DB_PASSWORD for the read-only bench_read role, which has no rds_iam grant (migration 005). The IAM path in lib/db.ts never ran there. getPool() now requires BENCH_DB_PASSWORD through requireEnv, so a missing or empty value fails at first use with a clear message. passwordProvider, the region setting and the rds-signer import are gone, and the pool gets the password as a string. DbConfig stops being exported. Remove @aws-sdk/rds-signer. bun.lock loses only the signer and its transitive packages; nothing else is re-resolved. The README and the deploy runbook now list BENCH_DB_PASSWORD as required and drop BENCH_DB_REGION. BENCH_DB_REGION on the benchmarks-website Vercel project is now unused. Deleting it is a settings change for Will, so this commit leaves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/docs/runbooks/deploy-secrets-setup.md | 3 +- bench/web/README.md | 24 +++--- bench/web/lib/db.test.ts | 84 +++++---------------- bench/web/lib/db.ts | 56 ++------------ bench/web/package.json | 1 - bun.lock | 83 -------------------- 6 files changed, 39 insertions(+), 212 deletions(-) diff --git a/bench/docs/runbooks/deploy-secrets-setup.md b/bench/docs/runbooks/deploy-secrets-setup.md index bc42165..08c3cd3 100644 --- a/bench/docs/runbooks/deploy-secrets-setup.md +++ b/bench/docs/runbooks/deploy-secrets-setup.md @@ -130,9 +130,8 @@ team's secret store. **Never paste a value into this document.** | `BENCH_DB_HOST` | RDS instance hostname for the read pool | Existing monorepo Vercel project env, or `infra/README.md` "After provisioning" section | | `BENCH_DB_NAME` | Postgres database name (`vortex_bench`) | Existing monorepo Vercel project env | | `BENCH_DB_USER` | Postgres username for the read role (`bench_read`) | Existing monorepo Vercel project env | -| `BENCH_DB_PASSWORD` | Static password for the `bench_read` role (IAM auth bypass for Vercel) | Existing monorepo Vercel project env or your secret store | +| `BENCH_DB_PASSWORD` | Password for the `bench_read` role (required) | Existing monorepo Vercel project env or your secret store | | `BENCH_DB_PORT` | Postgres port (default `5432`; omit to use default) | Existing monorepo Vercel project env | -| `BENCH_DB_REGION` | AWS region for IAM token generation (e.g. `us-east-1`); required when `BENCH_DB_PASSWORD` is unset | Existing monorepo Vercel project env | | `BENCH_DB_SSL` | TLS verification mode (`verify-full` for RDS production, `disable` for local dev only) | Existing monorepo Vercel project env | | `BENCH_DB_CA` | RDS CA bundle PEM content or mode; required for `verify-full` — Node does not include Amazon RDS roots in its trust store | Existing monorepo Vercel project env or [Amazon RDS CA bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem) | | `BENCH_DB_POOL_MAX` | Max connections in the pg pool (default `8`; omit to use default) | Existing monorepo Vercel project env | diff --git a/bench/web/README.md b/bench/web/README.md index 3a90f65..1a345de 100644 --- a/bench/web/README.md +++ b/bench/web/README.md @@ -51,11 +51,10 @@ Connection config is read by `lib/db.ts`: | `BENCH_DB_NAME` | yes | Database name. | | `BENCH_DB_USER` | yes | Role to connect as. | | `BENCH_DB_PORT` | no (5432) | Port. | -| `BENCH_DB_PASSWORD` | no | Static password. When unset, each new connection authenticates with a freshly minted RDS IAM token instead. | -| `BENCH_DB_REGION` | for IAM | AWS region for the RDS IAM signer; required when no password is set. IAM token signing also needs AWS credentials in the runtime environment. | +| `BENCH_DB_PASSWORD` | yes | Password for the role in `BENCH_DB_USER` (`bench_read` in production). | | `BENCH_DB_SSL` | no (`verify-full`) | `verify-full` validates the certificate chain and hostname; `disable` is for local non-TLS containers only. Any other value fails loudly. | | `BENCH_DB_CA` | prod | PEM contents of the Amazon RDS CA bundle; Node's trust store does not include the RDS roots, so `verify-full` against RDS fails without it. | -| `BENCH_DB_POOL_MAX` | no (8) | Max pool connections per serverless instance; the per-render summary fan-out (`SUMMARY_CONCURRENCY`) is sized to this default. | +| `BENCH_DB_POOL_MAX` | no (8) | Max pool connections per serverless instance; the per-render summary fan-out (`QUERY_CONCURRENCY`) is sized to this default. | | `BENCH_DB_IDLE_TIMEOUT_MS` | no (5000) | Idle connection timeout in ms. Must be a positive integer within Node's timer range. | | `BENCH_DB_CONNECTION_TIMEOUT_MS` | no (5000) | Timeout in ms for a new connection or a free pool slot. Must be a positive integer within Node's timer range. | | `BENCH_DB_STATEMENT_TIMEOUT_MS` | no (30000) | PostgreSQL server-side timeout for each web statement. `0` disables the timeout. | @@ -119,17 +118,14 @@ One-time operator setup: production URL as `https://<domain>`. The keep-warm workflow uses the same variable. Production deployment requires it before uploading and verifies the public domain after deployment. -3. Configure `BENCH_DB_*` on the Vercel project (Production and Preview environments). Two open - wiring choices are deliberately left to this step, per environment: +3. Configure `BENCH_DB_*` on the Vercel project (Production and Preview environments). Two + wiring points are set in this step, per environment: - **Endpoint**: the public RDS instance endpoint (the same endpoint CI writers use). There is no RDS Proxy: the one provisioned earlier was VPC-internal, so Vercel functions could not reach it, and it was deleted on 2026-10-28. - - **Auth**: a static `BENCH_DB_PASSWORD` for the read-only `bench_read` role. This is the - currently supported mode: migration `005_read_role.sql` creates `bench_read` with **NO - `rds_iam` grant** (and idempotently revokes it if a pre-existing role carries it), because - on RDS `rds_iam` membership forces IAM-only auth and the Vercel runtime has no AWS - credentials to mint IAM tokens. IAM auth is therefore **not available for `bench_read` as - shipped**: enabling it would require BOTH a follow-up migration granting `rds_iam` to a - read role (which atomically disables that role's password auth) AND AWS credentials in the - function runtime (for example Vercel's OIDC federation to an AWS role with - `rds-db:connect`). + - **Auth**: the app signs in only with `BENCH_DB_PASSWORD`, for the read-only `bench_read` + role. Migration `005_read_role.sql` creates `bench_read` with **no `rds_iam` grant** (and + revokes it if a pre-existing role carries it), because on RDS `rds_iam` membership forces + IAM-only auth. Moving the read path to IAM would need app code, a migration and AWS + credentials in the function runtime (for example Vercel's OIDC federation to an AWS role + with `rds-db:connect`). diff --git a/bench/web/lib/db.test.ts b/bench/web/lib/db.test.ts index 4e2e924..a44ff16 100644 --- a/bench/web/lib/db.test.ts +++ b/bench/web/lib/db.test.ts @@ -2,7 +2,6 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Signer } from '@aws-sdk/rds-signer'; import { type BenchDatabase, integrationDatabaseAvailable, @@ -11,7 +10,6 @@ import { import { buildQuery, getPool, - passwordProvider, requireEnv, resetPool, resolveIdleTimeoutMillis, @@ -19,32 +17,16 @@ import { resolveStatementTimeoutMillis, resolveSsl, sql, - type DbConfig, } from './db'; -// A single shared getAuthToken mock so the IAM test can script distinct -// per-call return values and assert the call count. Hoisted so it is defined -// before the (hoisted) vi.mock factory references it. -const { getAuthTokenMock } = vi.hoisted(() => ({ getAuthTokenMock: vi.fn() })); - -// Mock the RDS signer so the IAM token path is exercised without a live AWS -// endpoint. The testcontainers roundtrip below uses a static password, so it -// never constructs a Signer and is unaffected by this mock. -vi.mock('@aws-sdk/rds-signer', () => ({ - // A `function` (not an arrow) so the mock is constructable via `new Signer()`. - Signer: vi.fn(function MockSigner() { - return { getAuthToken: getAuthTokenMock }; - }), -})); - const databaseAvailable = integrationDatabaseAvailable(); describe.skipIf(!databaseAvailable)('db pool roundtrip (testcontainers Postgres)', () => { let container: BenchDatabase; beforeAll(async () => { - // The pool roundtrip needs no schema; the BENCH_DB_PASSWORD fixture path - // set by the harness means IAM token generation is bypassed. + // The pool roundtrip needs no schema; the harness sets the BENCH_DB_* + // variables that getPool() reads. vi.stubEnv('BENCH_DB_POOL_MAX', '1'); vi.stubEnv('BENCH_DB_CONNECTION_TIMEOUT_MS', '1000'); container = await startBenchDatabase({ applySchema: false }); @@ -86,50 +68,6 @@ describe.skipIf(!databaseAvailable)('db pool roundtrip (testcontainers Postgres) }); }); -describe('db IAM auth path (mocked rds-signer)', () => { - const iamConfig: DbConfig = { - host: 'proxy.example.us-east-1.rds.amazonaws.com', - port: 5432, - database: 'bench', - user: 'bench_reader', - region: 'us-east-1', - ssl: false, - poolMax: 4, - idleTimeoutMillis: 5000, - connectionTimeoutMillis: 5000, - statementTimeoutMillis: 30000, - staticPassword: undefined, - }; - - it('mints a FRESH RDS IAM token per connection (not a single cached token)', async () => { - vi.mocked(Signer).mockClear(); - getAuthTokenMock.mockReset(); - getAuthTokenMock.mockResolvedValueOnce('iam-token-1').mockResolvedValueOnce('iam-token-2'); - - const provider = passwordProvider(iamConfig); - // pg invokes the provider once per new physical connection; distinct tokens - // across two calls prove a fresh mint each time rather than one cached token. - await expect(provider()).resolves.toBe('iam-token-1'); - await expect(provider()).resolves.toBe('iam-token-2'); - expect(getAuthTokenMock).toHaveBeenCalledTimes(2); - expect(Signer).toHaveBeenCalledWith({ - hostname: 'proxy.example.us-east-1.rds.amazonaws.com', - port: 5432, - username: 'bench_reader', - region: 'us-east-1', - }); - }); - - it('uses the static password and skips IAM when one is supplied', async () => { - const provider = passwordProvider({ ...iamConfig, region: '', staticPassword: 'fixture-pw' }); - await expect(provider()).resolves.toBe('fixture-pw'); - }); - - it('throws when neither a static password nor a region is configured', () => { - expect(() => passwordProvider({ ...iamConfig, region: '' })).toThrow(/BENCH_DB_REGION/); - }); -}); - describe('buildQuery (parameterization)', () => { const q = (strings: TemplateStringsArray, ...values: unknown[]) => buildQuery(strings, values); @@ -290,6 +228,24 @@ describe('singleton pool configuration', () => { expect(pool.options.connectionTimeoutMillis).toBe(3456); }); + it('requires BENCH_DB_PASSWORD, even when BENCH_DB_REGION is set', () => { + configurePoolEnvironment(); + process.env.BENCH_DB_REGION = 'us-east-1'; + for (const value of [undefined, '']) { + if (value === undefined) delete process.env.BENCH_DB_PASSWORD; + else process.env.BENCH_DB_PASSWORD = value; + expect(() => getPool()).toThrow( + 'Missing required environment variable `BENCH_DB_PASSWORD` for the benchmarks DB connection.', + ); + } + }); + + it('hands the password to pg as a string', () => { + configurePoolEnvironment(); + const pool = getPool() as unknown as { options: { password?: unknown } }; + expect(pool.options.password).toBe('fixture-pw'); + }); + it('attaches one lifecycle listener per singleton pool', async () => { configurePoolEnvironment(); const pool = getPool(); diff --git a/bench/web/lib/db.ts b/bench/web/lib/db.ts index 3ba99b3..99a6be4 100644 --- a/bench/web/lib/db.ts +++ b/bench/web/lib/db.ts @@ -2,31 +2,22 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { Pool, type PoolConfig, type QueryResultRow } from 'pg'; -import { Signer } from '@aws-sdk/rds-signer'; import { attachDatabasePool } from '@vercel/functions'; /** * Resolved Postgres connection settings for the benchmarks read service. * - * In production the read service runs on Vercel; the endpoint and auth choice - * (the public RDS instance endpoint, IAM tokens or a static reader password) - * is configured per environment, see the README's "Database - * environment" and "Deploys" sections. On the IAM path, RDS IAM auth tokens - * are valid for only ~15 minutes, so a fresh token is minted for every new - * pool connection (see [`passwordProvider`]); that is the - * token-refresh-before-expiry strategy. - * - * When [`DbConfig.staticPassword`] is set (local development and the - * integration tests) the IAM token path is skipped entirely and the static - * password is used, which keeps the test fixture free of any AWS dependency. + * The app signs in with a password (`BENCH_DB_PASSWORD`). In production the + * read service runs on Vercel and connects to the public RDS instance endpoint + * as the read-only `bench_read` role; the endpoint and role are configured per + * environment, see the README's "Database environment" and "Deploys" sections. */ -export interface DbConfig { +interface DbConfig { host: string; port: number; database: string; user: string; - /** AWS region for IAM token generation; unused when `staticPassword` is set. */ - region: string; + password: string; ssl: PoolConfig['ssl']; poolMax: number; /** Idle-connection timeout (ms) for the pg pool; see `resolveIdleTimeoutMillis`. */ @@ -35,8 +26,6 @@ export interface DbConfig { connectionTimeoutMillis: number; /** Per-statement server timeout (ms); see `resolveStatementTimeoutMillis`. */ statementTimeoutMillis: number; - /** When defined, IAM token generation is bypassed in favor of this password. */ - staticPassword: string | undefined; } /** @@ -132,56 +121,27 @@ export function resolveStatementTimeoutMillis(): number { } function readConfig(): DbConfig { - const staticPassword = process.env.BENCH_DB_PASSWORD; return { host: requireEnv('BENCH_DB_HOST'), port: Number(process.env.BENCH_DB_PORT ?? '5432'), database: requireEnv('BENCH_DB_NAME'), user: requireEnv('BENCH_DB_USER'), - region: process.env.BENCH_DB_REGION ?? '', + password: requireEnv('BENCH_DB_PASSWORD'), ssl: resolveSsl(), poolMax: Number(process.env.BENCH_DB_POOL_MAX ?? '8'), idleTimeoutMillis: resolveIdleTimeoutMillis(), connectionTimeoutMillis: resolveConnectionTimeoutMillis(), statementTimeoutMillis: resolveStatementTimeoutMillis(), - staticPassword: staticPassword === '' ? undefined : staticPassword, }; } -/** - * Builds the per-connection password resolver passed to [`pg.Pool`]. `pg` - * invokes the resolver for every new physical connection, so returning a - * freshly-minted RDS IAM token here means each connection authenticates with a - * token comfortably inside its ~15-minute validity window. - * - * Exported for unit-testing the IAM token path without a live AWS endpoint. - */ -export function passwordProvider(config: DbConfig): () => Promise<string> { - if (config.staticPassword !== undefined) { - const password = config.staticPassword; - return () => Promise.resolve(password); - } - if (config.region === '') { - throw new Error( - '`BENCH_DB_REGION` is required when no `BENCH_DB_PASSWORD` is set (IAM auth path).', - ); - } - const signer = new Signer({ - hostname: config.host, - port: config.port, - username: config.user, - region: config.region, - }); - return () => signer.getAuthToken(); -} - function createPool(config: DbConfig = readConfig()): Pool { const pool = new Pool({ host: config.host, port: config.port, database: config.database, user: config.user, - password: passwordProvider(config), + password: config.password, ssl: config.ssl, max: config.poolMax, idleTimeoutMillis: config.idleTimeoutMillis, diff --git a/bench/web/package.json b/bench/web/package.json index 6c78595..42ac33b 100644 --- a/bench/web/package.json +++ b/bench/web/package.json @@ -13,7 +13,6 @@ "format:check": "biome format ." }, "dependencies": { - "@aws-sdk/rds-signer": "^3.1063.0", "@vercel/functions": "^3.10.1", "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", diff --git a/bun.lock b/bun.lock index f2f82df..75e8ae2 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,6 @@ "name": "benchmarks-web", "version": "0.1.0", "dependencies": { - "@aws-sdk/rds-signer": "^3.1063.0", "@vercel/functions": "^3.10.1", "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", @@ -70,56 +69,6 @@ "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@9.2.4", "", { "dependencies": { "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.3" } }, "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw=="], - "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], - - "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], - - "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], - - "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], - - "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - - "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fLwNblkowkRyuxdVehlHVOnr/7bBf8Y1UGYdhhpuMPHOQL2QTY6kLcQ+EV1BhTQG1p4ATwaONNJsIk44hxEGMA=="], - - "@aws-sdk/core": ["@aws-sdk/core@3.974.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@aws-sdk/xml-builder": "^3.972.28", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g=="], - - "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.42", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-94W7f8xVsdLEjv3TY8R+beoFL0pIRduiGZdqMfIVMvQfn6q9IA3SgE2mIQluu3VCULn8PopB/gx7Fns8ETn/1Q=="], - - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ=="], - - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g=="], - - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg=="], - - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g=="], - - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.52", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-ini": "^3.972.50", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw=="], - - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg=="], - - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/token-providers": "3.1063.0", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA=="], - - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw=="], - - "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1063.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1063.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-cognito-identity": "^3.972.42", "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-ini": "^3.972.50", "@aws-sdk/credential-provider-login": "^3.972.49", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ApW861WX8h7wKDKRNj7Dyne7awtq/PHrJVSdr3NsE/rmuFUxSha6BFJJ1H0S1MD7hCqZjYqz2VPPmCXo3IKC9A=="], - - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.17", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/signature-v4-multi-region": "^3.996.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig=="], - - "@aws-sdk/rds-signer": ["@aws-sdk/rds-signer@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-providers": "3.1063.0", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-U/t4PcQDkIqKSkwLwY3+zxm92lHcZWnbIYgDZR1sSR/MKns+1j1rkm33SMApQ1Pp6W6BhSEvOehCmC17lLv4DQ=="], - - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.32", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w=="], - - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1063.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ=="], - - "@aws-sdk/types": ["@aws-sdk/types@3.973.11", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg=="], - - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.6", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw=="], - - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.28", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg=="], - - "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], @@ -322,8 +271,6 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg=="], - "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], - "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], "@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.53", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA=="], @@ -414,24 +361,6 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], - - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="], - - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], - - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], - - "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], - - "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], - - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], @@ -640,8 +569,6 @@ "body-parser": ["body-parser@1.20.8", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA=="], - "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -866,10 +793,6 @@ "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - - "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1264,8 +1187,6 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1488,8 +1409,6 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -1638,8 +1557,6 @@ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], - "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], - "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], From 18b7a8d68247d43d41ed9814c47475c46cc037d2 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 01:51:52 +0100 Subject: [PATCH 23/34] Fetch a bench group's charts with two queries, not two per chart collectGroupCharts called chartPayload once per chart, two queries each: about 200 round trips for a 99-chart TPC-DS group. groupChartPayloads builds the same payloads from one statement for the last n commits and one for every chart's rows, a LATERAL join per chart. A chart's x-axis is the last min(n, total) commits of that shared window, so the JSON is byte-identical. chartPayload is unchanged and is the tests' oracle. On one connection the brief's SQL lost to the old 7-wide fan-out, so: - The commit count is an aggregate lateral plus a width_bucket binary search. A scalar subquery was flattened and ran once per output row. - A query chart's first row is a skip scan of idx_query_measurements_summary. MIN read ~27K heap rows per chart. - The compression families split charts by NULL variant, so each pin is sargable. IS NOT DISTINCT FROM planned a semi join per fact row. - Rows come in slices of 25 charts, one after another. One result for TPC-DS at ?n=all peaked at 1.6x the old memory. On the 2026-10-28 dump every group and window matches the old path. At n=100, warm: TPC-DS (NVMe) SF=1 1587 -> 321 ms, Clickbench 865 -> 143 ms, Compression 70 -> 120 ms. At ?n=all, TPC-DS takes 5.3 s against 4.9 s, with peak memory at most 1.34x the old. QUERY_CONCURRENCY now bounds only the summaries in collectGroups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/docs/architecture/performance.md | 27 +- bench/web/lib/group-charts.test.ts | 386 ++++++++++++++++ bench/web/lib/queries.ts | 607 +++++++++++++++++++++---- 3 files changed, 923 insertions(+), 97 deletions(-) create mode 100644 bench/web/lib/group-charts.test.ts diff --git a/bench/docs/architecture/performance.md b/bench/docs/architecture/performance.md index 48b1674..e1e2e96 100644 --- a/bench/docs/architecture/performance.md +++ b/bench/docs/architecture/performance.md @@ -106,17 +106,22 @@ fast. scaling that is not full coverage — but a low-traffic site effectively runs one instance, so the ping warms the path the typical first visitor actually takes. -### Parallel group fan-out - -A group page builds one chart payload per chart (99 for TPC-DS, 43 for -Clickbench). The fan-out in `collectGroupCharts` -([`web/lib/queries.ts`](../../web/lib/queries.ts)) runs those queries with an -order-preserving `Promise.all` bounded by the existing `pg` pool (max 8 -connections) rather than a sequential `await` loop, so a Data Cache *miss* on a -big group does not serialize into dozens of round-trips. The output (chart set, -order, shape) is unchanged and pinned by an integration test. This was a small, -low-risk change; batching a whole group into one SQL statement is the larger -win, deliberately left for later because the chart shapes are heterogeneous. +### One query per group + +A group page inlines every chart's payload (99 for TPC-DS, 43 for Clickbench). +`groupChartPayloads` ([`web/lib/queries.ts`](../../web/lib/queries.ts)) builds +them from two statements instead of two per chart: the last `n` commits, and +every chart's rows in that window, one `LATERAL` join per chart (in slices of +25 charts, one after another, so a TPC-DS group runs five). A chart's x-axis is +the last `min(n, total)` commits of the shared window, so the JSON is +byte-identical to `chartPayload`'s, which a test and a dump check pin. On one +connection the per-chart `MIN` over a query chart's whole history became the +cost, so it is a skip scan of `idx_query_measurements_summary`, and the commit +count is a binary search. `QUERY_CONCURRENCY` now bounds only the summaries in +`collectGroups`. On the 2026-10-28 dump at `n=100`, warm: TPC-DS (NVMe) SF=1 +1.59 s → 0.32 s, Clickbench 0.87 s → 0.14 s, Compression 70 → 120 ms (its +`MIN` still reads every row). At `?n=all` the TPC-DS group takes 5.3 s against +4.9 s, with peak memory at most 1.34× the old fan-out's. ### `query_measurements` reads filter on a denormalized timestamp diff --git a/bench/web/lib/group-charts.test.ts b/bench/web/lib/group-charts.test.ts new file mode 100644 index 0000000..d23fb4b --- /dev/null +++ b/bench/web/lib/group-charts.test.ts @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { getPool, resetPool } from './db'; +import { + chartPayload, + collectGroupCharts, + collectGroups, + groupChartPayloads, + historyFor, + seriesFor, + type Group, +} from './queries'; +import { chartKeyFromSlug, groupKeyFromSlug, type ChartKey, type GroupKey } from './slug'; +import { + type BenchDatabase, + commitUrl, + integrationDatabaseAvailable, + seedChartFixture, + startBenchDatabase, + TREE_SHA, +} from './test-harness'; +import { type CommitWindow, parseCommitWindow } from './window'; + +describe('historyFor', () => { + it('bounds a long history to the window', () => { + expect(historyFor(125, { kind: 'last', n: 100 })).toEqual({ + total_commits: 125, + start_index: 25, + loaded_commits: 100, + complete: false, + }); + }); + + it('keeps a short history whole', () => { + expect(historyFor(3, { kind: 'last', n: 100 })).toEqual({ + total_commits: 3, + start_index: 0, + loaded_commits: 3, + complete: true, + }); + }); + + it('loads everything for ?n=all', () => { + expect(historyFor(125, { kind: 'all' })).toEqual({ + total_commits: 125, + start_index: 0, + loaded_commits: 125, + complete: true, + }); + }); + + it('reports an empty history as complete', () => { + expect(historyFor(0, { kind: 'last', n: 100 })).toEqual({ + total_commits: 0, + start_index: 0, + loaded_commits: 0, + complete: true, + }); + }); +}); + +describe('seriesFor', () => { + it.each([ + [ + 'QueryMeasurement', + { engine: 'duckdb', format: 'parquet' }, + { key: 'duckdb:parquet', engine: 'duckdb', format: 'parquet' }, + ], + [ + 'CompressionTime', + { format: 'parquet', op: 'encode' }, + { key: 'parquet:encode', engine: undefined, format: 'parquet' }, + ], + ['CompressionSize', { format: 'lance' }, { key: 'lance', engine: undefined, format: 'lance' }], + [ + 'RandomAccess', + { format: 'parquet', open_mode: 'reopen' }, + { key: 'parquet:cold', engine: undefined, format: 'parquet' }, + ], + [ + 'VectorSearch', + { flavor: 'vortex-turboquant' }, + { key: 'vortex-turboquant', engine: undefined, format: undefined }, + ], + ] as const)('names the %s series', (kind, cols, expected) => { + expect(seriesFor(kind, { commit_sha: 'a', value: 1, ...cols })).toEqual(expected); + }); +}); + +const databaseAvailable = integrationDatabaseAvailable(); + +/** The oracle: each link's `chartPayload`, named and flattened as the group route does. */ +async function expectedCharts(group: Group, window: CommitWindow): Promise<string> { + const charts: unknown[] = []; + for (const link of group.charts) { + const payload = await chartPayload(chartKeyFromSlug(link.slug), window); + if (payload !== null) { + charts.push({ name: link.name, slug: link.slug, ...payload }); + } + } + return JSON.stringify(charts); +} + +describe.skipIf(!databaseAvailable)('groupChartPayloads on the canonical fixture', () => { + let database: BenchDatabase; + + const Q1: ChartKey = { + k: 'QueryMeasurement', + dataset: 'tpch', + dataset_variant: null, + scale_factor: '1', + storage: 'nvme', + query_idx: 1, + }; + const TPCH: GroupKey = { + k: 'QueryGroup', + dataset: 'tpch', + dataset_variant: null, + scale_factor: '1', + storage: 'nvme', + }; + + beforeAll(async () => { + database = await startBenchDatabase(); + await seedChartFixture(getPool()); + }); + + afterAll(async () => { + await resetPool(); + await database.stop(); + }); + + it.each(['1', '2', '100', 'all'])( + 'every group matches chartPayload byte for byte at ?n=%s', + async (n) => { + const window = parseCommitWindow(n); + const groups = await collectGroups(); + expect(groups.length).toBeGreaterThan(0); + for (const group of groups) { + const bundle = await collectGroupCharts(groupKeyFromSlug(group.slug), window); + expect(JSON.stringify(bundle?.charts ?? [])).toBe(await expectedCharts(group, window)); + } + }, + ); + + it('issues two statements for a group of up to 25 charts', async () => { + const group = (await collectGroups()).find((g) => g.name === 'TPC-H (NVMe) (SF=1)'); + const keys = (group?.charts ?? []).map((link) => chartKeyFromSlug(link.slug)); + expect(keys.length).toBeGreaterThan(1); + const spy = vi.spyOn(getPool(), 'query'); + const payloads = await groupChartPayloads(TPCH, keys, parseCommitWindow(null)); + expect(spy).toHaveBeenCalledTimes(2); + expect(payloads.every((payload) => payload !== null)).toBe(true); + }); + + it('adds one rows statement per 25 more charts and keeps chart order', async () => { + const window = parseCommitWindow(null); + // 25 charts with no rows fill the first slice, so Q1 and Q2 land in the second. + const empty = Array.from({ length: 25 }, (_, i) => ({ ...Q1, query_idx: 100 + i })); + const keys: ChartKey[] = [...empty, { ...Q1, query_idx: 2 }, Q1]; + const expected = await Promise.all(keys.map((key) => chartPayload(key, window))); + const spy = vi.spyOn(getPool(), 'query'); + const payloads = await groupChartPayloads(TPCH, keys, window); + expect(spy).toHaveBeenCalledTimes(3); + expect(payloads.slice(25).every((payload) => payload !== null)).toBe(true); + expect(JSON.stringify(payloads)).toBe(JSON.stringify(expected)); + }); + + it('returns null in place of a chart with no rows', async () => { + const window = parseCommitWindow(null); + const payloads = await groupChartPayloads(TPCH, [Q1, { ...Q1, query_idx: 99 }], window); + expect(payloads).toHaveLength(2); + expect(payloads[1]).toBeNull(); + expect(JSON.stringify(payloads[0])).toBe(JSON.stringify(await chartPayload(Q1, window))); + }); +}); + +// Commits oldest first. D and E share a timestamp, so (timestamp, sha) order puts D first. +const C0 = '0'.repeat(40); +const C1 = '1'.repeat(40); +const C2 = '2'.repeat(40); +const D = 'd'.repeat(40); +const E = 'e'.repeat(40); +const F = 'f'.repeat(40); +const EDGE_COMMITS: ReadonlyArray<readonly [string, string]> = [ + [C0, '2026-06-01T00:00:00Z'], + [C1, '2026-06-02T00:00:00Z'], + [C2, '2026-06-03T00:00:00Z'], + [D, '2026-06-04T00:00:00Z'], + [E, '2026-06-04T00:00:00Z'], + [F, '2026-06-05T00:00:00Z'], +]; +const ALL_SHAS = EDGE_COMMITS.map(([sha]) => sha); + +function queryGroup(scaleFactor: string | null, storage: string): GroupKey { + return { + k: 'QueryGroup', + dataset: 'grp', + dataset_variant: null, + scale_factor: scaleFactor, + storage, + }; +} + +function queryChart(group: GroupKey, queryIdx: number): ChartKey { + if (group.k !== 'QueryGroup') { + throw new Error('expected a query group'); + } + const { dataset, dataset_variant, scale_factor, storage } = group; + return { + k: 'QueryMeasurement', + dataset, + dataset_variant, + scale_factor, + storage, + query_idx: queryIdx, + }; +} + +const G = queryGroup(null, 'nvme'); +const S = queryGroup('1', 'nvme'); +const T = queryGroup(null, 's3'); +const G_CHARTS = [1, 2, 3].map((idx) => queryChart(G, idx)); + +describe.skipIf(!databaseAvailable)('groupChartPayloads at the window edges', () => { + let database: BenchDatabase; + let measurementId = 0; + + async function insertQuery( + group: GroupKey, + sha: string, + queryIdx: number, + engine: string, + valueNs: number, + stamped = true, + ): Promise<void> { + if (group.k !== 'QueryGroup') { + throw new Error('expected a query group'); + } + measurementId += 1; + // `commit_timestamp` is stamped from `commits`, as the writers do, unless `stamped` is false. + await getPool().query( + `INSERT INTO query_measurements + (measurement_id, commit_sha, dataset, dataset_variant, scale_factor, + query_idx, storage, engine, format, value_ns, all_runtimes_ns, + commit_timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'f1', $9, '{1}'::bigint[], + (SELECT timestamp FROM commits WHERE commit_sha = $2 AND $10))`, + [ + measurementId, + sha, + group.dataset, + group.dataset_variant, + group.scale_factor, + queryIdx, + group.storage, + engine, + valueNs, + stamped, + ], + ); + } + + beforeAll(async () => { + database = await startBenchDatabase(); + for (const [sha, ts] of EDGE_COMMITS) { + await getPool().query( + `INSERT INTO commits (commit_sha, timestamp, message, tree_sha, url) + VALUES ($1, $2::timestamptz, $3, $4, $5)`, + [sha, ts, `commit ${sha.slice(0, 1)}`, TREE_SHA, commitUrl(sha)], + ); + } + for (const [i, sha] of ALL_SHAS.entries()) { + await insertQuery(G, sha, 1, 'e1', 10 * (i + 1)); + await insertQuery(G, sha, 1, 'e2', 1_000 + i); + await insertQuery(S, sha, 1, 'e1', 100 + i); + await insertQuery(T, sha, 1, 'e1', 200 + i); + } + await insertQuery(G, E, 2, 'e1', 7); + await insertQuery(G, F, 2, 'e1', 8); + await insertQuery(G, C0, 3, 'e1', 9); + // An unstamped row on an older commit of a stamped series: the chart's first row ignores it, + // as `MIN` does. + await insertQuery(G, C1, 2, 'e1', 5, false); + for (const [sha, dataset, variant, valueNs] of [ + [C1, 'cx', null, 11], + [F, 'cx', null, 12], + [D, 'cx', 'v1', 21], + [E, 'cx', 'v1', 22], + [C0, 'cy', 'v1', 31], + ] as const) { + measurementId += 1; + await getPool().query( + `INSERT INTO compression_times + (measurement_id, commit_sha, dataset, dataset_variant, format, op, + value_ns, all_runtimes_ns) + VALUES ($1, $2, $3, $4, 'f1', 'encode', $5, '{1}'::bigint[])`, + [measurementId, sha, dataset, variant, valueNs], + ); + } + }); + + afterAll(async () => { + await resetPool(); + await database.stop(); + }); + + it.each(['1', '2', '3', '4', 'all'])( + 'group payloads match chartPayload byte for byte at ?n=%s', + async (n) => { + const window = parseCommitWindow(n); + for (const [group, keys] of [ + [G, G_CHARTS], + [S, [queryChart(S, 1)]], + [T, [queryChart(T, 1)]], + ] as const) { + const expected = await Promise.all(keys.map((key) => chartPayload(key, window))); + expect(JSON.stringify(await groupChartPayloads(group, keys, window))).toBe( + JSON.stringify(expected), + ); + } + }, + ); + + it('gives each chart its own window', async () => { + const [q1, q2, q3] = await groupChartPayloads(G, G_CHARTS, parseCommitWindow('4')); + expect(q1?.history).toEqual({ + total_commits: 6, + start_index: 2, + loaded_commits: 4, + complete: false, + }); + expect(q2?.history).toEqual({ + total_commits: 3, + start_index: 0, + loaded_commits: 3, + complete: true, + }); + // D shares E's timestamp, so it is on Q2's axis although Q2 has no row there. + expect(q2?.commits.map((c) => c.sha)).toEqual([D, E, F]); + expect(q2?.series).toEqual({ 'e1:f1': [null, 7, 8] }); + expect(q3?.series).toEqual({}); + expect(q3?.history).toEqual({ + total_commits: 6, + start_index: 2, + loaded_commits: 4, + complete: false, + }); + }); + + it.each(['1', '4', 'all'])( + 'matches chartPayload when compression variants mix NULL and non-NULL at ?n=%s', + async (n) => { + const window = parseCommitWindow(n); + const keys: ChartKey[] = [ + { k: 'CompressionTime', dataset: 'cx', dataset_variant: 'v1' }, + { k: 'CompressionTime', dataset: 'cx', dataset_variant: null }, + { k: 'CompressionTime', dataset: 'cy', dataset_variant: null }, + { k: 'CompressionTime', dataset: 'cy', dataset_variant: 'v1' }, + ]; + const expected = await Promise.all(keys.map((key) => chartPayload(key, window))); + expect(expected[2]).toBeNull(); + const spy = vi.spyOn(getPool(), 'query'); + const payloads = await groupChartPayloads({ k: 'CompressionTimeGroup' }, keys, window); + expect(spy).toHaveBeenCalledTimes(2); + expect(JSON.stringify(payloads)).toBe(JSON.stringify(expected)); + }, + ); + + it('keeps sibling groups apart', async () => { + const window = parseCommitWindow('all'); + const [g] = await groupChartPayloads(G, [queryChart(G, 1)], window); + const [s] = await groupChartPayloads(S, [queryChart(S, 1)], window); + expect(g?.series['e1:f1']).toEqual([10, 20, 30, 40, 50, 60]); + expect(s?.series['e1:f1']).toEqual([100, 101, 102, 103, 104, 105]); + }); + + it('refuses a chart from another group', async () => { + await expect( + groupChartPayloads(G, [queryChart(S, 1)], parseCommitWindow(null)), + ).rejects.toThrow('is not in group'); + }); +}); diff --git a/bench/web/lib/queries.ts b/bench/web/lib/queries.ts index 6ca9ace..2067f53 100644 --- a/bench/web/lib/queries.ts +++ b/bench/web/lib/queries.ts @@ -12,7 +12,8 @@ * window at or after the earliest commit with a row in the fact table), then a * fact-row pass that threads values onto that x-axis through a * [`SeriesAccumulator`]. The result is a [`ChartResponse`] with the same wire - * shape the Axum server emits. + * shape the Axum server emits. [`groupChartPayloads`] builds the same payloads + * for every chart of one group with two statements in all. * * Behaviour-preservation notes (this is a substrate migration, DuckDB -> * Postgres): @@ -34,7 +35,7 @@ import { getPool } from './db'; import { randomAccessStorage } from './random-access'; import { groupDescription } from './descriptions'; import type { FilterUniverse } from './chart-format'; -import { compareCodeUnits, FAMILIES, type GroupKind } from './families'; +import { type ChartKind, compareCodeUnits, FAMILIES, type GroupKind } from './families'; import { chartKeyFromSlug, chartKeyToSlug, groupKeyFromSlug, groupKeyToSlug } from './slug'; import type { ChartKey, GroupKey } from './slug'; import { collectGroupSummary, type Summary } from './summary'; @@ -354,6 +355,81 @@ type FormatRow = ValueRow & { format: string }; type RandomAccessRow = ValueRow & { format: string; open_mode: 'cached' | 'reopen' }; type FlavorRow = ValueRow & { flavor: string }; +/** A fact row of any family. Each family fills the columns that name its series. */ +export type FactRow = { + commit_sha: string; + value: number; + engine?: string; + format?: string; + op?: string; + open_mode?: 'cached' | 'reopen'; + flavor?: string; +}; + +/** + * The series a fact row belongs to, with its engine/format tag. Vector-search + * series carry neither, so they are never tagged and the chart omits + * `series_meta`. + */ +export function seriesFor( + kind: ChartKind, + row: FactRow, +): { key: string; engine: string | undefined; format: string | undefined } { + switch (kind) { + case 'QueryMeasurement': + return { key: `${row.engine}:${row.format}`, engine: row.engine, format: row.format }; + case 'CompressionTime': + return { key: `${row.format}:${row.op}`, engine: undefined, format: row.format }; + case 'CompressionSize': + return { key: `${row.format}`, engine: undefined, format: row.format }; + case 'RandomAccess': { + const mode = row.open_mode === 'cached' ? 'hot' : 'cold'; + return { key: `${row.format}:${mode}`, engine: undefined, format: row.format }; + } + case 'VectorSearch': + return { key: `${row.flavor}`, engine: undefined, format: undefined }; + } +} + +/** Thread one fact row onto the seeded x-axis. A row off the seeded commits is dropped. */ +function recordRow(acc: SeriesAccumulator, kind: ChartKind, row: FactRow): void { + const idx = acc.commitIdx(row.commit_sha); + if (idx === undefined) { + return; + } + const { key, engine, format } = seriesFor(kind, row); + acc.record(key, idx, row.value); + acc.tag(key, engine, format); +} + +/** A chart's `display_name`, built from its key alone. */ +function chartDisplayName(key: ChartKey): string { + switch (key.k) { + case 'QueryMeasurement': { + let name = key.dataset; + if (key.dataset_variant !== null) { + name += `/${key.dataset_variant}`; + } + if (key.scale_factor !== null) { + name += ` sf=${key.scale_factor}`; + } + return `${name} Q${key.query_idx} [${key.storage}]`; + } + case 'CompressionTime': + case 'CompressionSize': + return key.dataset_variant === null ? key.dataset : `${key.dataset}/${key.dataset_variant}`; + case 'RandomAccess': + return key.dataset; + case 'VectorSearch': + return `${key.dataset} / ${key.layout} (threshold=${key.threshold})`; + } +} + +/** A chart's base `unit_kind`: sizes are bytes, everything else is a time. */ +function chartUnitKind(key: ChartKey): UnitKind { + return key.k === 'CompressionSize' ? 'bytes' : 'time_ns'; +} + async function collectQueryChart( key: QueryMeasurementKey, window: CommitWindow, @@ -396,24 +472,9 @@ async function collectQueryChart( `; const rows = (await getPool().query<QueryRow>(text, params.values)).rows; for (const row of rows) { - const idx = acc.commitIdx(row.commit_sha); - if (idx === undefined) { - continue; - } - const seriesKey = `${row.engine}:${row.format}`; - acc.record(seriesKey, idx, row.value); - acc.tag(seriesKey, row.engine, row.format); - } - - let name = dataset; - if (dataset_variant !== null) { - name += `/${dataset_variant}`; - } - if (scale_factor !== null) { - name += ` sf=${scale_factor}`; + recordRow(acc, key.k, row); } - name += ` Q${query_idx} [${storage}]`; - return acc.finish(name, 'time_ns', seeded.history); + return acc.finish(chartDisplayName(key), chartUnitKind(key), seeded.history); } async function collectCompressionTimeChart( @@ -448,20 +509,9 @@ async function collectCompressionTimeChart( `; const rows = (await getPool().query<CompressionTimeRow>(text, params.values)).rows; for (const row of rows) { - const idx = acc.commitIdx(row.commit_sha); - if (idx === undefined) { - continue; - } - const seriesKey = `${row.format}:${row.op}`; - acc.record(seriesKey, idx, row.value); - acc.tag(seriesKey, undefined, row.format); + recordRow(acc, key.k, row); } - - let name = dataset; - if (dataset_variant !== null) { - name += `/${dataset_variant}`; - } - return acc.finish(name, 'time_ns', seeded.history); + return acc.finish(chartDisplayName(key), chartUnitKind(key), seeded.history); } async function collectCompressionSizeChart( @@ -496,19 +546,9 @@ async function collectCompressionSizeChart( `; const rows = (await getPool().query<FormatRow>(text, params.values)).rows; for (const row of rows) { - const idx = acc.commitIdx(row.commit_sha); - if (idx === undefined) { - continue; - } - acc.record(row.format, idx, row.value); - acc.tag(row.format, undefined, row.format); + recordRow(acc, key.k, row); } - - let name = dataset; - if (dataset_variant !== null) { - name += `/${dataset_variant}`; - } - return acc.finish(name, 'bytes', seeded.history); + return acc.finish(chartDisplayName(key), chartUnitKind(key), seeded.history); } async function collectRandomAccessChart( @@ -543,17 +583,9 @@ async function collectRandomAccessChart( `; const rows = (await getPool().query<RandomAccessRow>(text, params.values)).rows; for (const row of rows) { - const idx = acc.commitIdx(row.commit_sha); - if (idx === undefined) { - continue; - } - const mode = row.open_mode === 'cached' ? 'hot' : 'cold'; - const series = `${row.format}:${mode}`; - acc.record(series, idx, row.value); - acc.tag(series, undefined, row.format); + recordRow(acc, key.k, row); } - - return acc.finish(dataset, 'time_ns', seeded.history); + return acc.finish(chartDisplayName(key), chartUnitKind(key), seeded.history); } async function collectVectorSearchChart( @@ -590,16 +622,9 @@ async function collectVectorSearchChart( `; const rows = (await getPool().query<FlavorRow>(text, params.values)).rows; for (const row of rows) { - const idx = acc.commitIdx(row.commit_sha); - if (idx === undefined) { - continue; - } - // Vector-search series carry no engine/format, so they are never tagged - // and the chart's `series_meta` is omitted from the wire. - acc.record(row.flavor, idx, row.value); + recordRow(acc, key.k, row); } - - return acc.finish(`${dataset} / ${layout} (threshold=${threshold})`, 'time_ns', seeded.history); + return acc.finish(chartDisplayName(key), chartUnitKind(key), seeded.history); } /** @@ -627,6 +652,416 @@ export function chartPayload(key: ChartKey, window: CommitWindow): Promise<Chart } } +/** + * Where a chart's loaded commits sit in its history of `totalCommits` eligible + * commits: the last `min(n, total)` of them, or all of them for `?n=all`. The + * same placement [`seededCommitsInWindow`] derives from the rows it returns. + */ +export function historyFor(totalCommits: number, window: CommitWindow): ChartHistory { + const limit = commitWindowLimit(window); + const loaded = limit === null ? totalCommits : Math.min(limit, totalCommits); + return { + total_commits: totalCommits, + start_index: Math.max(0, totalCommits - loaded), + loaded_commits: loaded, + complete: loaded === totalCommits, + }; +} + +type CommitRow = { commit_sha: string; ts_text: string; message: string; url: string }; + +/** + * The last `n` commits of all (every commit for `?n=all`), oldest first. Each + * chart's x-axis is a suffix of this list. A `null` `LIMIT` is no limit. + */ +async function windowCommits(window: CommitWindow): Promise<CommitPoint[]> { + const text = ` + SELECT commit_sha, + to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS"+00"') AS ts_text, + COALESCE(message, '') AS message, + url + FROM (SELECT * FROM commits ORDER BY timestamp DESC, commit_sha DESC LIMIT $1) w + ORDER BY timestamp ASC, commit_sha ASC + `; + const rows = (await getPool().query<CommitRow>(text, [commitWindowLimit(window)])).rows; + return rows.map((row) => ({ + sha: row.commit_sha, + timestamp: row.ts_text, + message: row.message, + url: row.url, + })); +} + +/** Whether `key` is one of the charts discovery puts in `group`. */ +function chartInGroup(group: GroupKey, key: ChartKey): boolean { + switch (group.k) { + case 'QueryGroup': + return ( + key.k === 'QueryMeasurement' && + key.dataset === group.dataset && + key.dataset_variant === group.dataset_variant && + key.scale_factor === group.scale_factor && + key.storage === group.storage + ); + case 'CompressionTimeGroup': + return key.k === 'CompressionTime'; + case 'CompressionSizeGroup': + return key.k === 'CompressionSize'; + case 'RandomAccessGroup': + return key.k === 'RandomAccess' && randomAccessStorage(key.dataset) === group.storage; + case 'VectorSearchGroup': + return ( + key.k === 'VectorSearch' && key.dataset === group.dataset && key.layout === group.layout + ); + } +} + +/** Narrow a group's chart keys to its family. The SQL pins the group's own dimensions, so a key + * from another group would get the wrong rows; refuse it instead. */ +function chartsOf<K extends ChartKind>( + group: GroupKey, + charts: readonly ChartKey[], + kind: K, +): Extract<ChartKey, { k: K }>[] { + return charts.map((key) => { + if (key.k !== kind || !chartInGroup(group, key)) { + throw new Error(`chart ${chartKeyToSlug(key)} is not in group ${groupKeyToSlug(group)}`); + } + return key as Extract<ChartKey, { k: K }>; + }); +} + +/** + * The earliest `commit_timestamp` of the query chart `pins` selects (its + * `query_idx` is `ch.query_idx`): the value of the collector's + * `MIN(q2.commit_timestamp)`, without reading the chart's whole history. + * + * That `MIN` reads every row of the chart, ~27K heap rows for a TPC-DS query, + * and a group of 99 charts in one statement pays it 99 times on one + * connection. Instead, a recursive skip scan of `idx_query_measurements_summary` + * finds the chart's (engine, format) series, and one backward descent per series + * finds its oldest non-NULL timestamp, which the index orders last. The minimum + * over series is the minimum over rows, NULLs ignored as `MIN` ignores them. + * The successor probe and the full-prefix `ORDER BY` follow + * `collectQuerySummary` in `summary.ts`, which explains both. + */ +function queryChartEarliest(pins: (alias: string) => string): string { + const order = + 'q.dataset, q.dataset_variant, q.scale_factor, q.storage, q.query_idx, q.engine, q.format'; + const oldestFirst = + 'q.dataset DESC, q.dataset_variant DESC, q.scale_factor DESC, q.storage DESC, ' + + 'q.query_idx DESC, q.engine DESC, q.format DESC, q.commit_timestamp'; + return `WITH RECURSIVE series AS ( + (SELECT q.engine, q.format + FROM query_measurements q + WHERE ${pins('q')} + ORDER BY ${order} + LIMIT 1) + UNION ALL + SELECT nxt.engine, nxt.format + FROM series s + CROSS JOIN LATERAL ( + (SELECT 1 AS br, q.engine, q.format + FROM query_measurements q + WHERE ${pins('q')} + AND q.engine = s.engine + AND q.format > s.format + ORDER BY ${order} + LIMIT 1) + UNION ALL + (SELECT 2 AS br, q.engine, q.format + FROM query_measurements q + WHERE ${pins('q')} + AND q.engine > s.engine + ORDER BY ${order} + LIMIT 1) + ORDER BY br + LIMIT 1 + ) nxt + ) + SELECT MIN(oldest.commit_timestamp) + FROM series s + CROSS JOIN LATERAL ( + SELECT q.commit_timestamp + FROM query_measurements q + WHERE ${pins('q')} + AND q.engine = s.engine + AND q.format = s.format + AND q.commit_timestamp IS NOT NULL + ORDER BY ${oldestFirst} + LIMIT 1 + ) oldest`; +} + +/** One per-chart column of a [`groupRowsQuery`] block: its name in `ch`, element type, values. */ +type ChartColumn = readonly [name: string, type: string, values: readonly unknown[]]; + +/** + * One row per (chart, fact row in the window), plus one row with NULL fact + * columns for a chart with no rows there. `ord` is the chart's 1-based position + * in `charts`; `total_commits` counts the commits at or after its first row. + * + * Each block unnests the charts' key columns as `ch`, then joins two + * per-chart subqueries, with the chart's binds replaced by `ch.<column>`: + * - `e`, the timestamp of the chart's first row: the collector's own `MIN`, + * except for query charts ([`queryChartEarliest`]). It is an aggregate, so + * the planner cannot flatten it into the outer query and run it once per + * output row. + * - `d`, the collector's own row query. + * + * `total_commits` is the collectors' `count(*)` of commits at or after `e`, + * found by binary search: `width_bucket` counts the sorted timestamps up to its + * argument, and timestamps are whole microseconds, so one microsecond before + * `e` counts the commits before it. A `count(*)` per chart scans `commits` + * 99 times for a TPC-DS group. + * + * There is no `ORDER BY`: each (series, commit) cell is unique by the fact + * tables' natural keys and `finish()` sorts the series, so row order cannot + * show. + */ +function groupRowsQuery( + group: GroupKey, + charts: readonly ChartKey[], + window: CommitWindow, +): { text: string; values: unknown[] } { + const params = new QueryParams(); + const blocks: string[] = []; + const block = ( + fields: readonly string[], + positions: readonly number[], + columns: readonly ChartColumn[], + earliest: string, + rows: string, + ): void => { + const arrays = [ + `${params.bind(positions)}::int[]`, + ...columns.map(([, type, values]) => `${params.bind(values)}::${type}[]`), + ]; + const names = ['ord', ...columns.map(([name]) => name)]; + blocks.push(` + SELECT ch.ord, + COALESCE(cardinality(t.sorted) + - width_bucket(e.first_ts - interval '1 microsecond', t.sorted), 0) + AS total_commits, + d.commit_sha, ${fields.map((f) => `d.${f}`).join(', ')}, d.value + FROM commit_times t + CROSS JOIN unnest(${arrays.join(', ')}) AS ch(${names.join(', ')}) + CROSS JOIN LATERAL ( + ${earliest} + ) e(first_ts) + LEFT JOIN LATERAL ( + ${rows} + ) d ON true`); + }; + switch (group.k) { + case 'QueryGroup': { + const keys = chartsOf(group, charts, 'QueryMeasurement'); + const dataset = params.bind(group.dataset); + const storage = params.bind(group.storage); + const pins = (alias: string): string => + `${alias}.dataset = ${dataset} + AND ${sargableDimEq(params, `${alias}.dataset_variant`, group.dataset_variant)} + AND ${sargableDimEq(params, `${alias}.scale_factor`, group.scale_factor)} + AND ${alias}.storage = ${storage} + AND ${alias}.query_idx = ch.query_idx`; + block( + ['engine', 'format'], + keys.map((_, i) => i + 1), + [['query_idx', 'int', keys.map((k) => k.query_idx)]], + queryChartEarliest(pins), + `SELECT q.commit_sha, + q.engine, q.format, q.value_ns::float8 AS value + FROM query_measurements q + WHERE ${pins('q')}${queryMeasurementWindowFilter(params, window)}`, + ); + break; + } + case 'CompressionTimeGroup': + case 'CompressionSizeGroup': { + const time = group.k === 'CompressionTimeGroup'; + const table = time ? 'compression_times' : 'compression_sizes'; + const value = time ? 'value_ns' : 'value_bytes'; + const fields = time ? ['format', 'op'] : ['format']; + const keys = chartsOf(group, charts, time ? 'CompressionTime' : 'CompressionSize'); + // `dataset_variant` varies per chart. `IS NOT DISTINCT FROM ch.dataset_variant` would plan + // as if it matched one row and rescan the window per fact row, so split the charts by + // whether the variant is NULL and pin each block sargably, as `sargableDimEq` does. + for (const nullVariant of [true, false]) { + const positions: number[] = []; + const picked: typeof keys = []; + keys.forEach((key, i) => { + if ((key.dataset_variant === null) === nullVariant) { + positions.push(i + 1); + picked.push(key); + } + }); + if (picked.length === 0) { + continue; + } + const columns: ChartColumn[] = [['dataset', 'text', picked.map((k) => k.dataset)]]; + if (!nullVariant) { + columns.push(['dataset_variant', 'text', picked.map((k) => k.dataset_variant)]); + } + const pins = (alias: string): string => + `${alias}.dataset = ch.dataset + AND ${alias}.dataset_variant ${nullVariant ? 'IS NULL' : '= ch.dataset_variant'}`; + block( + fields, + positions, + columns, + `SELECT MIN(c2.timestamp) + FROM ${table} f2 + JOIN commits c2 ON c2.commit_sha = f2.commit_sha + WHERE ${pins('f2')}`, + `SELECT f.commit_sha, + ${fields.map((field) => `f.${field}`).join(', ')}, f.${value}::float8 AS value + FROM ${table} f + JOIN commits c USING (commit_sha) + WHERE ${pins('f')}${factWindowFilter(params, window)}`, + ); + } + break; + } + case 'RandomAccessGroup': { + const keys = chartsOf(group, charts, 'RandomAccess'); + block( + ['format', 'open_mode'], + keys.map((_, i) => i + 1), + [['dataset', 'text', keys.map((k) => k.dataset)]], + `SELECT MIN(c2.timestamp) + FROM random_access_times r2 + JOIN commits c2 ON c2.commit_sha = r2.commit_sha + WHERE r2.dataset = ch.dataset`, + `SELECT r.commit_sha, + r.format, + r.open_mode AS open_mode, + r.value_ns::float8 AS value + FROM random_access_times r + JOIN commits c USING (commit_sha) + WHERE r.dataset = ch.dataset${factWindowFilter(params, window)}`, + ); + break; + } + case 'VectorSearchGroup': { + const keys = chartsOf(group, charts, 'VectorSearch'); + const dataset = params.bind(group.dataset); + const layout = params.bind(group.layout); + block( + ['flavor'], + keys.map((_, i) => i + 1), + [['threshold', 'float8', keys.map((k) => k.threshold)]], + `SELECT MIN(c2.timestamp) + FROM vector_search_runs v2 + JOIN commits c2 ON c2.commit_sha = v2.commit_sha + WHERE v2.dataset = ${dataset} + AND v2.layout = ${layout} + AND v2.threshold = ch.threshold`, + `SELECT v.commit_sha, + v.flavor, v.value_ns::float8 AS value + FROM vector_search_runs v + JOIN commits c USING (commit_sha) + WHERE v.dataset = ${dataset} + AND v.layout = ${layout} + AND v.threshold = ch.threshold${factWindowFilter(params, window)}`, + ); + break; + } + } + const text = ` + WITH commit_times AS ( + SELECT array_agg(timestamp ORDER BY timestamp) AS sorted FROM commits + )${blocks.join('\n UNION ALL')}`; + return { text, values: params.values }; +} + +/** A [`groupRowsQuery`] row: a fact row, or NULL fact columns for a chart with none. */ +type GroupRow = { ord: number; total_commits: number } & (FactRow | { commit_sha: null }); + +/** + * Charts per rows statement. At `?n=all` a TPC-DS group has ~2.7M fact rows, + * and holding them in one result peaked the process at 1.6x the memory of the + * old per-chart fan-out on the 2026-10-28 dump. Slices run one after another, + * so only one slice's rows are alive at a time. + */ +const GROUP_ROWS_CHUNK = 25; + +/** + * Build the payloads of `charts`, every one of which must belong to `group`: + * one statement for the shared commit window ([`windowCommits`]) and one for + * every [`GROUP_ROWS_CHUNK`] charts' fact rows ([`groupRowsQuery`]), so two for + * most groups and five for TPC-DS's 99 charts. The result follows the order of + * `charts`, with `null` for a chart with no rows, and each payload is + * byte-identical to [`chartPayload`]'s. + * + * A chart's eligible commits (at or after its first row) are a suffix of all + * commits in (timestamp, sha) order, so its last-`n` window is the last + * `min(n, total)` commits of the shared window, ties included: exactly what + * [`seededCommitsInWindow`] returns. + */ +export async function groupChartPayloads( + group: GroupKey, + charts: readonly ChartKey[], + window: CommitWindow, +): Promise<(ChartResponse | null)[]> { + // Build every statement first, so a chart from another group throws before any query runs. + const slices: { charts: readonly ChartKey[]; query: { text: string; values: unknown[] } }[] = []; + for (let start = 0; start < charts.length; start += GROUP_ROWS_CHUNK) { + const slice = charts.slice(start, start + GROUP_ROWS_CHUNK); + slices.push({ charts: slice, query: groupRowsQuery(group, slice, window) }); + } + if (slices.length === 0) { + return []; + } + const shared = windowCommits(window); + const payloads: (ChartResponse | null)[] = []; + for (const slice of slices) { + const [commits, result] = await Promise.all([shared, getPool().query<GroupRow>(slice.query)]); + payloads.push(...payloadsFromRows(slice.charts, commits, result.rows, window)); + } + return payloads; +} + +/** Fold one [`groupRowsQuery`] result into the payloads of its `charts`, in their order. */ +function payloadsFromRows( + charts: readonly ChartKey[], + commits: readonly CommitPoint[], + rows: readonly GroupRow[], + window: CommitWindow, +): (ChartResponse | null)[] { + const totals = new Array<number>(charts.length).fill(0); + const rowsByChart = charts.map((): FactRow[] => []); + for (const row of rows) { + totals[row.ord - 1] = row.total_commits; + if (row.commit_sha !== null) { + rowsByChart[row.ord - 1].push(row); + } + } + return charts.map((key, i) => { + const total = totals[i]; + if (total === 0) { + return null; + } + let history = historyFor(total, window); + const seeded = commits.slice(Math.max(0, commits.length - history.loaded_commits)); + if (seeded.length < history.loaded_commits) { + // The statements run on separate connections, so an ingest that lands between them can + // count a commit the window did not return. Describe the commits actually loaded. + history = { + ...history, + start_index: total - seeded.length, + loaded_commits: seeded.length, + complete: false, + }; + } + const acc = new SeriesAccumulator(); + acc.seedCommits(seeded); + for (const row of rowsByChart[i]) { + recordRow(acc, key.k, row); + } + return acc.finish(chartDisplayName(key), chartUnitKind(key), history); + }); +} + // --------------------------------------------------------------------------- // Group + chart-link discovery, the TypeScript port of // `server/src/api/groups.rs`. `collectGroups` scans every fact table for its @@ -1101,13 +1536,14 @@ function discoverGroups(groupKind: GroupKind): Promise<Group[]> { /** * Bound on how many queries one request runs concurrently: the per-group - * summaries in [`collectGroups`] (PR-5.1.5 fix e) and the per-chart payloads in - * [`collectGroupCharts`]. Kept one below the `BENCH_DB_POOL_MAX` default because - * a cached fill holds one pool connection for its advisory lock. Excess work - * waits here, not on `pg`'s acquire list, where a wait longer than - * `BENCH_DB_CONNECTION_TIMEOUT_MS` fails the query. A smaller pool still queues - * the excess on that list. Overlapping the queries is what turns the sequential - * sum of round trips into roughly the slowest query's wall-clock. + * summaries in [`collectGroups`] (PR-5.1.5 fix e). [`collectGroupCharts`] no + * longer fans out; it runs two statements per group. Kept one below the + * `BENCH_DB_POOL_MAX` default because a cached fill holds one pool connection + * for its advisory lock. Excess work waits here, not on `pg`'s acquire list, + * where a wait longer than `BENCH_DB_CONNECTION_TIMEOUT_MS` fails the query. A + * smaller pool still queues the excess on that list. Overlapping the queries is + * what turns the sequential sum of round trips into roughly the slowest query's + * wall-clock. */ const QUERY_CONCURRENCY = 7; @@ -1175,9 +1611,10 @@ export async function collectGroups(): Promise<Group[]> { /** * Collect every chart inside one group with its full payload inlined, or `null` * when the group has no data (callers render a 404). Re-runs the full - * [`collectGroups`] discovery to resolve the group by slug, then fetches each - * chart link's payload, dropping links whose chart has no rows. Mirrors the - * Rust `collect_group_charts` (including its per-call re-discovery). + * [`collectGroups`] discovery to resolve the group by slug, then fetches every + * chart link's payload with [`groupChartPayloads`] (two statements for the whole + * group), dropping links whose chart has no rows. Mirrors the Rust + * `collect_group_charts` (including its per-call re-discovery). * * The full re-discovery makes this materially more query work than resolving the * one group directly; it inherits the Rust source's `TODO(#7812)` deferral of that @@ -1192,20 +1629,18 @@ export async function collectGroupCharts( if (group === undefined) { return null; } - // Fetch chart payloads concurrently rather than in a sequential `await` loop: - // on a Data Cache MISS a large group otherwise pays one SQL round-trip per - // chart back-to-back (99 for TPC-DS), which dominates the cold-start latency. - // [`QUERY_CONCURRENCY`] caps the queries in flight, so the rest wait here and - // not on the pool, whose acquire wait times out. The result keeps chart order. - const settled = await mapWithConcurrency( - group.charts, - QUERY_CONCURRENCY, - async (link): Promise<NamedChartResponse | null> => { - const chart = await chartPayload(chartKeyFromSlug(link.slug), window); - return chart === null ? null : { name: link.name, slug: link.slug, ...chart }; - }, + const payloads = await groupChartPayloads( + key, + group.charts.map((link) => chartKeyFromSlug(link.slug)), + window, ); - const charts = settled.filter((chart): chart is NamedChartResponse => chart !== null); + const charts: NamedChartResponse[] = []; + group.charts.forEach((link, i) => { + const chart = payloads[i]; + if (chart !== null) { + charts.push({ name: link.name, slug: link.slug, ...chart }); + } + }); if (charts.length === 0) { return null; } From f68093dc6df8c43db8b58a78bc08a5b253b78c22 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:08:10 +0100 Subject: [PATCH 24/34] Build a bench group's payloads from one snapshot groupChartPayloads ran the commit window and the first rows slice on two pooled connections, and later slices after them, each in its own READ COMMITTED snapshot. An ingest landing between them could give payloads the per-chart path never could: a commit counted in total_commits but missing from commits[], or a short chart losing its own first commit. Now the window and every rows slice run one after another on one client, in a REPEATABLE READ, READ ONLY transaction, rolled back on error, with the client always released. The clamp that patched one direction of the skew is gone. A group still runs two statements for up to 25 charts; a TPC-DS group (99 charts) runs five. The group fill now holds one more pool connection, after the summaries finish. New window-edge tests pin byte-equality with chartPayload for a random-access group, a vector-search group with a 0.1 threshold, and a compression-size group with a non-NULL variant block, with per-chart windows. The statement-count tests now read the pooled client. performance.md states the ?n=all memory as measured: under 1.5x, 1.2 to 1.8 GB against 1.3 GB. On the 2026-10-28 dump every group still matches the old path at n=100 and n=50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/docs/architecture/performance.md | 20 +-- bench/web/lib/group-charts.test.ts | 203 +++++++++++++++++++++++-- bench/web/lib/queries.ts | 78 ++++++---- 3 files changed, 251 insertions(+), 50 deletions(-) diff --git a/bench/docs/architecture/performance.md b/bench/docs/architecture/performance.md index e1e2e96..147dc9f 100644 --- a/bench/docs/architecture/performance.md +++ b/bench/docs/architecture/performance.md @@ -112,16 +112,18 @@ A group page inlines every chart's payload (99 for TPC-DS, 43 for Clickbench). `groupChartPayloads` ([`web/lib/queries.ts`](../../web/lib/queries.ts)) builds them from two statements instead of two per chart: the last `n` commits, and every chart's rows in that window, one `LATERAL` join per chart (in slices of -25 charts, one after another, so a TPC-DS group runs five). A chart's x-axis is -the last `min(n, total)` commits of the shared window, so the JSON is -byte-identical to `chartPayload`'s, which a test and a dump check pin. On one -connection the per-chart `MIN` over a query chart's whole history became the -cost, so it is a skip scan of `idx_query_measurements_summary`, and the commit -count is a binary search. `QUERY_CONCURRENCY` now bounds only the summaries in +25 charts, so a TPC-DS group runs five). They run one after another on one +connection, in one `REPEATABLE READ` snapshot. A chart's x-axis is the last +`min(n, total)` commits of the shared window, so the JSON is byte-identical to +`chartPayload`'s, which a test and a dump check pin. On one connection the +per-chart `MIN` over a query chart's whole history became the cost, so it is a +skip scan of `idx_query_measurements_summary`, and the commit count is a +binary search. `QUERY_CONCURRENCY` now bounds only the summaries in `collectGroups`. On the 2026-10-28 dump at `n=100`, warm: TPC-DS (NVMe) SF=1 -1.59 s → 0.32 s, Clickbench 0.87 s → 0.14 s, Compression 70 → 120 ms (its -`MIN` still reads every row). At `?n=all` the TPC-DS group takes 5.3 s against -4.9 s, with peak memory at most 1.34× the old fan-out's. +1.57 s → 0.31 s, Clickbench 0.82 s → 0.14 s, Compression 68 → 117 ms (its +`MIN` still reads every row). At `?n=all` the TPC-DS group takes 5.2 s against +4.7–4.9 s, and its peak memory stays under 1.5× the old fan-out's (1.2–1.8 GB +against 1.3 GB over four runs each). ### `query_measurements` reads filter on a denormalized timestamp diff --git a/bench/web/lib/group-charts.test.ts b/bench/web/lib/group-charts.test.ts index d23fb4b..a5dd76f 100644 --- a/bench/web/lib/group-charts.test.ts +++ b/bench/web/lib/group-charts.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +import { Client } from 'pg'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { getPool, resetPool } from './db'; @@ -92,6 +93,31 @@ describe('seriesFor', () => { const databaseAvailable = integrationDatabaseAvailable(); +/** The SQL text of a `query` call, whether it passed a string or a config object. */ +function sqlText(query: unknown): string { + return typeof query === 'string' ? query : (query as { text: string }).text; +} + +/** + * Run `build` and return what it issued on pooled clients: its data statements + * without the transaction's `BEGIN` and `COMMIT`, which it checks are there. It + * also checks that nothing went through `pool.query`. + */ +async function statementsOf<T>( + build: () => Promise<T>, +): Promise<{ result: T; statements: string[] }> { + const clientQuery = vi.spyOn(Client.prototype, 'query'); + const poolQuery = vi.spyOn(getPool(), 'query'); + const result = await build(); + const texts = clientQuery.mock.calls.map((call) => sqlText(call[0]).trim()); + expect(poolQuery).not.toHaveBeenCalled(); + expect(texts[0]).toBe('BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY'); + expect(texts.at(-1)).toBe('COMMIT'); + clientQuery.mockRestore(); + poolQuery.mockRestore(); + return { result, statements: texts.slice(1, -1) }; +} + /** The oracle: each link's `chartPayload`, named and flattened as the group route does. */ async function expectedCharts(group: Group, window: CommitWindow): Promise<string> { const charts: unknown[] = []; @@ -150,9 +176,10 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads on the canonical fixture const group = (await collectGroups()).find((g) => g.name === 'TPC-H (NVMe) (SF=1)'); const keys = (group?.charts ?? []).map((link) => chartKeyFromSlug(link.slug)); expect(keys.length).toBeGreaterThan(1); - const spy = vi.spyOn(getPool(), 'query'); - const payloads = await groupChartPayloads(TPCH, keys, parseCommitWindow(null)); - expect(spy).toHaveBeenCalledTimes(2); + const { result: payloads, statements } = await statementsOf(() => + groupChartPayloads(TPCH, keys, parseCommitWindow(null)), + ); + expect(statements).toHaveLength(2); expect(payloads.every((payload) => payload !== null)).toBe(true); }); @@ -162,13 +189,39 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads on the canonical fixture const empty = Array.from({ length: 25 }, (_, i) => ({ ...Q1, query_idx: 100 + i })); const keys: ChartKey[] = [...empty, { ...Q1, query_idx: 2 }, Q1]; const expected = await Promise.all(keys.map((key) => chartPayload(key, window))); - const spy = vi.spyOn(getPool(), 'query'); - const payloads = await groupChartPayloads(TPCH, keys, window); - expect(spy).toHaveBeenCalledTimes(3); + const { result: payloads, statements } = await statementsOf(() => + groupChartPayloads(TPCH, keys, window), + ); + expect(statements).toHaveLength(3); expect(payloads.slice(25).every((payload) => payload !== null)).toBe(true); expect(JSON.stringify(payloads)).toBe(JSON.stringify(expected)); }); + it('rolls back and releases its client when a statement fails', async () => { + const pool = getPool(); + const original = Client.prototype.query; + const clientQuery = vi.spyOn(Client.prototype, 'query').mockImplementation(function ( + this: Client, + ...args: unknown[] + ) { + if (sqlText(args[0]).includes('commit_times')) { + return Promise.reject(new Error('rows statement failed')); + } + return (original as (...a: unknown[]) => unknown).apply(this, args); + } as never); + await expect(groupChartPayloads(TPCH, [Q1], parseCommitWindow(null))).rejects.toThrow( + 'rows statement failed', + ); + const texts = clientQuery.mock.calls.map((call) => sqlText(call[0]).trim()); + expect(texts.at(-1)).toBe('ROLLBACK'); + expect(texts).not.toContain('COMMIT'); + clientQuery.mockRestore(); + expect(pool.idleCount).toBe(pool.totalCount); + // The released client still works. + const payloads = await groupChartPayloads(TPCH, [Q1], parseCommitWindow(null)); + expect(payloads[0]).not.toBeNull(); + }); + it('returns null in place of a chart with no rows', async () => { const window = parseCommitWindow(null); const payloads = await groupChartPayloads(TPCH, [Q1, { ...Q1, query_idx: 99 }], window); @@ -301,6 +354,67 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads at the window edges', () [measurementId, sha, dataset, variant, valueNs], ); } + // Random access: `ra` on every commit in both open modes, `rb` from E on (tied with D). + for (const [i, sha] of ALL_SHAS.entries()) { + for (const [openMode, valueNs] of [ + ['cached', 40 + i], + ['reopen', 400 + i], + ] as const) { + measurementId += 1; + await getPool().query( + `INSERT INTO random_access_times + (measurement_id, commit_sha, dataset, format, open_mode, value_ns, all_runtimes_ns) + VALUES ($1, $2, 'ra', 'f1', $3, $4, '{1}'::bigint[])`, + [measurementId, sha, openMode, valueNs], + ); + } + } + for (const [sha, valueNs] of [ + [E, 51], + [F, 52], + ] as const) { + measurementId += 1; + await getPool().query( + `INSERT INTO random_access_times + (measurement_id, commit_sha, dataset, format, open_mode, value_ns, all_runtimes_ns) + VALUES ($1, $2, 'rb', 'f1', 'cached', $3, '{1}'::bigint[])`, + [measurementId, sha, valueNs], + ); + } + // Vector search: 0.75 on every commit, 0.1 (not a dyadic fraction) from C2, 0.3 on F only. + for (const [threshold, shas] of [ + [0.75, ALL_SHAS], + [0.1, [C2, D, E, F]], + [0.3, [F]], + ] as const) { + for (const [i, sha] of shas.entries()) { + measurementId += 1; + await getPool().query( + `INSERT INTO vector_search_runs + (measurement_id, commit_sha, dataset, layout, flavor, threshold, value_ns, + all_runtimes_ns, matches, rows_scanned, bytes_scanned, iterations) + VALUES ($1, $2, 'vs', 'l', 'fl', $3, $4, '{1}'::bigint[], 1, 1, 1, 1)`, + [measurementId, sha, threshold, 600 + i], + ); + } + } + // Compression sizes: a NULL-variant chart and two non-NULL ones with different first commits. + for (const [sha, dataset, variant, valueBytes] of [ + [C0, 'sx', null, 71], + [F, 'sx', null, 72], + [D, 'sx', 'v1', 81], + [E, 'sx', 'v1', 82], + [F, 'sy', 'v2', 91], + ] as const) { + measurementId += 1; + await getPool().query( + `INSERT INTO compression_sizes + (measurement_id, commit_sha, dataset, dataset_variant, format, value_bytes, + uncompressed_bytes) + VALUES ($1, $2, $3, $4, 'f1', $5, 1000)`, + [measurementId, sha, dataset, variant, valueBytes], + ); + } }); afterAll(async () => { @@ -363,13 +477,84 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads at the window edges', () ]; const expected = await Promise.all(keys.map((key) => chartPayload(key, window))); expect(expected[2]).toBeNull(); - const spy = vi.spyOn(getPool(), 'query'); - const payloads = await groupChartPayloads({ k: 'CompressionTimeGroup' }, keys, window); - expect(spy).toHaveBeenCalledTimes(2); + const { result: payloads, statements } = await statementsOf(() => + groupChartPayloads({ k: 'CompressionTimeGroup' }, keys, window), + ); + expect(statements).toHaveLength(2); expect(JSON.stringify(payloads)).toBe(JSON.stringify(expected)); }, ); + const OTHER_FAMILIES: ReadonlyArray<readonly [GroupKey, ChartKey[]]> = [ + [ + { k: 'RandomAccessGroup', storage: 'nvme' }, + [ + { k: 'RandomAccess', dataset: 'ra' }, + { k: 'RandomAccess', dataset: 'rb' }, + ], + ], + [ + { k: 'VectorSearchGroup', dataset: 'vs', layout: 'l' }, + [0.75, 0.1, 0.3].map((threshold) => ({ + k: 'VectorSearch' as const, + dataset: 'vs', + layout: 'l', + threshold, + })), + ], + [ + { k: 'CompressionSizeGroup' }, + [ + { k: 'CompressionSize', dataset: 'sx', dataset_variant: 'v1' }, + { k: 'CompressionSize', dataset: 'sx', dataset_variant: null }, + { k: 'CompressionSize', dataset: 'sy', dataset_variant: 'v2' }, + ], + ], + ]; + + it.each(['1', '2', '3', '4', 'all'])( + 'matches chartPayload for random-access, vector-search and compression-size groups at ?n=%s', + async (n) => { + const window = parseCommitWindow(n); + for (const [group, keys] of OTHER_FAMILIES) { + const expected = await Promise.all(keys.map((key) => chartPayload(key, window))); + expect(expected.every((payload) => payload !== null)).toBe(true); + expect(JSON.stringify(await groupChartPayloads(group, keys, window))).toBe( + JSON.stringify(expected), + ); + } + }, + ); + + it('gives each random-access and vector-search chart its own window', async () => { + const window = parseCommitWindow('4'); + const [ra, rb] = await groupChartPayloads(...OTHER_FAMILIES[0], window); + expect(ra?.history).toEqual({ + total_commits: 6, + start_index: 2, + loaded_commits: 4, + complete: false, + }); + expect(rb?.history).toEqual({ + total_commits: 3, + start_index: 0, + loaded_commits: 3, + complete: true, + }); + expect(rb?.commits.map((c) => c.sha)).toEqual([D, E, F]); + expect(rb?.series).toEqual({ 'f1:hot': [null, 51, 52] }); + const [, tenth, short] = await groupChartPayloads(...OTHER_FAMILIES[1], window); + expect(tenth?.history).toEqual({ + total_commits: 4, + start_index: 0, + loaded_commits: 4, + complete: true, + }); + expect(tenth?.series).toEqual({ fl: [600, 601, 602, 603] }); + expect(short?.commits.map((c) => c.sha)).toEqual([F]); + expect(short?.series).toEqual({ fl: [600] }); + }); + it('keeps sibling groups apart', async () => { const window = parseCommitWindow('all'); const [g] = await groupChartPayloads(G, [queryChart(G, 1)], window); diff --git a/bench/web/lib/queries.ts b/bench/web/lib/queries.ts index 2067f53..bedfc37 100644 --- a/bench/web/lib/queries.ts +++ b/bench/web/lib/queries.ts @@ -13,7 +13,8 @@ * fact-row pass that threads values onto that x-axis through a * [`SeriesAccumulator`]. The result is a [`ChartResponse`] with the same wire * shape the Axum server emits. [`groupChartPayloads`] builds the same payloads - * for every chart of one group with two statements in all. + * for every chart of one group with two statements, or more in slices of 25 + * charts for large groups. * * Behaviour-preservation notes (this is a substrate migration, DuckDB -> * Postgres): @@ -31,6 +32,7 @@ * whole-second, UTC) git commit timestamps. */ +import type { PoolClient } from 'pg'; import { getPool } from './db'; import { randomAccessStorage } from './random-access'; import { groupDescription } from './descriptions'; @@ -674,7 +676,7 @@ type CommitRow = { commit_sha: string; ts_text: string; message: string; url: st * The last `n` commits of all (every commit for `?n=all`), oldest first. Each * chart's x-axis is a suffix of this list. A `null` `LIMIT` is no limit. */ -async function windowCommits(window: CommitWindow): Promise<CommitPoint[]> { +async function windowCommits(client: PoolClient, window: CommitWindow): Promise<CommitPoint[]> { const text = ` SELECT commit_sha, to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS"+00"') AS ts_text, @@ -683,7 +685,7 @@ async function windowCommits(window: CommitWindow): Promise<CommitPoint[]> { FROM (SELECT * FROM commits ORDER BY timestamp DESC, commit_sha DESC LIMIT $1) w ORDER BY timestamp ASC, commit_sha ASC `; - const rows = (await getPool().query<CommitRow>(text, [commitWindowLimit(window)])).rows; + const rows = (await client.query<CommitRow>(text, [commitWindowLimit(window)])).rows; return rows.map((row) => ({ sha: row.commit_sha, timestamp: row.ts_text, @@ -997,6 +999,11 @@ const GROUP_ROWS_CHUNK = 25; * commits in (timestamp, sha) order, so its last-`n` window is the last * `min(n, total)` commits of the shared window, ties included: exactly what * [`seededCommitsInWindow`] returns. + * + * That holds only if every statement sees the same commits, so they run one + * after another on one connection, in a `REPEATABLE READ` transaction that + * shares one snapshot. An ingest landing mid-build cannot then count a commit + * the window lacks, or drop a chart's first commit from it. */ export async function groupChartPayloads( group: GroupKey, @@ -1012,13 +1019,29 @@ export async function groupChartPayloads( if (slices.length === 0) { return []; } - const shared = windowCommits(window); - const payloads: (ChartResponse | null)[] = []; - for (const slice of slices) { - const [commits, result] = await Promise.all([shared, getPool().query<GroupRow>(slice.query)]); - payloads.push(...payloadsFromRows(slice.charts, commits, result.rows, window)); + const client = await getPool().connect(); + // A client whose ROLLBACK failed is broken; release it with the error so the pool discards it. + let broken: Error | undefined; + try { + await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY'); + const commits = await windowCommits(client, window); + const payloads: (ChartResponse | null)[] = []; + for (const slice of slices) { + const result = await client.query<GroupRow>(slice.query); + payloads.push(...payloadsFromRows(slice.charts, commits, result.rows, window)); + } + await client.query('COMMIT'); + return payloads; + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + broken = rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)); + } + throw error; + } finally { + client.release(broken); } - return payloads; } /** Fold one [`groupRowsQuery`] result into the payloads of its `charts`, in their order. */ @@ -1041,20 +1064,9 @@ function payloadsFromRows( if (total === 0) { return null; } - let history = historyFor(total, window); - const seeded = commits.slice(Math.max(0, commits.length - history.loaded_commits)); - if (seeded.length < history.loaded_commits) { - // The statements run on separate connections, so an ingest that lands between them can - // count a commit the window did not return. Describe the commits actually loaded. - history = { - ...history, - start_index: total - seeded.length, - loaded_commits: seeded.length, - complete: false, - }; - } + const history = historyFor(total, window); const acc = new SeriesAccumulator(); - acc.seedCommits(seeded); + acc.seedCommits(commits.slice(commits.length - history.loaded_commits)); for (const row of rowsByChart[i]) { recordRow(acc, key.k, row); } @@ -1537,13 +1549,14 @@ function discoverGroups(groupKind: GroupKind): Promise<Group[]> { /** * Bound on how many queries one request runs concurrently: the per-group * summaries in [`collectGroups`] (PR-5.1.5 fix e). [`collectGroupCharts`] no - * longer fans out; it runs two statements per group. Kept one below the - * `BENCH_DB_POOL_MAX` default because a cached fill holds one pool connection - * for its advisory lock. Excess work waits here, not on `pg`'s acquire list, - * where a wait longer than `BENCH_DB_CONNECTION_TIMEOUT_MS` fails the query. A - * smaller pool still queues the excess on that list. Overlapping the queries is - * what turns the sequential sum of round trips into roughly the slowest query's - * wall-clock. + * longer fans out; it runs two statements per group, or more in slices of 25 + * charts for large groups, one after another on one connection, after the + * summaries finish. Kept one below the `BENCH_DB_POOL_MAX` default because a + * cached fill holds one pool connection for its advisory lock. Excess work + * waits here, not on `pg`'s acquire list, where a wait longer than + * `BENCH_DB_CONNECTION_TIMEOUT_MS` fails the query. A smaller pool still queues + * the excess on that list. Overlapping the queries is what turns the + * sequential sum of round trips into roughly the slowest query's wall-clock. */ const QUERY_CONCURRENCY = 7; @@ -1612,9 +1625,10 @@ export async function collectGroups(): Promise<Group[]> { * Collect every chart inside one group with its full payload inlined, or `null` * when the group has no data (callers render a 404). Re-runs the full * [`collectGroups`] discovery to resolve the group by slug, then fetches every - * chart link's payload with [`groupChartPayloads`] (two statements for the whole - * group), dropping links whose chart has no rows. Mirrors the Rust - * `collect_group_charts` (including its per-call re-discovery). + * chart link's payload with [`groupChartPayloads`] (two statements, or more in + * slices of 25 charts for large groups), dropping links whose chart has no + * rows. Mirrors the Rust `collect_group_charts` (including its per-call + * re-discovery). * * The full re-discovery makes this materially more query work than resolving the * one group directly; it inherits the Rust source's `TODO(#7812)` deferral of that From c55792afadd1151372d48553855e0a5afdfa2ba5 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:19:17 +0100 Subject: [PATCH 25/34] Split bench's chart store into modules Move lib/chart-store.ts into lib/chart-store/: queues, bundles, global-filter and group-store, with index.ts re-exporting each module so '@/lib/chart-store' keeps its export surface. The code moves verbatim. group-store reads the global filter through getGlobalFilterSnapshot() instead of the module variable. Add a pin test for the chart island before it is split: under StrictMode, one wheel event pans once, and unmount removes the listener. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/web/components/Chart.loading.test.tsx | 70 ++- bench/web/lib/chart-store.ts | 632 -------------------- bench/web/lib/chart-store/bundles.ts | 200 +++++++ bench/web/lib/chart-store/global-filter.ts | 91 +++ bench/web/lib/chart-store/group-store.ts | 251 ++++++++ bench/web/lib/chart-store/index.ts | 25 + bench/web/lib/chart-store/queues.ts | 87 +++ 7 files changed, 723 insertions(+), 633 deletions(-) delete mode 100644 bench/web/lib/chart-store.ts create mode 100644 bench/web/lib/chart-store/bundles.ts create mode 100644 bench/web/lib/chart-store/global-filter.ts create mode 100644 bench/web/lib/chart-store/group-store.ts create mode 100644 bench/web/lib/chart-store/index.ts create mode 100644 bench/web/lib/chart-store/queues.ts diff --git a/bench/web/components/Chart.loading.test.tsx b/bench/web/components/Chart.loading.test.tsx index cc1162b..84095cd 100644 --- a/bench/web/components/Chart.loading.test.tsx +++ b/bench/web/components/Chart.loading.test.tsx @@ -3,7 +3,7 @@ // @vitest-environment jsdom -import { act } from 'react'; +import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -339,6 +339,74 @@ describe('Chart opt-in full-history loading', () => { expect(chip?.dataset.state).not.toBe('windowed'); }); + it('pans once per wheel event and stops after unmount', async () => { + // Pins the construction config and the wheel-pan listener's lifecycle. + // StrictMode runs the mount effect as mount, cleanup, remount on the same + // canvas node: the first controller's teardown must leave no wheel + // listener behind, and unmount must remove the survivor's. + interface StubOptions { + plugins: { tooltip: { enabled: boolean }; zoom: { limits: { x: unknown } } }; + } + const charts: StubChart[] = []; + class StubChart { + data: { labels: unknown[]; datasets: unknown[] }; + options: StubOptions; + pan = vi.fn(); + constructor( + _canvas: HTMLCanvasElement, + config: { data: { labels: unknown[]; datasets: unknown[] }; options: StubOptions }, + ) { + this.data = { labels: config.data.labels ?? [], datasets: config.data.datasets ?? [] }; + this.options = config.options; + charts.push(this); + } + update(): void {} + destroy(): void {} + } + vi.mocked(loadChartJs).mockResolvedValueOnce(StubChart as never); + responders.push({ + match: (u) => u.includes('n=100'), + respond: () => Promise.resolve(jsonResponse(completePayload(20))), + }); + container.innerHTML = + '<section class="group-details">' + + '<details class="group-disclosure" open><summary class="group-summary">g</summary></details>' + + '<div class="chart-grid"><div id="mount"></div></div>' + + '</section>'; + const mount = container.querySelector('#mount') as HTMLElement; + root = createRoot(mount); + await act(async () => { + root?.render( + <StrictMode> + <Chart slug="qm.eyJrIjoidHBjaCJ9" name="tpch q1" index={0} groupSlug="tpch" /> + </StrictMode>, + ); + }); + // The bundle 404, the per-chart fetch, the loader await and the React + // commit each take a few microtask turns; flush generously. + await act(async () => { + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } + }); + expect(charts).toHaveLength(1); + const stub = charts[0]; + expect(stub.options.plugins.tooltip.enabled).toBe(false); + expect(stub.options.plugins.zoom.limits.x).toEqual({ min: 0, max: 19, minRange: 4 }); + + const canvas = container.querySelector('canvas') as HTMLCanvasElement; + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 40, cancelable: true })); + expect(stub.pan).toHaveBeenCalledTimes(1); + expect(stub.pan).toHaveBeenCalledWith({ x: 20 }, undefined, 'none'); + + await act(async () => { + root?.unmount(); + root = null; + }); + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 40, cancelable: true })); + expect(stub.pan).toHaveBeenCalledTimes(1); + }); + describe('PR-5.0.95 fetch resilience: timeout + abort', () => { // A fetch stub whose `?n=100` response never resolves on its own but DOES // reject when its AbortSignal fires, so timeout/destroy aborts are observable. diff --git a/bench/web/lib/chart-store.ts b/bench/web/lib/chart-store.ts deleted file mode 100644 index b2919a9..0000000 --- a/bench/web/lib/chart-store.ts +++ /dev/null @@ -1,632 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -/** - * Client-side singletons shared by the chart islands, the stateful counterpart - * to the pure helpers in `lib/chart-format.ts`: the bounded-concurrency fetch - * queues, the global engine/format filter store, and the per-group filter/Y - * stores. One instance of each lives per browser tab (module scope), mirroring - * the page-level closures of `server/static/chart-init.js`. - * - * The stores expose a `subscribe`/`getSnapshot` pair compatible with React's - * `useSyncExternalStore`, so the islands (every `Chart`, the global - * `FilterBar`, each `GroupToolbar`) re-render and re-apply filters when any of - * them mutates shared state. Snapshots are replaced wholesale on mutation; - * a stable reference means "nothing changed". - * - * This module holds browser-tab state and is only imported from `'use client'` - * components; it has no DOM dependency itself, so node-environment vitest can - * exercise the queues and stores directly. - */ - -import { - BUNDLE_CONCURRENCY, - chartHasRecentData, - FETCH_TIMEOUT_MS, - FULL_HISTORY_CONCURRENCY, - HYDRATION_CONCURRENCY, - seedActiveFormats, - seedActiveFromAllowlist, - seriesPassesFilter, - seriesPassesGroupFilter, - type FilterUniverse, - type GlobalFilterState, -} from '@/lib/chart-format'; -import type { ChartResponse, GroupChartsResponse, SeriesTag } from '@/lib/queries'; - -// --------------------------------------------------------------------------- -// Bounded priority queues (ported from chart-init.js sections 11 and 15). -// --------------------------------------------------------------------------- - -/** A queued fetch task; `priority` may be bumped while still queued. */ -export interface QueueEntry { - priority: number; - /** Resolves/rejects with the task's outcome once the queue runs it. */ - promise: Promise<unknown>; -} - -interface InternalEntry extends QueueEntry { - task: () => Promise<unknown>; - resolve: (value: unknown) => void; - reject: (err: unknown) => void; -} - -/** A bounded-concurrency, priority-ordered task queue. */ -export interface TaskQueue { - /** Enqueue `task` and start draining; higher `priority` runs first. */ - schedule(task: () => Promise<unknown>, priority?: number): QueueEntry; - /** Re-sort and start any idle slots (called after a priority bump). */ - drain(): void; -} - -function makeQueue(concurrency: number): TaskQueue { - let active = 0; - const queue: InternalEntry[] = []; - - function drain(): void { - while (active < concurrency && queue.length > 0) { - queue.sort((a, b) => b.priority - a.priority); - const item = queue.shift(); - if (item === undefined) { - return; - } - active += 1; - Promise.resolve() - .then(item.task) - .then( - (value) => { - active -= 1; - item.resolve(value); - drain(); - }, - (err: unknown) => { - active -= 1; - item.reject(err); - drain(); - }, - ); - } - } - - function schedule(task: () => Promise<unknown>, priority = 0): QueueEntry { - let resolve!: (value: unknown) => void; - let reject!: (err: unknown) => void; - const promise = new Promise<unknown>((res, rej) => { - resolve = res; - reject = rej; - }); - const entry: InternalEntry = { task, priority, promise, resolve, reject }; - queue.push(entry); - drain(); - return entry; - } - - return { schedule, drain }; -} - -/** Per-tab queue for the initial latest-100 chart fetches. */ -export const hydrationQueue: TaskQueue = makeQueue(HYDRATION_CONCURRENCY); - -/** Per-tab queue for the background `?n=all` full-history upgrades. */ -export const fullHistoryQueue: TaskQueue = makeQueue(FULL_HISTORY_CONCURRENCY); - -// --------------------------------------------------------------------------- -// Group-bundle fetch + session payload cache (PR-5.0.97). -// --------------------------------------------------------------------------- - -/** Per-tab queue for the per-group `/api/group/{slug}?n=100` bundle fetches. */ -export const bundleQueue: TaskQueue = makeQueue(BUNDLE_CONCURRENCY); - -// Session-lifetime cache of the default last-100 chart payloads, keyed by chart -// slug and filled by `ensureGroupBundle`. Closing and reopening a group reads -// from here, so it never refetches. An open tab keeps these until reload; a -// server-side revalidation is picked up on the next full load (a data-version -// invalidation is future work, not built here). -const payloadCache = new Map<string, ChartResponse>(); - -/** The cached default payload for `slug`, or `undefined` on a miss. */ -export function getCachedPayload(slug: string): ChartResponse | undefined { - return payloadCache.get(slug); -} - -/** Seed the cache for one chart slug (idempotent; last write wins). Internal to - * the store; `ensureGroupBundle` is its only caller. */ -function primePayload(slug: string, payload: ChartResponse): void { - payloadCache.set(slug, payload); -} - -/** Only unavailable bundles and successful bundles missing a chart permit a - * per-chart fallback. Failures require an explicit shared retry. */ -export type BundleResult = - | { status: 'success' } - | { status: 'unavailable' } - | { status: 'failed'; error: unknown } - | { status: 'aborted' }; - -// Successes persist for the session. Other settled outcomes persist until the -// group closes or the user retries, so later cards do not retry passively. -const settledBundles = new Map<string, BundleResult>(); -const bundleRetryListeners = new Map<string, Set<() => void>>(); - -/** Subscribe a chart to user-initiated retries of its group bundle. */ -export function subscribeGroupBundleRetry(groupSlug: string, cb: () => void): () => void { - let listeners = bundleRetryListeners.get(groupSlug); - if (!listeners) { - listeners = new Set(); - bundleRetryListeners.set(groupSlug, listeners); - } - listeners.add(cb); - return () => { - listeners.delete(cb); - if (listeners.size === 0) { - bundleRetryListeners.delete(groupSlug); - } - }; -} - -/** Retry one failed bundle. Waiting cards join the first subscriber's request. */ -export function retryGroupBundle(groupSlug: string): boolean { - if (settledBundles.get(groupSlug)?.status !== 'failed') { - return false; - } - settledBundles.delete(groupSlug); - for (const cb of bundleRetryListeners.get(groupSlug) ?? []) { - cb(); - } - return true; -} - -/** Clear the cache and in-flight bundle map. TEST-ONLY: production never evicts - * within a tab session. */ -export function resetPayloadCache(): void { - payloadCache.clear(); - inFlightBundles.clear(); - settledBundles.clear(); -} - -/** A group's in-flight bundle fetch: the queue entry (for priority bumps), its - * aborter (for group-close cancellation), and the settle promise callers join. */ -interface BundleInFlight { - entry: QueueEntry; - controller: AbortController; - promise: Promise<BundleResult>; -} - -const inFlightBundles = new Map<string, BundleInFlight>(); - -/** - * Fetch one group's default last-100 bundle (`/api/group/{slug}?n=100`) and - * prime [`payloadCache`] for every chart in it. Concurrent callers for the same - * group share one in-flight fetch (priority is bumped to the highest caller's). - * A 404 permits per-chart fallback. Other failures remain shared until an - * explicit retry or group reopen. Close cancellation stays silent. - */ -export function ensureGroupBundle(groupSlug: string, priority: number): Promise<BundleResult> { - const settled = settledBundles.get(groupSlug); - if (settled) { - return Promise.resolve(settled); - } - const existing = inFlightBundles.get(groupSlug); - if (existing) { - if (priority > existing.entry.priority) { - existing.entry.priority = priority; - bundleQueue.drain(); - } - return existing.promise; - } - const url = `/api/group/${encodeURIComponent(groupSlug)}?n=100`; - const controller = new AbortController(); - const entry = bundleQueue.schedule(async () => { - controller.signal.throwIfAborted(); - // The timeout starts when the task actually runs (not while queued), so it - // bounds the fetch, not the queue wait. A `TimeoutError` reason lets the - // catch tell a timeout apart from a close/destroy `AbortError`. - const timer = setTimeout( - () => controller.abort(new DOMException('Fetch timed out', 'TimeoutError')), - FETCH_TIMEOUT_MS, - ); - try { - const r = await fetch(url, { - headers: { accept: 'application/json' }, - signal: controller.signal, - }); - if (r.status === 404) { - return null; - } - if (!r.ok) { - throw new Error(`HTTP ${r.status}`); - } - const body = (await r.json()) as GroupChartsResponse | null; - if (body === null) { - throw new Error('invalid group bundle'); - } - return body; - } finally { - clearTimeout(timer); - } - }, priority); - const promise: Promise<BundleResult> = entry.promise - .then((body): BundleResult => { - if (inFlightBundles.get(groupSlug)?.entry !== entry) { - return { status: 'aborted' }; - } - controller.signal.throwIfAborted(); - if (body !== null) { - const bundle = body as GroupChartsResponse; - for (const chart of bundle.charts) { - // `NamedChartResponse` is `ChartResponse & { name, slug }`; the extra - // keys are harmless in the cached payload. - primePayload(chart.slug, chart); - noteGroupSeries(groupSlug, chart.series_meta); - // The bundle IS the group's latest-100 window, so classify every - // chart's empty-window state here — including cards that never - // intersect the viewport (their islands never seed a payload). - noteChartRecentData(groupSlug, chart.slug, chartHasRecentData(chart)); - } - return { status: 'success' }; - } - return { status: 'unavailable' }; - }) - .catch((err: unknown): BundleResult => { - if ( - inFlightBundles.get(groupSlug)?.entry !== entry || - (controller.signal.aborted && controller.signal.reason?.name === 'AbortError') - ) { - return { status: 'aborted' }; - } - return { - status: 'failed', - error: controller.signal.aborted ? controller.signal.reason : err, - }; - }) - .then((result): BundleResult => { - if (inFlightBundles.get(groupSlug)?.entry !== entry) { - return { status: 'aborted' }; - } - inFlightBundles.delete(groupSlug); - settledBundles.set(groupSlug, result); - return result; - }); - inFlightBundles.set(groupSlug, { entry, controller, promise }); - return promise; -} - -/** Abort a group's in-flight bundle fetch (on group close) and reset its - * per-cycle state so a reopen re-issues. Idempotent. A successfully completed - * bundle's cache stays valid, so a reopen of a fully cached group issues nothing. */ -export function abortGroupBundle(groupSlug: string): void { - const inFlight = inFlightBundles.get(groupSlug); - if (inFlight) { - inFlight.controller.abort(new DOMException('group closed', 'AbortError')); - inFlightBundles.delete(groupSlug); - } - if (settledBundles.get(groupSlug)?.status !== 'success') { - settledBundles.delete(groupSlug); - } -} - -// --------------------------------------------------------------------------- -// Global filter store. -// --------------------------------------------------------------------------- - -/** The global filter's full state: the chip universe plus the active sets. */ -export interface GlobalFilterSnapshot { - universe: FilterUniverse; - active: GlobalFilterState; -} - -const EMPTY_GLOBAL: GlobalFilterSnapshot = { - universe: { engines: [], formats: [] }, - active: { engines: [], formats: [] }, -}; - -let globalSnapshot: GlobalFilterSnapshot = EMPTY_GLOBAL; -const globalListeners = new Set<() => void>(); - -function notifyGlobal(): void { - for (const cb of globalListeners) { - cb(); - } -} - -/** Subscribe to global-filter changes; returns the unsubscribe function. */ -export function subscribeGlobalFilter(cb: () => void): () => void { - globalListeners.add(cb); - return () => { - globalListeners.delete(cb); - }; -} - -/** Current global-filter snapshot (stable reference until a mutation). */ -export function getGlobalFilterSnapshot(): GlobalFilterSnapshot { - return globalSnapshot; -} - -/** - * Seed the store from server-provided props: the chip universe plus the URL - * `?engine=`/`?format=` allowlists (null uses the defaults; empty hides all). - * Called by the filter bar on mount and again on soft navigation, so the store - * tracks the URL state of the page that most recently mounted it. - */ -export function initGlobalFilter( - universe: FilterUniverse, - engineAllowlist: readonly string[] | null, - formatAllowlist: readonly string[] | null, -): void { - globalSnapshot = { - universe: { engines: [...universe.engines], formats: [...universe.formats] }, - active: { - engines: seedActiveFromAllowlist(engineAllowlist, universe.engines), - formats: seedActiveFormats(formatAllowlist, universe.formats), - }, - }; - notifyGlobal(); -} - -/** - * Toggle one chip independently. The `'*'` value is the one-shot reset chip: - * it forces every chip in that dimension back to active. Specific chips flip - * their own membership in the active set. - */ -export function toggleGlobalFilterValue(dim: 'engine' | 'format', value: string): void { - const key = dim === 'engine' ? 'engines' : 'formats'; - const active = { ...globalSnapshot.active }; - if (value === '*') { - active[key] = [...globalSnapshot.universe[key]]; - } else { - const list = [...active[key]]; - const idx = list.indexOf(value); - if (idx === -1) { - list.push(value); - } else { - list.splice(idx, 1); - } - active[key] = list; - } - globalSnapshot = { ...globalSnapshot, active }; - notifyGlobal(); -} - -// --------------------------------------------------------------------------- -// Per-group stores (ported from chart-init.js section 17's section-node state). -// --------------------------------------------------------------------------- - -/** One group's override state plus the series labels its charts have surfaced. */ -export interface GroupSnapshot { - /** Dataset labels the user has toggled off via the group's filter dropdown. */ - hiddenSeries: string[]; - /** Dataset labels explicitly restored over a global engine/format filter. */ - shownSeries: string[]; - /** The group-level Y override; `null` means "no override; defer to charts". */ - groupY: 'linear' | 'log' | null; - /** Series labels (with engine/format tags) surfaced by hydrated charts. */ - knownSeries: Record<string, SeriesTag>; - /** Chart slugs whose latest-100 window carries no data point at all (see - * [`chartHasRecentData`]); these cards hide by default. Data-derived, like - * `knownSeries`, so a group Reset leaves it intact. */ - emptyCharts: string[]; - /** The group toolbar's "show empty charts" toggle; `false` (the default and - * the post-Reset state) hides every `emptyCharts` card. */ - showEmptyCharts: boolean; -} - -const EMPTY_GROUP: GroupSnapshot = { - hiddenSeries: [], - shownSeries: [], - groupY: null, - knownSeries: {}, - emptyCharts: [], - showEmptyCharts: false, -}; - -interface GroupStore { - snapshot: GroupSnapshot; - listeners: Set<() => void>; -} - -const groupStores = new Map<string, GroupStore>(); - -function groupStore(slug: string): GroupStore { - let store = groupStores.get(slug); - if (store === undefined) { - store = { snapshot: EMPTY_GROUP, listeners: new Set() }; - groupStores.set(slug, store); - } - return store; -} - -function setGroupSnapshot(slug: string, next: GroupSnapshot): void { - const store = groupStore(slug); - store.snapshot = next; - for (const cb of store.listeners) { - cb(); - } -} - -/** Subscribe to one group's changes; returns the unsubscribe function. */ -export function subscribeGroup(slug: string, cb: () => void): () => void { - const store = groupStore(slug); - store.listeners.add(cb); - return () => { - store.listeners.delete(cb); - }; -} - -/** Current snapshot for one group (stable reference until a mutation). */ -export function getGroupSnapshot(slug: string): GroupSnapshot { - return groupStore(slug).snapshot; -} - -/** Seed one group's local series overrides from the current page URL. */ -export function initGroupFilter( - slug: string, - hiddenSeries: readonly string[], - shownSeries: readonly string[] = [], -): void { - const prev = groupStore(slug).snapshot; - const nextShown = [...new Set(shownSeries)]; - const nextHidden = [...new Set(hiddenSeries)].filter((label) => !nextShown.includes(label)); - if ( - prev.hiddenSeries.length === nextHidden.length && - prev.hiddenSeries.every((label, index) => label === nextHidden[index]) && - prev.shownSeries.length === nextShown.length && - prev.shownSeries.every((label, index) => label === nextShown[index]) - ) { - return; - } - setGroupSnapshot(slug, { ...prev, hiddenSeries: nextHidden, shownSeries: nextShown }); -} - -/** The shared empty snapshot, for islands that have no enclosing group. */ -export function emptyGroupSnapshot(): GroupSnapshot { - return EMPTY_GROUP; -} - -/** Effective visibility after the group override falls back to global state. */ -export function groupSeriesIsVisible( - snapshot: GroupSnapshot, - label: string, - global: GlobalFilterSnapshot = globalSnapshot, -): boolean { - const defaultVisible = seriesPassesFilter( - snapshot.knownSeries[label], - global.active, - global.universe, - ); - return seriesPassesGroupFilter(snapshot, label, defaultVisible); -} - -function setSeriesVisibility( - snapshot: GroupSnapshot, - labels: readonly string[], - visible: boolean, -): Pick<GroupSnapshot, 'hiddenSeries' | 'shownSeries'> { - const selected = new Set(labels); - let hiddenSeries = snapshot.hiddenSeries.filter((label) => !selected.has(label)); - let shownSeries = snapshot.shownSeries.filter((label) => !selected.has(label)); - if (visible) { - shownSeries = [ - ...shownSeries, - ...labels.filter((label) => { - const defaultVisible = seriesPassesFilter( - snapshot.knownSeries[label], - globalSnapshot.active, - globalSnapshot.universe, - ); - return !defaultVisible && !shownSeries.includes(label); - }), - ]; - } else { - hiddenSeries = [...hiddenSeries, ...labels.filter((label) => !hiddenSeries.includes(label))]; - } - return { hiddenSeries, shownSeries }; -} - -/** Toggle one series against its effective global-plus-local visibility. */ -export function toggleGroupSeries(slug: string, label: string): void { - const prev = groupStore(slug).snapshot; - const overrides = setSeriesVisibility(prev, [label], !groupSeriesIsVisible(prev, label)); - setGroupSnapshot(slug, { ...prev, ...overrides }); -} - -/** - * Apply an engine/format macro click: find every known series whose tag - * matches. If every match is currently visible, hide them all; otherwise (any - * match already hidden) show them all, so the macro chip toggles between "all - * matching visible" and "all matching hidden". - */ -export function applyGroupMacro(slug: string, dim: 'engine' | 'format', value: string): void { - const prev = groupStore(slug).snapshot; - const matching = Object.keys(prev.knownSeries).filter( - (label) => prev.knownSeries[label]?.[dim] === value, - ); - if (matching.length === 0) { - return; - } - const allVisible = matching.every((label) => groupSeriesIsVisible(prev, label)); - const overrides = setSeriesVisibility(prev, matching, !allVisible); - setGroupSnapshot(slug, { ...prev, ...overrides }); -} - -/** Clear the group's series filter (the `'*'` reset chip). */ -export function clearGroupSeriesFilter(slug: string): void { - const prev = groupStore(slug).snapshot; - setGroupSnapshot(slug, { ...prev, hiddenSeries: [], shownSeries: [] }); -} - -/** Set the group-level Y override broadcast to non-sticky charts. */ -export function setGroupY(slug: string, y: 'linear' | 'log'): void { - const prev = groupStore(slug).snapshot; - setGroupSnapshot(slug, { ...prev, groupY: y }); -} - -/** Reset the group: empty series filter, no Y override, and empty charts back - * to hidden (the default). Per-card legend overrides and per-card sticky Y - * choices are intentionally NOT cleared, matching the v3 reset semantics. */ -export function resetGroup(slug: string): void { - const prev = groupStore(slug).snapshot; - setGroupSnapshot(slug, { - ...prev, - hiddenSeries: [], - shownSeries: [], - groupY: null, - showEmptyCharts: false, - }); -} - -/** - * Record whether one chart's latest-100 window carries any data (idempotent). - * Called with a [`chartHasRecentData`] verdict wherever a default-window payload - * lands — the group-bundle prime here in the store, plus the per-chart fetch - * paths in the chart island — so `emptyCharts` fills in as the group hydrates - * and the affected cards hide without a layout pass per notification. - */ -export function noteChartRecentData(slug: string, chartSlug: string, hasData: boolean): void { - const prev = groupStore(slug).snapshot; - const isEmpty = prev.emptyCharts.includes(chartSlug); - if (isEmpty === !hasData) { - return; - } - const emptyCharts = hasData - ? prev.emptyCharts.filter((s) => s !== chartSlug) - : [...prev.emptyCharts, chartSlug]; - setGroupSnapshot(slug, { ...prev, emptyCharts }); -} - -/** Set the group toolbar's "show empty charts" toggle. */ -export function setShowEmptyCharts(slug: string, show: boolean): void { - const prev = groupStore(slug).snapshot; - if (prev.showEmptyCharts === show) { - return; - } - setGroupSnapshot(slug, { ...prev, showEmptyCharts: show }); -} - -/** Whether a group snapshot currently hides `chartSlug` as an empty card: - * classified empty in the latest-100 window and not revealed by the toggle. */ -export function chartIsHiddenAsEmpty(snapshot: GroupSnapshot, chartSlug: string): boolean { - return !snapshot.showEmptyCharts && snapshot.emptyCharts.includes(chartSlug); -} - -/** - * Fold a hydrated chart's `series_meta` labels into the group's running set - * (idempotent), so the group toolbar's series chip row grows as charts in the - * group hydrate. No-op when every label is already known. - */ -export function noteGroupSeries(slug: string, meta: Record<string, SeriesTag> | undefined): void { - if (!meta) { - return; - } - const prev = groupStore(slug).snapshot; - let added = false; - const knownSeries = { ...prev.knownSeries }; - for (const [label, tag] of Object.entries(meta)) { - if (!(label in knownSeries)) { - knownSeries[label] = tag ?? {}; - added = true; - } - } - if (added) { - setGroupSnapshot(slug, { ...prev, knownSeries }); - } -} diff --git a/bench/web/lib/chart-store/bundles.ts b/bench/web/lib/chart-store/bundles.ts new file mode 100644 index 0000000..2b76289 --- /dev/null +++ b/bench/web/lib/chart-store/bundles.ts @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { chartHasRecentData, FETCH_TIMEOUT_MS } from '@/lib/chart-format'; +import type { ChartResponse, GroupChartsResponse } from '@/lib/queries'; + +import { noteChartRecentData, noteGroupSeries } from './group-store'; +import { bundleQueue, type QueueEntry } from './queues'; + +// --------------------------------------------------------------------------- +// Group-bundle fetch + session payload cache (PR-5.0.97). +// --------------------------------------------------------------------------- + +// Session-lifetime cache of the default last-100 chart payloads, keyed by chart +// slug and filled by `ensureGroupBundle`. Closing and reopening a group reads +// from here, so it never refetches. An open tab keeps these until reload; a +// server-side revalidation is picked up on the next full load (a data-version +// invalidation is future work, not built here). +const payloadCache = new Map<string, ChartResponse>(); + +/** The cached default payload for `slug`, or `undefined` on a miss. */ +export function getCachedPayload(slug: string): ChartResponse | undefined { + return payloadCache.get(slug); +} + +/** Seed the cache for one chart slug (idempotent; last write wins). Internal to + * the store; `ensureGroupBundle` is its only caller. */ +function primePayload(slug: string, payload: ChartResponse): void { + payloadCache.set(slug, payload); +} + +/** Only unavailable bundles and successful bundles missing a chart permit a + * per-chart fallback. Failures require an explicit shared retry. */ +export type BundleResult = + | { status: 'success' } + | { status: 'unavailable' } + | { status: 'failed'; error: unknown } + | { status: 'aborted' }; + +// Successes persist for the session. Other settled outcomes persist until the +// group closes or the user retries, so later cards do not retry passively. +const settledBundles = new Map<string, BundleResult>(); +const bundleRetryListeners = new Map<string, Set<() => void>>(); + +/** Subscribe a chart to user-initiated retries of its group bundle. */ +export function subscribeGroupBundleRetry(groupSlug: string, cb: () => void): () => void { + let listeners = bundleRetryListeners.get(groupSlug); + if (!listeners) { + listeners = new Set(); + bundleRetryListeners.set(groupSlug, listeners); + } + listeners.add(cb); + return () => { + listeners.delete(cb); + if (listeners.size === 0) { + bundleRetryListeners.delete(groupSlug); + } + }; +} + +/** Retry one failed bundle. Waiting cards join the first subscriber's request. */ +export function retryGroupBundle(groupSlug: string): boolean { + if (settledBundles.get(groupSlug)?.status !== 'failed') { + return false; + } + settledBundles.delete(groupSlug); + for (const cb of bundleRetryListeners.get(groupSlug) ?? []) { + cb(); + } + return true; +} + +/** Clear the cache and in-flight bundle map. TEST-ONLY: production never evicts + * within a tab session. */ +export function resetPayloadCache(): void { + payloadCache.clear(); + inFlightBundles.clear(); + settledBundles.clear(); +} + +/** A group's in-flight bundle fetch: the queue entry (for priority bumps), its + * aborter (for group-close cancellation), and the settle promise callers join. */ +interface BundleInFlight { + entry: QueueEntry; + controller: AbortController; + promise: Promise<BundleResult>; +} + +const inFlightBundles = new Map<string, BundleInFlight>(); + +/** + * Fetch one group's default last-100 bundle (`/api/group/{slug}?n=100`) and + * prime [`payloadCache`] for every chart in it. Concurrent callers for the same + * group share one in-flight fetch (priority is bumped to the highest caller's). + * A 404 permits per-chart fallback. Other failures remain shared until an + * explicit retry or group reopen. Close cancellation stays silent. + */ +export function ensureGroupBundle(groupSlug: string, priority: number): Promise<BundleResult> { + const settled = settledBundles.get(groupSlug); + if (settled) { + return Promise.resolve(settled); + } + const existing = inFlightBundles.get(groupSlug); + if (existing) { + if (priority > existing.entry.priority) { + existing.entry.priority = priority; + bundleQueue.drain(); + } + return existing.promise; + } + const url = `/api/group/${encodeURIComponent(groupSlug)}?n=100`; + const controller = new AbortController(); + const entry = bundleQueue.schedule(async () => { + controller.signal.throwIfAborted(); + // The timeout starts when the task actually runs (not while queued), so it + // bounds the fetch, not the queue wait. A `TimeoutError` reason lets the + // catch tell a timeout apart from a close/destroy `AbortError`. + const timer = setTimeout( + () => controller.abort(new DOMException('Fetch timed out', 'TimeoutError')), + FETCH_TIMEOUT_MS, + ); + try { + const r = await fetch(url, { + headers: { accept: 'application/json' }, + signal: controller.signal, + }); + if (r.status === 404) { + return null; + } + if (!r.ok) { + throw new Error(`HTTP ${r.status}`); + } + const body = (await r.json()) as GroupChartsResponse | null; + if (body === null) { + throw new Error('invalid group bundle'); + } + return body; + } finally { + clearTimeout(timer); + } + }, priority); + const promise: Promise<BundleResult> = entry.promise + .then((body): BundleResult => { + if (inFlightBundles.get(groupSlug)?.entry !== entry) { + return { status: 'aborted' }; + } + controller.signal.throwIfAborted(); + if (body !== null) { + const bundle = body as GroupChartsResponse; + for (const chart of bundle.charts) { + // `NamedChartResponse` is `ChartResponse & { name, slug }`; the extra + // keys are harmless in the cached payload. + primePayload(chart.slug, chart); + noteGroupSeries(groupSlug, chart.series_meta); + // The bundle IS the group's latest-100 window, so classify every + // chart's empty-window state here — including cards that never + // intersect the viewport (their islands never seed a payload). + noteChartRecentData(groupSlug, chart.slug, chartHasRecentData(chart)); + } + return { status: 'success' }; + } + return { status: 'unavailable' }; + }) + .catch((err: unknown): BundleResult => { + if ( + inFlightBundles.get(groupSlug)?.entry !== entry || + (controller.signal.aborted && controller.signal.reason?.name === 'AbortError') + ) { + return { status: 'aborted' }; + } + return { + status: 'failed', + error: controller.signal.aborted ? controller.signal.reason : err, + }; + }) + .then((result): BundleResult => { + if (inFlightBundles.get(groupSlug)?.entry !== entry) { + return { status: 'aborted' }; + } + inFlightBundles.delete(groupSlug); + settledBundles.set(groupSlug, result); + return result; + }); + inFlightBundles.set(groupSlug, { entry, controller, promise }); + return promise; +} + +/** Abort a group's in-flight bundle fetch (on group close) and reset its + * per-cycle state so a reopen re-issues. Idempotent. A successfully completed + * bundle's cache stays valid, so a reopen of a fully cached group issues nothing. */ +export function abortGroupBundle(groupSlug: string): void { + const inFlight = inFlightBundles.get(groupSlug); + if (inFlight) { + inFlight.controller.abort(new DOMException('group closed', 'AbortError')); + inFlightBundles.delete(groupSlug); + } + if (settledBundles.get(groupSlug)?.status !== 'success') { + settledBundles.delete(groupSlug); + } +} diff --git a/bench/web/lib/chart-store/global-filter.ts b/bench/web/lib/chart-store/global-filter.ts new file mode 100644 index 0000000..f479d50 --- /dev/null +++ b/bench/web/lib/chart-store/global-filter.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { + seedActiveFormats, + seedActiveFromAllowlist, + type FilterUniverse, + type GlobalFilterState, +} from '@/lib/chart-format'; + +// --------------------------------------------------------------------------- +// Global filter store. +// --------------------------------------------------------------------------- + +/** The global filter's full state: the chip universe plus the active sets. */ +export interface GlobalFilterSnapshot { + universe: FilterUniverse; + active: GlobalFilterState; +} + +const EMPTY_GLOBAL: GlobalFilterSnapshot = { + universe: { engines: [], formats: [] }, + active: { engines: [], formats: [] }, +}; + +let globalSnapshot: GlobalFilterSnapshot = EMPTY_GLOBAL; +const globalListeners = new Set<() => void>(); + +function notifyGlobal(): void { + for (const cb of globalListeners) { + cb(); + } +} + +/** Subscribe to global-filter changes; returns the unsubscribe function. */ +export function subscribeGlobalFilter(cb: () => void): () => void { + globalListeners.add(cb); + return () => { + globalListeners.delete(cb); + }; +} + +/** Current global-filter snapshot (stable reference until a mutation). */ +export function getGlobalFilterSnapshot(): GlobalFilterSnapshot { + return globalSnapshot; +} + +/** + * Seed the store from server-provided props: the chip universe plus the URL + * `?engine=`/`?format=` allowlists (null uses the defaults; empty hides all). + * Called by the filter bar on mount and again on soft navigation, so the store + * tracks the URL state of the page that most recently mounted it. + */ +export function initGlobalFilter( + universe: FilterUniverse, + engineAllowlist: readonly string[] | null, + formatAllowlist: readonly string[] | null, +): void { + globalSnapshot = { + universe: { engines: [...universe.engines], formats: [...universe.formats] }, + active: { + engines: seedActiveFromAllowlist(engineAllowlist, universe.engines), + formats: seedActiveFormats(formatAllowlist, universe.formats), + }, + }; + notifyGlobal(); +} + +/** + * Toggle one chip independently. The `'*'` value is the one-shot reset chip: + * it forces every chip in that dimension back to active. Specific chips flip + * their own membership in the active set. + */ +export function toggleGlobalFilterValue(dim: 'engine' | 'format', value: string): void { + const key = dim === 'engine' ? 'engines' : 'formats'; + const active = { ...globalSnapshot.active }; + if (value === '*') { + active[key] = [...globalSnapshot.universe[key]]; + } else { + const list = [...active[key]]; + const idx = list.indexOf(value); + if (idx === -1) { + list.push(value); + } else { + list.splice(idx, 1); + } + active[key] = list; + } + globalSnapshot = { ...globalSnapshot, active }; + notifyGlobal(); +} diff --git a/bench/web/lib/chart-store/group-store.ts b/bench/web/lib/chart-store/group-store.ts new file mode 100644 index 0000000..f71535c --- /dev/null +++ b/bench/web/lib/chart-store/group-store.ts @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { seriesPassesFilter, seriesPassesGroupFilter } from '@/lib/chart-format'; +import type { SeriesTag } from '@/lib/queries'; + +import { getGlobalFilterSnapshot, type GlobalFilterSnapshot } from './global-filter'; + +// --------------------------------------------------------------------------- +// Per-group stores (ported from chart-init.js section 17's section-node state). +// --------------------------------------------------------------------------- + +/** One group's override state plus the series labels its charts have surfaced. */ +export interface GroupSnapshot { + /** Dataset labels the user has toggled off via the group's filter dropdown. */ + hiddenSeries: string[]; + /** Dataset labels explicitly restored over a global engine/format filter. */ + shownSeries: string[]; + /** The group-level Y override; `null` means "no override; defer to charts". */ + groupY: 'linear' | 'log' | null; + /** Series labels (with engine/format tags) surfaced by hydrated charts. */ + knownSeries: Record<string, SeriesTag>; + /** Chart slugs whose latest-100 window carries no data point at all (see + * [`chartHasRecentData`]); these cards hide by default. Data-derived, like + * `knownSeries`, so a group Reset leaves it intact. */ + emptyCharts: string[]; + /** The group toolbar's "show empty charts" toggle; `false` (the default and + * the post-Reset state) hides every `emptyCharts` card. */ + showEmptyCharts: boolean; +} + +const EMPTY_GROUP: GroupSnapshot = { + hiddenSeries: [], + shownSeries: [], + groupY: null, + knownSeries: {}, + emptyCharts: [], + showEmptyCharts: false, +}; + +interface GroupStore { + snapshot: GroupSnapshot; + listeners: Set<() => void>; +} + +const groupStores = new Map<string, GroupStore>(); + +function groupStore(slug: string): GroupStore { + let store = groupStores.get(slug); + if (store === undefined) { + store = { snapshot: EMPTY_GROUP, listeners: new Set() }; + groupStores.set(slug, store); + } + return store; +} + +function setGroupSnapshot(slug: string, next: GroupSnapshot): void { + const store = groupStore(slug); + store.snapshot = next; + for (const cb of store.listeners) { + cb(); + } +} + +/** Subscribe to one group's changes; returns the unsubscribe function. */ +export function subscribeGroup(slug: string, cb: () => void): () => void { + const store = groupStore(slug); + store.listeners.add(cb); + return () => { + store.listeners.delete(cb); + }; +} + +/** Current snapshot for one group (stable reference until a mutation). */ +export function getGroupSnapshot(slug: string): GroupSnapshot { + return groupStore(slug).snapshot; +} + +/** Seed one group's local series overrides from the current page URL. */ +export function initGroupFilter( + slug: string, + hiddenSeries: readonly string[], + shownSeries: readonly string[] = [], +): void { + const prev = groupStore(slug).snapshot; + const nextShown = [...new Set(shownSeries)]; + const nextHidden = [...new Set(hiddenSeries)].filter((label) => !nextShown.includes(label)); + if ( + prev.hiddenSeries.length === nextHidden.length && + prev.hiddenSeries.every((label, index) => label === nextHidden[index]) && + prev.shownSeries.length === nextShown.length && + prev.shownSeries.every((label, index) => label === nextShown[index]) + ) { + return; + } + setGroupSnapshot(slug, { ...prev, hiddenSeries: nextHidden, shownSeries: nextShown }); +} + +/** The shared empty snapshot, for islands that have no enclosing group. */ +export function emptyGroupSnapshot(): GroupSnapshot { + return EMPTY_GROUP; +} + +/** Effective visibility after the group override falls back to global state. */ +export function groupSeriesIsVisible( + snapshot: GroupSnapshot, + label: string, + global: GlobalFilterSnapshot = getGlobalFilterSnapshot(), +): boolean { + const defaultVisible = seriesPassesFilter( + snapshot.knownSeries[label], + global.active, + global.universe, + ); + return seriesPassesGroupFilter(snapshot, label, defaultVisible); +} + +function setSeriesVisibility( + snapshot: GroupSnapshot, + labels: readonly string[], + visible: boolean, +): Pick<GroupSnapshot, 'hiddenSeries' | 'shownSeries'> { + const selected = new Set(labels); + let hiddenSeries = snapshot.hiddenSeries.filter((label) => !selected.has(label)); + let shownSeries = snapshot.shownSeries.filter((label) => !selected.has(label)); + if (visible) { + shownSeries = [ + ...shownSeries, + ...labels.filter((label) => { + const defaultVisible = seriesPassesFilter( + snapshot.knownSeries[label], + getGlobalFilterSnapshot().active, + getGlobalFilterSnapshot().universe, + ); + return !defaultVisible && !shownSeries.includes(label); + }), + ]; + } else { + hiddenSeries = [...hiddenSeries, ...labels.filter((label) => !hiddenSeries.includes(label))]; + } + return { hiddenSeries, shownSeries }; +} + +/** Toggle one series against its effective global-plus-local visibility. */ +export function toggleGroupSeries(slug: string, label: string): void { + const prev = groupStore(slug).snapshot; + const overrides = setSeriesVisibility(prev, [label], !groupSeriesIsVisible(prev, label)); + setGroupSnapshot(slug, { ...prev, ...overrides }); +} + +/** + * Apply an engine/format macro click: find every known series whose tag + * matches. If every match is currently visible, hide them all; otherwise (any + * match already hidden) show them all, so the macro chip toggles between "all + * matching visible" and "all matching hidden". + */ +export function applyGroupMacro(slug: string, dim: 'engine' | 'format', value: string): void { + const prev = groupStore(slug).snapshot; + const matching = Object.keys(prev.knownSeries).filter( + (label) => prev.knownSeries[label]?.[dim] === value, + ); + if (matching.length === 0) { + return; + } + const allVisible = matching.every((label) => groupSeriesIsVisible(prev, label)); + const overrides = setSeriesVisibility(prev, matching, !allVisible); + setGroupSnapshot(slug, { ...prev, ...overrides }); +} + +/** Clear the group's series filter (the `'*'` reset chip). */ +export function clearGroupSeriesFilter(slug: string): void { + const prev = groupStore(slug).snapshot; + setGroupSnapshot(slug, { ...prev, hiddenSeries: [], shownSeries: [] }); +} + +/** Set the group-level Y override broadcast to non-sticky charts. */ +export function setGroupY(slug: string, y: 'linear' | 'log'): void { + const prev = groupStore(slug).snapshot; + setGroupSnapshot(slug, { ...prev, groupY: y }); +} + +/** Reset the group: empty series filter, no Y override, and empty charts back + * to hidden (the default). Per-card legend overrides and per-card sticky Y + * choices are intentionally NOT cleared, matching the v3 reset semantics. */ +export function resetGroup(slug: string): void { + const prev = groupStore(slug).snapshot; + setGroupSnapshot(slug, { + ...prev, + hiddenSeries: [], + shownSeries: [], + groupY: null, + showEmptyCharts: false, + }); +} + +/** + * Record whether one chart's latest-100 window carries any data (idempotent). + * Called with a [`chartHasRecentData`] verdict wherever a default-window payload + * lands — the group-bundle prime here in the store, plus the per-chart fetch + * paths in the chart island — so `emptyCharts` fills in as the group hydrates + * and the affected cards hide without a layout pass per notification. + */ +export function noteChartRecentData(slug: string, chartSlug: string, hasData: boolean): void { + const prev = groupStore(slug).snapshot; + const isEmpty = prev.emptyCharts.includes(chartSlug); + if (isEmpty === !hasData) { + return; + } + const emptyCharts = hasData + ? prev.emptyCharts.filter((s) => s !== chartSlug) + : [...prev.emptyCharts, chartSlug]; + setGroupSnapshot(slug, { ...prev, emptyCharts }); +} + +/** Set the group toolbar's "show empty charts" toggle. */ +export function setShowEmptyCharts(slug: string, show: boolean): void { + const prev = groupStore(slug).snapshot; + if (prev.showEmptyCharts === show) { + return; + } + setGroupSnapshot(slug, { ...prev, showEmptyCharts: show }); +} + +/** Whether a group snapshot currently hides `chartSlug` as an empty card: + * classified empty in the latest-100 window and not revealed by the toggle. */ +export function chartIsHiddenAsEmpty(snapshot: GroupSnapshot, chartSlug: string): boolean { + return !snapshot.showEmptyCharts && snapshot.emptyCharts.includes(chartSlug); +} + +/** + * Fold a hydrated chart's `series_meta` labels into the group's running set + * (idempotent), so the group toolbar's series chip row grows as charts in the + * group hydrate. No-op when every label is already known. + */ +export function noteGroupSeries(slug: string, meta: Record<string, SeriesTag> | undefined): void { + if (!meta) { + return; + } + const prev = groupStore(slug).snapshot; + let added = false; + const knownSeries = { ...prev.knownSeries }; + for (const [label, tag] of Object.entries(meta)) { + if (!(label in knownSeries)) { + knownSeries[label] = tag ?? {}; + added = true; + } + } + if (added) { + setGroupSnapshot(slug, { ...prev, knownSeries }); + } +} diff --git a/bench/web/lib/chart-store/index.ts b/bench/web/lib/chart-store/index.ts new file mode 100644 index 0000000..6c0c219 --- /dev/null +++ b/bench/web/lib/chart-store/index.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +/** + * Client-side singletons shared by the chart islands, the stateful counterpart + * to the pure helpers in `lib/chart-format.ts`: the bounded-concurrency fetch + * queues, the global engine/format filter store, and the per-group filter/Y + * stores. One instance of each lives per browser tab (module scope), mirroring + * the page-level closures of `server/static/chart-init.js`. + * + * The stores expose a `subscribe`/`getSnapshot` pair compatible with React's + * `useSyncExternalStore`, so the islands (every `Chart`, the global + * `FilterBar`, each `GroupToolbar`) re-render and re-apply filters when any of + * them mutates shared state. Snapshots are replaced wholesale on mutation; + * a stable reference means "nothing changed". + * + * This module holds browser-tab state and is only imported from `'use client'` + * components; it has no DOM dependency itself, so node-environment vitest can + * exercise the queues and stores directly. + */ + +export * from './queues'; +export * from './bundles'; +export * from './global-filter'; +export * from './group-store'; diff --git a/bench/web/lib/chart-store/queues.ts b/bench/web/lib/chart-store/queues.ts new file mode 100644 index 0000000..381a031 --- /dev/null +++ b/bench/web/lib/chart-store/queues.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { + BUNDLE_CONCURRENCY, + FULL_HISTORY_CONCURRENCY, + HYDRATION_CONCURRENCY, +} from '@/lib/chart-format'; + +// --------------------------------------------------------------------------- +// Bounded priority queues (ported from chart-init.js sections 11 and 15). +// --------------------------------------------------------------------------- + +/** A queued fetch task; `priority` may be bumped while still queued. */ +export interface QueueEntry { + priority: number; + /** Resolves/rejects with the task's outcome once the queue runs it. */ + promise: Promise<unknown>; +} + +interface InternalEntry extends QueueEntry { + task: () => Promise<unknown>; + resolve: (value: unknown) => void; + reject: (err: unknown) => void; +} + +/** A bounded-concurrency, priority-ordered task queue. */ +export interface TaskQueue { + /** Enqueue `task` and start draining; higher `priority` runs first. */ + schedule(task: () => Promise<unknown>, priority?: number): QueueEntry; + /** Re-sort and start any idle slots (called after a priority bump). */ + drain(): void; +} + +function makeQueue(concurrency: number): TaskQueue { + let active = 0; + const queue: InternalEntry[] = []; + + function drain(): void { + while (active < concurrency && queue.length > 0) { + queue.sort((a, b) => b.priority - a.priority); + const item = queue.shift(); + if (item === undefined) { + return; + } + active += 1; + Promise.resolve() + .then(item.task) + .then( + (value) => { + active -= 1; + item.resolve(value); + drain(); + }, + (err: unknown) => { + active -= 1; + item.reject(err); + drain(); + }, + ); + } + } + + function schedule(task: () => Promise<unknown>, priority = 0): QueueEntry { + let resolve!: (value: unknown) => void; + let reject!: (err: unknown) => void; + const promise = new Promise<unknown>((res, rej) => { + resolve = res; + reject = rej; + }); + const entry: InternalEntry = { task, priority, promise, resolve, reject }; + queue.push(entry); + drain(); + return entry; + } + + return { schedule, drain }; +} + +/** Per-tab queue for the initial latest-100 chart fetches. */ +export const hydrationQueue: TaskQueue = makeQueue(HYDRATION_CONCURRENCY); + +/** Per-tab queue for the background `?n=all` full-history upgrades. */ +export const fullHistoryQueue: TaskQueue = makeQueue(FULL_HISTORY_CONCURRENCY); + +/** Per-tab queue for the per-group `/api/group/{slug}?n=100` bundle fetches. */ +export const bundleQueue: TaskQueue = makeQueue(BUNDLE_CONCURRENCY); From b4be6c6debed5e603b475d252fcbaedad825a221 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:23:18 +0100 Subject: [PATCH 26/34] Split bench's chart island into modules Move the ChartController class and its helpers out of components/Chart.tsx into components/chart/: types, datasets, tooltip, config, card-dom, pan-zoom and controller. Chart.tsx keeps the 'use client' boundary, the architecture notes, ChartIslandProps and the Chart component. The code moves verbatim. The moved private methods (the slider, badge and window-chip renderers, wheel pan and the range strip) take the state, elements and abort signal as parameters, and the controller keeps a delegator with the same name for each. maybeConstruct gets the Chart.js config from lineChartConfig(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/web/components/Chart.tsx | 1706 +--------------------- bench/web/components/chart/card-dom.ts | 127 ++ bench/web/components/chart/config.ts | 199 +++ bench/web/components/chart/controller.ts | 998 +++++++++++++ bench/web/components/chart/datasets.ts | 67 + bench/web/components/chart/pan-zoom.ts | 223 +++ bench/web/components/chart/tooltip.ts | 144 ++ bench/web/components/chart/types.ts | 79 + 8 files changed, 1839 insertions(+), 1704 deletions(-) create mode 100644 bench/web/components/chart/card-dom.ts create mode 100644 bench/web/components/chart/config.ts create mode 100644 bench/web/components/chart/controller.ts create mode 100644 bench/web/components/chart/datasets.ts create mode 100644 bench/web/components/chart/pan-zoom.ts create mode 100644 bench/web/components/chart/tooltip.ts create mode 100644 bench/web/components/chart/types.ts diff --git a/bench/web/components/Chart.tsx b/bench/web/components/Chart.tsx index 66965e3..9ca2055 100644 --- a/bench/web/components/Chart.tsx +++ b/bench/web/components/Chart.tsx @@ -4,73 +4,18 @@ 'use client'; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; -import type { - ActiveElement, - Chart as ChartJs, - ChartDataset, - ChartEvent, - LegendItem, - Plugin, - TooltipModel, -} from 'chart.js'; -import { - CHART_FETCH_N, - chartHasRecentData, - clampRangeWindow, - collectAllValues, - commitDateLabel, - decimateSeries, - DEFAULT_VISIBLE, - escapeHtml, - FETCH_N, - FETCH_TIMEOUT_MS, - firstLine, - formatDisplayValue, - HOVER_DWELL_MS, - HOVER_PREFETCH_PRIORITY, - IDENTITY_UNIT, - INTERACTION_FULL_PRIORITY, - LAZY_HYDRATION_ROOT_MARGIN, - MAX_VISIBLE_POINTS, - normalizeChartPayload, - PAN_THROTTLE_MS, - parsePrNumber, - pickDisplayUnit, - predecessorValue, - rangeTouchesUnloadedHistory, - displaySeriesLabel, - seriesOrder, - seriesPassesFilter, - seriesPassesGroupFilter, - seriesStyle, - shortDate, - shortSha, - throttle, - truncate, - visibleRange, - ZOOM_THROTTLE_MS, - type DisplayUnit, - type NormalizedChartPayload, -} from '@/lib/chart-format'; -import { loadChartJs } from '@/lib/chart-js'; +import { ChartController } from '@/components/chart/controller'; +import { LAZY_HYDRATION_ROOT_MARGIN, throttle, ZOOM_THROTTLE_MS } from '@/lib/chart-format'; import { abortGroupBundle, chartIsHiddenAsEmpty, emptyGroupSnapshot, ensureGroupBundle, - fullHistoryQueue, - getCachedPayload, getGlobalFilterSnapshot, getGroupSnapshot, - hydrationQueue, - noteChartRecentData, - noteGroupSeries, - retryGroupBundle, subscribeGlobalFilter, subscribeGroup, - subscribeGroupBundleRetry, - type QueueEntry, } from '@/lib/chart-store'; import type { ChartResponse } from '@/lib/queries'; @@ -105,1653 +50,6 @@ import type { ChartResponse } from '@/lib/queries'; * shared React tree). */ -/** A Chart.js line dataset extended with the bench-specific carry-alongs. */ -interface BenchDataset extends ChartDataset<'line', (number | null)[]> { - /** The unmodified payload values; the tooltip reads these regardless of - * which indices LTTB kept in `data`. */ - rawData: (number | null)[]; - /** Engine/format tag used by the global filter's bulk hide/show. */ - benchMeta: { engine?: string; format?: string }; -} - -/** The per-island mutable state, the port of the v3 `canvas.__bench_*` contract. */ -interface CardState { - chart: ChartJs | null; - constructing: boolean; - payload: NormalizedChartPayload | null; - ui: { y: 'linear' | 'log'; scope: number | 'all' }; - /** Series labels the user has explicitly legend-toggled on this card. Once - * set, the global/group filters no longer drive that label here. */ - overrides: Record<string, true>; - displayUnit: DisplayUnit; - /** v3 also tracked `__bench_inline_trimmed`; it was write-only there and is - * dropped here. `payload.history.complete` carries the same information. */ - fullLoaded: boolean; - initialFetchEntry: QueueEntry | null; - fullFetchEntry: QueueEntry | null; - fullFetchPending: Promise<void> | null; - /** The in-flight `?n=100` fetch's per-fetch aborter; lets a group close or - * destroy cancel it without aborting the controller-lifetime `aborter`. */ - initialFetchController: AbortController | null; - /** The in-flight `?n=all` fetch's per-fetch aborter; same role as above. */ - fullFetchController: AbortController | null; - /** True once this card has rendered a bounded window (so the chip is shown); - * a chart born complete never sets it and shows no chip. */ - everWindowed: boolean; - /** The most recent full-history fetch failed; the chip offers a retry. */ - chipError: boolean; - /** The pointer is currently resting on this card (chip shows the action). */ - hovering: boolean; - /** Pending hover-dwell prefetch timer; cleared on `pointerleave`/destroy. */ - hoverDwellTimer: ReturnType<typeof setTimeout> | null; - /** A full-history fetch returned 404: there is nothing beyond the window to - * load, so the chip stops offering the action and hovers stop re-fetching. */ - fullUnavailable: boolean; - yUserSet: boolean; - stripRender: (() => void) | null; - rebuild: ((chart: ChartJs) => void) | null; - wheelAttached: boolean; - disposed: boolean; -} - -/** The DOM handles the controller operates on, resolved from refs at call time. */ -interface CardElements { - card: HTMLElement | null; - canvas: HTMLCanvasElement | null; - tooltipHost: HTMLDivElement | null; - slider: HTMLInputElement | null; - badge: HTMLSpanElement | null; - chip: HTMLButtonElement | null; - strip: HTMLDivElement | null; - stripWindow: HTMLDivElement | null; -} - -/** React-state setters the controller drives (low-frequency UI bits only). */ -interface CardCallbacks { - setY: (y: 'linear' | 'log') => void; - setLoading: (on: boolean) => void; - setError: (msg: string | null) => void; - /** Show/hide the initial-fetch retry control in the error region. */ - setRetryable: (on: boolean) => void; - /** Flip once the Chart.js instance exists, so the pre-data placeholder hides. */ - setConstructed: (on: boolean) => void; -} - -/** Subtle, theme-neutral grid line color (slate-gray at low opacity), matching - * the v2 frontend's faint gridlines a reviewer preferred over the heavier - * Chart.js default. */ -const GRID_COLOR = 'rgba(148, 163, 184, 0.14)'; - -// --------------------------------------------------------------------------- -// Crosshair plugin: draws a vertical line at the chart's active hover index. -// An inline plugin is cheaper than chartjs-plugin-crosshair, which is overkill -// for this one feature. -// --------------------------------------------------------------------------- -const crosshairPlugin: Plugin<'line'> = { - id: 'benchCrosshair', - afterDatasetsDraw(chart) { - const active = chart.tooltip?.getActiveElements ? chart.tooltip.getActiveElements() : []; - if (!active || active.length === 0) { - return; - } - const x = active[0].element.x; - const ya = chart.scales?.y; - if (!ya || !Number.isFinite(x)) { - return; - } - const ctx = chart.ctx; - ctx.save(); - // `--muted` from the page theme, read lazily so dark mode picks up the - // right color. - const muted = - getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#9ca3af'; - ctx.strokeStyle = muted; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(x, ya.top); - ctx.lineTo(x, ya.bottom); - ctx.stroke(); - ctx.restore(); - }, -}; - -/** Read a chart's datasets with the bench carry-alongs visible to the types. */ -function benchDatasets(chart: ChartJs): BenchDataset[] { - return chart.data.datasets as BenchDataset[]; -} - -/** - * Build the per-series dataset shells. `data` starts as a full-length - * null-padded array; `rebuildVisibleAndUpdate` fills it in based on the current - * visible range. `rawData` holds a reference to the original payload so the - * tooltip can show raw values regardless of LTTB. - */ -function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { - const raw = payload.series ?? {}; - const meta = payload.series_meta ?? {}; - const n = payload.commits.length; - const global = getGlobalFilterSnapshot(); - return Object.keys(raw) - .sort() - .map((name) => { - const seriesMeta = meta[name] ?? {}; - const rawValues = Array.isArray(raw[name]) ? raw[name] : []; - // Tier-based style: the named Vortex/Parquet series get vivid, thicker - // lines; everything else is muted and thin (see `seriesStyle`). - const style = seriesStyle(name, seriesMeta); - const coldRandomAccess = name.endsWith(':cold'); - // `data` starts null-padded; `rebuildVisibleAndUpdate` fills the current - // visible window with raw or LTTB-kept values. With `spanGaps: true` the - // line connects across nulls, so a series with partial coverage still - // draws as a continuous trend; markers only appear at non-null indices. - const data = new Array<number | null>(n).fill(null); - return { - label: name, - data, - rawData: rawValues, - borderColor: style.color, - backgroundColor: `${style.color}20`, - borderWidth: style.width, - borderDash: coldRandomAccess ? [6, 4] : undefined, - // Layer the hero series on top: Vortex in front ... arrow at the back, - // datafusion ahead of duckdb. Lower `order` renders on top in Chart.js. - order: seriesOrder(name, seriesMeta), - spanGaps: true, - tension: 0, - pointRadius: 2, - pointHoverRadius: 5, - pointHitRadius: 8, - pointStyle: 'cross', - benchMeta: { engine: seriesMeta.engine, format: seriesMeta.format }, - hidden: !seriesPassesFilter(seriesMeta, global.active, global.universe), - } satisfies BenchDataset; - }); -} - -/** - * The external tooltip handler factory, ported verbatim from v3 including the - * flicker fix: the tooltip host is ALWAYS `pointer-events: none` (via CSS); the - * previous v2 implementation flipped it to `auto` when visible and the cursor - * would oscillate between canvas and tooltip at event-loop frequency. Clicks on - * a data point are handled by the chart's `onClick`, so the tooltip itself - * never needs to be interactive. - */ -function externalTooltipHandler(state: CardState, host: HTMLDivElement | null) { - return (context: { chart: ChartJs; tooltip: TooltipModel<'line'> }) => { - const tt = context.tooltip; - if (!host) { - return; - } - if (tt.opacity === 0) { - host.style.opacity = '0'; - return; - } - - const chart = context.chart; - const firstDp = tt.dataPoints?.[0]; - if (!firstDp) { - host.style.opacity = '0'; - return; - } - // Snap to a single commit: `mode: "nearest"` means `firstDp.dataIndex` is - // the single closest data point to the cursor (skipping nulls). If the - // cursor falls between two LTTB-kept points, exactly one wins. - const idx = firstDp.dataIndex; - const commit = state.payload?.commits?.[idx] ?? null; - const displayUnit = state.displayUnit ?? IDENTITY_UNIT; - - // Build one row per dataset from each series' `rawData` so the tooltip - // shows raw measurements even when LTTB nulled out `dataset.data[idx]`. - // Iterating `chart.data.datasets` directly (instead of `tt.dataPoints`) - // guarantees one row per series at this single commit. - const rowItems = benchDatasets(chart) - .map((ds, dsIndex) => { - const meta = chart.getDatasetMeta(dsIndex); - if (meta?.hidden || ds.hidden) { - return null; - } - const raw = ds.rawData?.[idx]; - if (raw === null || raw === undefined || Number.isNaN(raw)) { - return null; - } - // Per-row delta is `(current - previous) / previous`, where "previous" - // is the chronologically preceding commit per the BAN-pinned `idx - 1` - // oldest-first walk in [`predecessorValue`]. - const prevRaw = predecessorValue(ds.rawData ?? [], idx); - let deltaHtml = ''; - if (prevRaw !== null && prevRaw !== 0) { - const pct = ((raw - prevRaw) / prevRaw) * 100; - const cls = - pct > 0 - ? 'tt-delta tt-delta--worse' - : pct < 0 - ? 'tt-delta tt-delta--better' - : 'tt-delta'; - const sign = pct > 0 ? '+' : ''; - deltaHtml = `<span class="${cls}">${sign}${pct.toFixed(1)}%</span>`; - } - return { label: ds.label ?? '', color: String(ds.borderColor), raw, deltaHtml }; - }) - .filter((r): r is NonNullable<typeof r> => r !== null); - - // Top-to-bottom order matches the visual stack of lines at this x. - rowItems.sort((a, b) => b.raw - a.raw); - - const rows = rowItems - .map( - (r) => - `<div class="tt-row">` + - `<span class="tt-swatch" style="background:${r.color}"></span>` + - `<span class="tt-label">${escapeHtml(displaySeriesLabel(r.label))}</span>` + - `<span class="tt-value">${escapeHtml(formatDisplayValue(r.raw, displayUnit))}</span>` + - r.deltaHtml + - `</div>`, - ) - .join(''); - - // If every series was hidden or had no value at this commit, treat this as - // a no-op hover instead of flashing an empty popup. - if (!rows) { - host.style.opacity = '0'; - return; - } - - const titleHtml = - `<div class="tt-title">` + - `${escapeHtml(shortSha(commit?.sha))} · ${escapeHtml(shortDate(commit?.timestamp))}` + - `</div>`; - - // Short SHA + first-line commit message, truncated. The full URL (or PR - // URL) is wired up via the chart's onClick handler. - const msg = truncate(firstLine(commit?.message ?? ''), 80); - const footerLine = commit?.sha - ? msg - ? `${escapeHtml(shortSha(commit.sha))} · ${escapeHtml(msg)}` - : escapeHtml(shortSha(commit.sha)) - : escapeHtml(msg); - const footerHtml = footerLine - ? `<div class="tt-footer"><div class="tt-msg">${footerLine}</div></div>` - : ''; - - host.innerHTML = `${titleHtml}<div class="tt-rows">${rows}</div>${footerHtml}`; - - // Position the tooltip relative to its container, offset 12px from the - // cursor; flip horizontally if it would overflow. - const canvasRect = context.chart.canvas.getBoundingClientRect(); - const parent = host.parentNode as HTMLElement; - const hostRect = parent.getBoundingClientRect(); - const x = canvasRect.left - hostRect.left + tt.caretX; - const y = canvasRect.top - hostRect.top + tt.caretY; - host.style.opacity = '1'; - host.style.left = `${x}px`; - host.style.top = `${y}px`; - // Measure after the content swap so flipping is correct. - const ttWidth = host.offsetWidth || 0; - const containerWidth = parent.clientWidth || 0; - const flip = x + ttWidth + 24 > containerWidth; - host.style.transform = flip ? 'translate(calc(-100% - 12px), 12px)' : 'translate(12px, 12px)'; - }; -} - -/** - * The imperative per-island engine. Created once per MOUNT (not per component - * instance): React StrictMode runs every dev effect as mount, cleanup, remount, - * and `destroy()` is one-way, so the mount effect constructs a fresh controller - * each time it runs and tears the previous one down in its cleanup. Every - * method is a direct port of the corresponding `chart-init.js` function with - * the canvas field-stash replaced by [`CardState`]; the DOM listeners the - * controller attaches (wheel pan, range-strip pointers) are registered with its - * own abort signal so a teardown removes them from the still-mounted nodes - * before the next controller re-binds. - */ -class ChartController { - private readonly state: CardState; - /** Aborted on [`destroy`]; every controller-attached DOM listener uses it. */ - private readonly aborter = new AbortController(); - /** Failed Chart.js dynamic-import attempts; bounds the error-dismiss retry. */ - private loadAttempts = 0; - private bundleFailed = false; - private readonly unsubscribeBundleRetry?: () => void; - - constructor( - private readonly slug: string, - private readonly groupSlug: string | undefined, - private readonly els: () => CardElements, - private readonly cb: CardCallbacks, - ) { - this.state = { - chart: null, - constructing: false, - payload: null, - ui: { y: 'linear', scope: DEFAULT_VISIBLE }, - overrides: {}, - displayUnit: IDENTITY_UNIT, - fullLoaded: false, - initialFetchEntry: null, - fullFetchEntry: null, - fullFetchPending: null, - initialFetchController: null, - fullFetchController: null, - everWindowed: false, - chipError: false, - hovering: false, - hoverDwellTimer: null, - fullUnavailable: false, - yUserSet: false, - stripRender: null, - rebuild: null, - wheelAttached: false, - disposed: false, - }; - if (groupSlug) { - this.unsubscribeBundleRetry = subscribeGroupBundleRetry(groupSlug, () => { - if (this.bundleFailed && this.groupIsOpen() && !this.state.disposed) { - this.bundleFailed = false; - this.cb.setError(null); - this.onGroupOpen(0); - } - }); - } - } - - /** Seed the permalink page's server-fetched payload before any fetch runs. */ - seedPayload(payload: ChartResponse): void { - const normalized = normalizeChartPayload(payload); - this.state.payload = normalized; - this.state.fullLoaded = normalized.history.complete; - if (!normalized.history.complete) { - this.state.everWindowed = true; - } - this.syncWindowChip(); - if (this.groupSlug) { - noteGroupSeries(this.groupSlug, normalized.series_meta); - } - } - - /** Whether the enclosing group disclosure (if any) is open. */ - private groupIsOpen(): boolean { - const card = this.els().card; - const group = card?.closest('.group-details'); - const details = group?.querySelector('details.group-disclosure'); - return !details || (details as HTMLDetailsElement).open; - } - - /** Whether the group store currently hides this card as an empty chart (no - * data in the latest-100 window, reveal toggle off). Read from the store, - * not the DOM: the store mutation precedes React's `display: none` commit, - * so gating on it suppresses construction without racing the render. */ - private hiddenAsEmpty(): boolean { - return ( - this.groupSlug !== undefined && - chartIsHiddenAsEmpty(getGroupSnapshot(this.groupSlug), this.slug) - ); - } - - /** - * Ensure this chart's default `?n=100` payload is loaded. Consults the session - * payload cache first (a sibling group-bundle fetch may have already cached - * it), then on the landing page drives one bundle fetch per group, falling - * back to the per-chart [`fetchInitialPayloadDirect`] only when the bundle does - * not cover this slug. The permalink page (no group slug) goes straight to the - * per-chart fetch. `showLoading` mirrors v3: the group-open path shows the - * per-card loading indicator, the pointer-intent prefetch stays silent. - */ - ensureInitialPayload(priority: number, showLoading: boolean): Promise<void> { - const state = this.state; - if (state.payload || state.disposed) { - return Promise.resolve(); - } - // Fast path: a sibling group-bundle fetch may have already cached this - // chart's default payload. Seed from it synchronously (the same steps as the - // fetch success path) so no per-chart request is issued. - const cached = getCachedPayload(this.slug); - if (cached) { - this.seedFromCachedPayload(cached); - return Promise.resolve(); - } - // On the landing page (a group slug is present), drive one bundle fetch per - // group and hydrate from it. Only fall through to the per-chart fetch when - // the bundle is unavailable (404) or this slug is missing. - if (this.groupSlug) { - if (showLoading) { - this.cb.setError(null); - this.cb.setLoading(true); - this.cb.setRetryable(false); - } - const groupSlug = this.groupSlug; - this.bundleFailed = false; - return ensureGroupBundle(groupSlug, priority).then((result) => { - // The group can close while this card awaits the in-flight bundle. The - // close runs `abortInFlightFetches` + `abortGroupBundle` already, so a - // per-chart fallback issued now would never be aborted and would defeat - // the "closing a group frees server capacity" contract. Bail when the - // group is no longer open. - if (state.disposed || state.payload || !this.groupIsOpen() || result.status === 'aborted') { - return; - } - const fromBundle = getCachedPayload(this.slug); - if (fromBundle) { - this.seedFromCachedPayload(fromBundle); - return; - } - if (result.status === 'failed') { - this.bundleFailed = true; - this.cb.setLoading(false); - this.cb.setError('failed to load group charts'); - this.cb.setRetryable(true); - return; - } - // Bundle did not cover this chart: fall back to the per-chart fetch. - return this.fetchInitialPayloadDirect(priority, showLoading); - }); - } - return this.fetchInitialPayloadDirect(priority, showLoading); - } - - /** Seed state from a cached default payload (the bundle/cache hit path), - * mirroring the per-chart fetch's success handler. Does NOT call - * `maybeConstruct`; the caller does after the returned promise resolves. */ - private seedFromCachedPayload(raw: ChartResponse): void { - const state = this.state; - // A concurrent full-history upgrade may already have constructed from the - // `?n=all` payload; the late cache seed must not clobber that back to the - // bounded window (same invariant as the per-chart success handler). - if (state.fullLoaded) { - return; - } - const normalized = normalizeChartPayload(raw); - state.payload = normalized; - state.fullLoaded = normalized.history.complete; - if (!normalized.history.complete) { - state.everWindowed = true; - } - this.syncWindowChip(); - this.cb.setLoading(false); - this.cb.setError(null); - this.cb.setRetryable(false); - if (this.groupSlug) { - noteGroupSeries(this.groupSlug, normalized.series_meta); - } - } - - /** - * Queue the initial `?n=100` fetch through the bounded hydration queue (or - * bump its priority if already queued). The per-chart fallback for the bundle - * path and the only path on the permalink page. `showLoading` mirrors v3: the - * group-open path shows the per-card loading indicator, the pointer-intent - * prefetch stays silent. - */ - private fetchInitialPayloadDirect(priority: number, showLoading: boolean): Promise<void> { - const state = this.state; - if (state.initialFetchEntry) { - if (priority > state.initialFetchEntry.priority) { - state.initialFetchEntry.priority = priority; - hydrationQueue.drain(); - } - if (showLoading) { - this.cb.setLoading(true); - this.cb.setRetryable(false); - } - return state.initialFetchEntry.promise.then( - () => undefined, - () => undefined, - ); - } - if (showLoading) { - this.cb.setLoading(true); - this.cb.setRetryable(false); - } - const url = `/api/chart/${encodeURIComponent(this.slug)}?n=${encodeURIComponent(CHART_FETCH_N)}`; - const fc = new AbortController(); - state.initialFetchController = fc; - // Bridge the controller-lifetime aborter to this per-fetch controller so - // `destroy()` cancels the in-flight request. `{ once: true }` self-removes - // the listener after a single abort; the `finally` removes it on the no-abort - // path. Propagate the parent's reason so a destroy reads as `AbortError`. - const onParentAbort = (): void => fc.abort(this.aborter.signal.reason); - this.aborter.signal.addEventListener('abort', onParentAbort, { once: true }); - if (this.aborter.signal.aborted) { - fc.abort(this.aborter.signal.reason); - } - const entry = hydrationQueue.schedule(async () => { - // The timeout starts when the task actually runs (not while queued), so it - // measures fetch duration, not queue wait. A `TimeoutError` reason lets the - // catch tell a timeout apart from a close/destroy `AbortError`. - const timer = setTimeout( - () => fc.abort(new DOMException('Fetch timed out', 'TimeoutError')), - FETCH_TIMEOUT_MS, - ); - try { - const r = await fetch(url, { headers: { accept: 'application/json' }, signal: fc.signal }); - if (!r.ok) { - throw new Error(r.status === 404 ? 'not found' : `HTTP ${r.status}`); - } - return (await r.json()) as ChartResponse; - } finally { - clearTimeout(timer); - this.aborter.signal.removeEventListener('abort', onParentAbort); - } - }, priority); - state.initialFetchEntry = entry; - return entry.promise.then( - (raw) => { - if (state.initialFetchEntry === entry) { - state.initialFetchEntry = null; - } - if (state.initialFetchController === fc) { - state.initialFetchController = null; - } - if (state.disposed) { - return; - } - // A concurrent full-history upgrade (hover dwell or chip click) may have - // already resolved and constructed from the `?n=all` payload while this - // `?n=100` window was still in flight. The late window resolution must - // not clobber that full payload back to the bounded window (which would - // diverge `payload` from the rendered datasets, regress the chip, and - // re-arm a redundant pan-triggered refetch). - if (state.fullLoaded) { - return; - } - const normalized = normalizeChartPayload(raw as ChartResponse); - state.payload = normalized; - state.fullLoaded = normalized.history.complete; - if (!normalized.history.complete) { - state.everWindowed = true; - } - this.syncWindowChip(); - this.cb.setLoading(false); - if (this.groupSlug) { - noteGroupSeries(this.groupSlug, normalized.series_meta); - // Classify the empty-window state from this `?n=100` payload (the - // per-chart analogue of the bundle-prime classification). - noteChartRecentData(this.groupSlug, this.slug, chartHasRecentData(normalized)); - } - void this.maybeConstruct(); - }, - (err: unknown) => { - if (state.initialFetchEntry === entry) { - state.initialFetchEntry = null; - } - if (state.initialFetchController === fc) { - state.initialFetchController = null; - } - if (state.disposed) { - return; - } - // A close/destroy cancellation aborts with `AbortError`: stay silent and - // do NOT touch the loading state, which may now belong to a fresh fetch - // scheduled after a reopen (clearing it here would extinguish that - // newer fetch's spinner). A timeout (`TimeoutError`) or a genuine - // network/HTTP failure clears loading and surfaces the error indicator. - if (err instanceof DOMException && err.name === 'AbortError') { - return; - } - this.cb.setLoading(false); - const message = - err instanceof DOMException && err.name === 'TimeoutError' - ? 'timed out' - : err instanceof Error - ? err.message - : 'unknown error'; - this.cb.setError(`failed to load: ${message}`); - this.cb.setRetryable(true); - }, - ); - } - - /** Re-issue the initial `?n=100` fetch after a failure/timeout. User-initiated - * (the error region's retry control), so it is naturally bounded; clears the - * error first and schedules at the top of the hydration queue. - */ - retryInitialPayload(): void { - if (this.state.disposed || this.state.payload) { - return; - } - if (this.groupSlug && this.bundleFailed && retryGroupBundle(this.groupSlug)) { - return; - } - this.cb.setError(null); - this.cb.setRetryable(false); - void this.ensureInitialPayload(0, true).then(() => { - if (this.state.disposed) { - return; - } - void this.maybeConstruct(); - }); - } - - /** - * Hydrate this chart's latest-100 window at `priority` and construct. Full - * history is NOT warmed here; it loads only on explicit per-chart intent - * (window-chip click, hover dwell, or pan/zoom into the unloaded region). The - * priority is the negated visual `index`, so top cards drain ahead of lower - * ones (the queue drains highest-priority-first). - */ - onGroupOpen(priority: number): void { - void this.ensureInitialPayload(priority, true).then(() => { - if (this.state.disposed) { - return; - } - void this.maybeConstruct(); - }); - } - - /** - * Queue the one-shot `?n=all` full-history upgrade (or promote the queued - * entry's priority). Triggered only by explicit intent — window-chip click - * (`INTERACTION_FULL_PRIORITY`), hover dwell (`HOVER_PREFETCH_PRIORITY`), or - * pan/zoom touching the unloaded region — never as an automatic warmup. - */ - ensureFullHistory(priority: number): Promise<void> { - const state = this.state; - // `fullUnavailable` is checked here (not only at the chip/hover call sites) - // so every intent path shares one terminal-404 guard, including the pan/zoom - // `rangeTouchesUnloadedHistory` promotion, which must not re-issue a fetch - // that already 404'd. - if (state.fullLoaded || state.fullUnavailable || state.disposed) { - return Promise.resolve(); - } - if (state.fullFetchEntry) { - if (priority > state.fullFetchEntry.priority) { - state.fullFetchEntry.priority = priority; - fullHistoryQueue.drain(); - } - return state.fullFetchPending ?? Promise.resolve(); - } - const url = `/api/chart/${encodeURIComponent(this.slug)}?n=${encodeURIComponent(FETCH_N)}`; - const fc = new AbortController(); - state.fullFetchController = fc; - const onParentAbort = (): void => fc.abort(this.aborter.signal.reason); - this.aborter.signal.addEventListener('abort', onParentAbort, { once: true }); - if (this.aborter.signal.aborted) { - fc.abort(this.aborter.signal.reason); - } - const entry = fullHistoryQueue.schedule(async () => { - const timer = setTimeout( - () => fc.abort(new DOMException('Fetch timed out', 'TimeoutError')), - FETCH_TIMEOUT_MS, - ); - try { - const r = await fetch(url, { headers: { accept: 'application/json' }, signal: fc.signal }); - if (r.status === 404) { - return null; - } - if (!r.ok) { - throw new Error(`HTTP ${r.status}`); - } - return (await r.json()) as ChartResponse; - } finally { - clearTimeout(timer); - this.aborter.signal.removeEventListener('abort', onParentAbort); - } - }, priority); - state.fullFetchEntry = entry; - state.fullFetchPending = entry.promise - .then((full) => { - if (state.disposed) { - return; - } - if (full === null) { - state.fullUnavailable = true; - return; - } - this.replaceChartPayload(full as ChartResponse); - state.fullLoaded = true; - if (state.fullFetchController === fc) { - state.fullFetchController = null; - } - state.chipError = false; - this.cb.setLoading(false); - if (!state.chart && this.groupIsOpen()) { - void this.maybeConstruct(); - } - }) - .catch((err: unknown) => { - if (state.fullFetchController === fc) { - state.fullFetchController = null; - } - // A close/destroy cancellation is silent; a timeout or genuine failure - // leaves the chip's retry affordance (chipError) so the user can re-try. - if (err instanceof DOMException && err.name === 'AbortError') { - return; - } - // Quiet: the latest-100 payload is still usable. Surface to the console - // for debugging; the chip exposes the retry affordance. - console.warn('bench: full history fetch failed', err); - state.chipError = true; - }) - .then(() => { - if (state.fullFetchEntry === entry) { - state.fullFetchEntry = null; - state.fullFetchController = null; - state.fullFetchPending = null; - } - // Always re-sync the chip so the UI reflects the latest state even when - // the guard above skips the stale entry clears. - this.syncWindowChip(); - }); - this.syncWindowChip(); - return state.fullFetchPending; - } - - /** - * Construct the Chart.js instance when the payload is present and the - * enclosing group (if any) is open. Loads Chart.js lazily on first need. - * Idempotent across overlapping calls via the `constructing` latch. - */ - async maybeConstruct(): Promise<void> { - const state = this.state; - if (state.chart || state.constructing || !state.payload || state.disposed) { - return; - } - // A card hidden as empty is `display: none`; constructing into it would - // yield a zero-size chart. The reveal effect re-enters when the toggle - // shows it (mirroring how the toggle-open path re-enters via onGroupOpen). - if (!this.groupIsOpen() || this.hiddenAsEmpty()) { - return; - } - const { canvas, tooltipHost, card } = this.els(); - if (!canvas || !card) { - return; - } - state.constructing = true; - try { - let Chart: typeof ChartJs; - try { - Chart = await loadChartJs(); - } catch (err) { - // A failed chunk load (deploy rotated the hashed assets, flaky - // network) surfaces like a fetch failure; the loader has already reset - // its cache, and the error indicator's auto-dismiss retries - // construction (bounded by `shouldRetryConstruct`) so charts whose - // only construction trigger already fired (the permalink page's - // one-shot IntersectionObserver) are not left permanently blank. - this.loadAttempts += 1; - if (!state.disposed) { - const message = err instanceof Error ? err.message : 'unknown error'; - this.cb.setError(`failed to load chart library: ${message}`); - } - return; - } - if (state.disposed || state.chart || !state.payload) { - return; - } - // Re-check the disclosure AND the empty-hide AFTER the await: the group - // can close (or the bundle can classify this card empty) while the - // Chart.js chunk loads (a window v3 never had, its library being - // preloaded), and constructing into the display:none grid would render a - // zero-size chart. The next toggle-open re-enters via onGroupOpen; the - // empty-reveal effect re-enters via maybeConstruct. - if (!this.groupIsOpen() || this.hiddenAsEmpty()) { - return; - } - const payload = state.payload; - - // Lock the display unit for the lifetime of this loaded payload; it is - // recomputed only when `replaceChartPayload` swaps in the wider window. - state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); - - const labels = payload.commits.map(commitDateLabel); - const datasets = buildDatasets(payload); - const range = visibleRange(labels.length, state.ui.scope); - const legendPosition = window.matchMedia?.('(max-width: 768px)').matches ? 'top' : 'bottom'; - - // Throttled rebuild for pan/zoom: both axes mutate `scales.x.min/max` - // continuously during interaction, so the rendered points re-derive at - // most every PAN_THROTTLE_MS and the range strip refreshes in the same - // call so LTTB and the strip never diverge. - const throttledRebuild = throttle((chart: ChartJs) => { - const sx = chart.scales?.x; - if (!sx) { - return; - } - this.rebuildVisibleAndUpdate(chart, sx.min, sx.max, true); - state.stripRender?.(); - }, PAN_THROTTLE_MS); - - const state_ = state; - const chart = new Chart(canvas, { - type: 'line', - data: { labels, datasets }, - plugins: [crosshairPlugin], - options: { - responsive: true, - maintainAspectRatio: false, - animation: false, - // Snap to the single nearest commit THAT HAS RENDERED DATA. After - // LTTB most commit indices are null in `dataset.data`; `mode: - // "index"` would pick null indices (empty tooltip) and `mode: "x"` - // would pick multiple closely-packed LTTB columns at once (duplicate - // rows). `intersect: false` keeps the tooltip active anywhere on the - // chart and, combined with `pointer-events: none` on the host, is - // also the flicker fix. - interaction: { mode: 'nearest', intersect: false, axis: 'x' }, - onClick: (event: ChartEvent, _active: ActiveElement[], c: ChartJs) => { - const native = event.native; - if (!native) { - return; - } - const points = c.getElementsAtEventForMode( - native, - 'nearest', - { intersect: false, axis: 'x' }, - true, - ); - if (points.length === 0) { - return; - } - const commit = state_.payload?.commits?.[points[0].index]; - if (!commit) { - return; - } - const pr = parsePrNumber(commit.message); - const url = pr ? `https://github.com/vortex-data/vortex/pull/${pr}` : commit.url; - if (url) { - window.open(url, '_blank', 'noopener'); - } - }, - scales: { - y: { - type: state.ui.y === 'log' ? 'logarithmic' : 'linear', - beginAtZero: state.ui.y !== 'log', - grid: { color: GRID_COLOR }, - // The axis title reflects the locked display unit; empty for - // dimensionless kinds so a "1.2x speedup" chart does not get an - // arbitrary label. - title: { - display: state.displayUnit.axisLabel !== '', - text: state.displayUnit.axisLabel, - }, - }, - x: { - min: range.min, - max: range.max, - grid: { color: GRID_COLOR }, - title: { display: false }, - // One tick per commit is unreadable on a 5000-commit history; - // Chart.js picks a sensible subset. - ticks: { maxTicksLimit: 12, autoSkip: true }, - }, - }, - plugins: { - legend: { - position: legendPosition, - // Rewrite only the displayed legend text (vortex-file-compressed -> - // vortex); `dataset.label` stays the real key the toggle/filter use. - labels: { - generateLabels: (ci) => { - const items = Chart.defaults.plugins.legend.labels.generateLabels(ci); - for (const item of items) { - if (typeof item.text === 'string') { - item.text = displaySeriesLabel(item.text); - } - } - return items; - }, - }, - // Wrap the default toggle to record the per-card override and - // keep `dataset.hidden` in sync with the legend's visibility - // flag; the filter passes write to `dataset.hidden`, so they - // need to track each other. - onClick: (_e: ChartEvent, item: LegendItem, legend) => { - const ci = legend.chart; - const ds = ci.data.datasets[item.datasetIndex ?? 0]; - const label = ds?.label; - if (label) { - state_.overrides[label] = true; - } - const visible = ci.isDatasetVisible(item.datasetIndex ?? 0); - ci.setDatasetVisibility(item.datasetIndex ?? 0, !visible); - if (ds) { - // Flipped: was visible means now hidden, and vice versa. - ds.hidden = visible; - } - ci.update(); - }, - }, - tooltip: { - enabled: false, - external: externalTooltipHandler(state, tooltipHost), - }, - // A mouse drag draws a selection rectangle and zooms to it (the v3 - // behavior), never pans. Panning is reserved for the wheel (manual - // listener below) and the range strip, so a plain drag highlights a - // region without sliding the axes. Wheel-zoom is disabled because - // wheel means PAN here. - zoom: { - zoom: { - wheel: { enabled: false }, - drag: { enabled: true, backgroundColor: 'rgba(37, 99, 235, 0.10)' }, - mode: 'x', - onZoom: (ctx) => throttledRebuild(ctx.chart), - }, - // Drag-to-pan is disabled so a drag is a zoom selection, not a pan. - // The plugin's `chart.pan()` API (used by the wheel listener and the - // range strip) is NOT gated by this flag, so wheel/strip panning is - // unaffected; only the built-in drag-pan gesture is removed. - pan: { - enabled: false, - mode: 'x', - }, - limits: { - x: { min: 0, max: Math.max(0, labels.length - 1), minRange: 4 }, - }, - }, - }, - }, - }); - - state.chart = chart; - this.cb.setConstructed(true); - state.rebuild = throttledRebuild; - this.attachWheelPan(canvas, chart, throttledRebuild); - this.syncSliderBounds(labels.length); - // Initial render: populate the null data for the initial window, then - // bind the strip so its first paint reflects the same range. - this.rebuildVisibleAndUpdate(chart, range.min ?? 0, range.max ?? 0, false); - this.bindRangeStrip(chart); - state.stripRender?.(); - // `buildDatasets` seeded `hidden` from the global filter; reapply through - // the layered helper so a per-group filter set before this card hydrated - // also takes effect. - this.applyFilters(); - if (this.groupSlug) { - noteGroupSeries(this.groupSlug, payload.series_meta); - } - } finally { - state.constructing = false; - } - } - - /** - * The single source of truth for the rendered point count, ported verbatim: - * build the per-commit max-across-series union over `[rangeMin, rangeMax]`, - * keep at most [`MAX_VISIBLE_POINTS`] shared commit indices (LTTB above the - * cap), and write the kept values into every `dataset.data` in place. - */ - rebuildVisibleAndUpdate( - chart: ChartJs, - rangeMin: number, - rangeMax: number, - allowFullFetch: boolean, - ): void { - const state = this.state; - // The throttled pan/zoom wrapper preserves its trailing call, which can - // land after teardown; updating a destroyed Chart.js instance throws. - if (state.disposed) { - return; - } - const payload = state.payload; - if (!payload) { - return; - } - const datasets = benchDatasets(chart); - const n = payload.commits.length; - if (n === 0) { - return; - } - - const min = Math.max(0, Math.floor(rangeMin)); - let max = Math.min(n - 1, Math.ceil(rangeMax)); - if (max < min) { - max = min; - } - - // Pick the kept commit indices by downsampling each series INDEPENDENTLY - // (see [`decimateSeries`]). The earlier approach LTTB'd a single - // max-across-series "virtual series" and shared its indices across every - // dataset; a series that never drove that maximum then lost its own points - // once the window exceeded the cap (the "data disappears after loading all" - // bug). The kept set is still capped at [`MAX_VISIBLE_POINTS`] distinct - // x-positions, split across the series with data in view. - const { kept: keptSet, downsampled: anyDownsampled } = decimateSeries( - datasets.map((ds) => ds.rawData), - min, - max, - MAX_VISIBLE_POINTS, - ); - - // Write the kept set into every dataset, scaled by the locked display-unit - // multiplier (applied here, not on ingest or in SQL, so the wire payload - // stays in base units). Values outside `[min, max]` stay null: planting - // off-screen neighbours would blow up the y-axis auto-scale (it reads - // every non-null value regardless of `scales.x.min/max`). - const multiplier = state.displayUnit.multiplier; - for (const ds of datasets) { - const dsRaw = ds.rawData; - if (!Array.isArray(dsRaw)) { - continue; - } - let data = ds.data; - if (!Array.isArray(data) || data.length !== n) { - data = new Array<number | null>(n); - ds.data = data; - } - for (let z = 0; z < n; z++) { - data[z] = null; - } - for (const idx of keptSet) { - const val = dsRaw[idx]; - if (val !== null && val !== undefined && !Number.isNaN(val)) { - data[idx] = val * multiplier; - } - } - } - - const visibleCommits = max - min + 1; - const keptCommits = keptSet.size; - chart.update('none'); - this.syncSliderFromRange(visibleCommits); - this.syncDownsampleBadge(keptCommits, visibleCommits, anyDownsampled); - // If the user moves into the virtual, not-yet-loaded part of the x-axis, - // promote this chart's queued full-history fetch ahead of background work. - if (allowFullFetch && rangeTouchesUnloadedHistory(payload, min, max)) { - void this.ensureFullHistory(INTERACTION_FULL_PRIORITY); - } - } - - /** - * Swap the chart's labels + datasets to the freshly fetched unbounded payload - * while preserving the current x-range. The virtual latest-100 payload and - * the full payload share a full-history x-axis, so the chart does not jump - * when the real older values arrive. - */ - replaceChartPayload(rawPayload: ChartResponse): void { - const state = this.state; - const payload = normalizeChartPayload(rawPayload); - state.payload = payload; - if (this.groupSlug) { - // [`chartHasRecentData`] reads only the trailing latest-100 slots, so the - // full-history payload yields the same verdict as the `?n=100` window; - // recording here (before the chart-null guard) covers the rare race where - // the full upgrade resolves before the initial window fetch, whose own - // handler then early-returns without classifying. - noteChartRecentData(this.groupSlug, this.slug, chartHasRecentData(payload)); - } - const chart = state.chart; - if (!chart) { - return; - } - // Re-pick the display unit against the now-wider window: the refetch may - // surface older commits with a different magnitude, and moving the y-axis - // once at the refetch boundary beats leaving the chart on a stale unit. - state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); - const yAxis = chart.options.scales?.y; - if (yAxis && 'title' in yAxis && yAxis.title) { - yAxis.title.display = state.displayUnit.axisLabel !== ''; - yAxis.title.text = state.displayUnit.axisLabel; - } - const newLabels = payload.commits.map(commitDateLabel); - const newDatasets = buildDatasets(payload); - // Honour any explicit legend toggles the user had made already. - for (const ds of newDatasets) { - if (ds.label && state.overrides[ds.label]) { - const prev = chart.data.datasets.find((p) => p.label === ds.label); - if (prev) { - ds.hidden = Boolean(prev.hidden); - } - } - } - chart.data.labels = newLabels; - chart.data.datasets = newDatasets; - this.applyFilters(); - if (this.groupSlug) { - noteGroupSeries(this.groupSlug, payload.series_meta); - } - const newMaxIdx = Math.max(0, newLabels.length - 1); - const zoomLimits = chart.options.plugins?.zoom?.limits?.x; - if (zoomLimits) { - zoomLimits.max = newMaxIdx; - } - this.syncSliderBounds(newLabels.length); - const sx = chart.options.scales?.x; - if (!sx) { - return; - } - // v3's `replaceChartPayload` carries an unreachable non-finite else branch - // here; the values are coerced finite above, so only the clamp survives. - const prevMin = typeof sx.min === 'number' && Number.isFinite(sx.min) ? sx.min : 0; - const prevMax = typeof sx.max === 'number' && Number.isFinite(sx.max) ? sx.max : 0; - sx.min = Math.max(0, Math.min(newMaxIdx, prevMin)); - sx.max = Math.max(sx.min, Math.min(newMaxIdx, prevMax)); - this.rebuildVisibleAndUpdate(chart, sx.min, sx.max, false); - state.stripRender?.(); - } - - /** Mirror the chart's visible commit count onto the toolbar slider; called - * from every path that changes the visible range. Programmatic value writes - * do not fire the slider's `input` event, so this never re-enters - * `applyScope`. */ - private syncSliderFromRange(visibleCommits: number): void { - const slider = this.els().slider; - if (!slider) { - return; - } - const lo = parseInt(slider.min, 10) || 1; - const hi = parseInt(slider.max, 10) || visibleCommits; - slider.value = String(Math.max(lo, Math.min(hi, visibleCommits))); - } - - /** Show the badge when the visible range was downsampled. The numbers are - * commit counts, matching the slider's mental model. */ - private syncDownsampleBadge( - keptCommits: number, - visibleCommits: number, - anyDownsampled: boolean, - ): void { - const badge = this.els().badge; - if (!badge) { - return; - } - if (!anyDownsampled || keptCommits >= visibleCommits) { - badge.setAttribute('hidden', ''); - badge.textContent = ''; - return; - } - badge.removeAttribute('hidden'); - badge.textContent = `downsampled · ${keptCommits} / ${visibleCommits}`; - badge.setAttribute( - 'title', - `Showing ${keptCommits} of ${visibleCommits} commits in view. Each series renders at most ` + - `${MAX_VISIBLE_POINTS} points at a time; when more are in view, we apply LTTB (Largest ` + - `Triangle, Three Buckets), an algorithm that picks representative points by maximising ` + - `the area of triangles formed with neighbouring buckets. Visual peaks and valleys are ` + - `preserved while the chart stays responsive. Zoom in past ${MAX_VISIBLE_POINTS} visible ` + - `commits to see every raw measurement.`, - ); - } - - /** Render the per-card window chip from controller state. Imperative, like - * `syncDownsampleBadge`: the chip is hidden for charts born complete, and - * otherwise reflects windowed → loading → complete, with an error → retry - * path and a hover-revealed "load all N" action. */ - private syncWindowChip(): void { - const chip = this.els().chip; - if (!chip) { - return; - } - const state = this.state; - const payload = state.payload; - if (!payload || !state.everWindowed) { - chip.setAttribute('hidden', ''); - chip.dataset.state = 'hidden'; - chip.textContent = ''; - chip.disabled = true; - chip.removeAttribute('title'); - return; - } - // Pin the locale so the grouped digits ("3,572") are deterministic across - // runtimes and CI ICU builds (and match the test expectations) rather than - // following the host's default locale. - const total = payload.history.total_commits.toLocaleString('en-US'); - const loaded = payload.history.loaded_commits.toLocaleString('en-US'); - chip.removeAttribute('hidden'); - if (state.fullLoaded) { - chip.dataset.state = 'complete'; - chip.disabled = true; - chip.textContent = `all ${total}`; - chip.removeAttribute('title'); - return; - } - if (state.fullUnavailable) { - chip.dataset.state = 'windowed'; - chip.disabled = true; - chip.textContent = `latest ${loaded} of ${total}`; - chip.removeAttribute('title'); - return; - } - if (state.fullFetchPending) { - chip.dataset.state = 'loading'; - chip.disabled = true; - chip.textContent = `loading all ${total}…`; - chip.removeAttribute('title'); - return; - } - if (state.chipError) { - chip.dataset.state = 'error'; - chip.disabled = false; - chip.textContent = 'retry'; - chip.setAttribute('title', 'Loading the full history failed. Click to retry.'); - return; - } - chip.dataset.state = 'windowed'; - chip.disabled = false; - chip.textContent = state.hovering ? `load all ${total}` : `latest ${loaded} of ${total}`; - chip.setAttribute( - 'title', - `Showing the latest ${loaded} of ${total} commits. Click to load the full history.`, - ); - } - - /** Window-chip click: load the full history at top priority, or retry after a - * failure. A no-op once full history is loaded or a fetch is already pending. */ - onWindowChipClick(): void { - const state = this.state; - if (state.disposed || state.fullLoaded || state.fullFetchPending || state.fullUnavailable) { - return; - } - state.chipError = false; - void this.ensureFullHistory(INTERACTION_FULL_PRIORITY); - } - - /** Pointer resting on the card: reveal the chip's action immediately and arm - * the dwell-prefetch timer. Only a deliberate dwell (not a sweep) fetches. */ - onCardHoverStart(): void { - const state = this.state; - if (state.disposed) { - return; - } - state.hovering = true; - this.syncWindowChip(); - if ( - state.fullLoaded || - state.fullFetchPending || - state.fullUnavailable || - state.hoverDwellTimer !== null - ) { - return; - } - state.hoverDwellTimer = setTimeout(() => { - state.hoverDwellTimer = null; - if (state.disposed) { - return; - } - void this.ensureFullHistory(HOVER_PREFETCH_PRIORITY); - }, HOVER_DWELL_MS); - } - - /** Pointer left the card: restore the chip label and cancel a pending dwell. */ - onCardHoverEnd(): void { - const state = this.state; - if (state.disposed) { - return; - } - state.hovering = false; - if (state.hoverDwellTimer !== null) { - clearTimeout(state.hoverDwellTimer); - state.hoverDwellTimer = null; - } - this.syncWindowChip(); - } - - /** Cap the slider's `max` to the chart's full x-axis length; for a virtual - * latest-100 payload this is intentionally larger than the loaded count so - * "show all" can expose the unloaded older range while the full-history - * fetch is warming. */ - private syncSliderBounds(commitCount: number): void { - const slider = this.els().slider; - if (!slider) { - return; - } - const max = Math.max(5, commitCount); - slider.max = String(max); - // ~200 stops across the slider so dragging feels continuous regardless of - // history size. - slider.step = String(Math.max(1, Math.round(max / 200))); - const current = parseInt(slider.value, 10); - if (!Number.isFinite(current) || current > max) { - slider.value = String(Math.min(DEFAULT_VISIBLE, max)); - } - } - - /** Wheel means horizontal pan (the zoom plugin only offers wheel-zoom), so a - * manual `wheel` listener translates the dominant delta into `chart.pan`. */ - private attachWheelPan( - canvas: HTMLCanvasElement, - chart: ChartJs, - rebuild: (chart: ChartJs) => void, - ): void { - const state = this.state; - if (state.wheelAttached) { - return; - } - state.wheelAttached = true; - canvas.addEventListener( - 'wheel', - (e: WheelEvent) => { - // Horizontal-wheel-or-shift+wheel pans horizontally; plain vertical - // wheel also pans so trackpad scroll moves through commit history - // without modifier keys. - const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - if (!dx) { - return; - } - e.preventDefault(); - chart.pan({ x: dx * 0.5 }, undefined, 'none'); - rebuild(chart); - }, - { passive: false, signal: this.aborter.signal }, - ); - } - - /** - * Bind the range scrollbar strip below the canvas: the highlighted window - * matches the chart's visible x-range and can be dragged or edge-resized to - * pan/zoom. Ported verbatim from v3's pointer math. - */ - private bindRangeStrip(chart: ChartJs): void { - const { strip, stripWindow } = this.els(); - const state = this.state; - if (!strip || !stripWindow) { - return; - } - - const commitCount = (): number => (chart.data.labels ?? []).length; - - const visibleBounds = (): { min: number; max: number } => { - const n = commitCount(); - if (n <= 0) { - return { min: 0, max: 0 }; - } - const maxIdx = n - 1; - const sx = chart.options.scales?.x ?? {}; - let min = typeof sx.min === 'number' && Number.isFinite(sx.min) ? sx.min : 0; - let max = typeof sx.max === 'number' && Number.isFinite(sx.max) ? sx.max : maxIdx; - min = Math.max(0, Math.min(maxIdx, min)); - max = Math.max(min, Math.min(maxIdx, max)); - return { min, max }; - }; - - const render = (): void => { - const n = commitCount(); - if (n <= 0) { - stripWindow.style.left = '0%'; - stripWindow.style.width = '100%'; - return; - } - const b = visibleBounds(); - const span = Math.max(1, n - 1); - let leftPct = (b.min / span) * 100; - let widthPct = ((b.max - b.min) / span) * 100; - // A minimum visible width keeps the handles grabbable when zoomed in - // tight on a single commit. - if (widthPct < 1.5) { - widthPct = 1.5; - } - if (leftPct + widthPct > 100) { - leftPct = 100 - widthPct; - } - stripWindow.style.left = `${leftPct}%`; - stripWindow.style.width = `${widthPct}%`; - strip.setAttribute('aria-valuenow', String(Math.round(leftPct))); - }; - - const setRange = (rawMin: number, rawMax: number): void => { - const n = commitCount(); - if (n <= 0) { - return; - } - const maxIdx = n - 1; - // Looser than the plugin's `limits.x.minRange = 4`; the strip allows a - // tighter window. `clampRangeWindow` collapses the minimum span to zero on - // a single-commit chart so a bare-track click cannot push `x.max` one slot - // past the only label. - const { min: newMin, max: newMax } = clampRangeWindow(maxIdx, rawMin, rawMax); - const sx = chart.options.scales?.x; - if (!sx) { - return; - } - sx.min = newMin; - sx.max = newMax; - // Track scope so the toolbar slider stays consistent on later drags. - state.ui.scope = Math.round(newMax - newMin + 1); - this.rebuildVisibleAndUpdate(chart, newMin, newMax, true); - render(); - }; - - const pxToIndex = (px: number, trackWidth: number): number => { - const n = commitCount(); - if (n <= 1 || trackWidth <= 0) { - return 0; - } - const pct = Math.max(0, Math.min(1, px / trackWidth)); - return pct * (n - 1); - }; - - let dragState: { - mode: 'pan' | 'resize-left' | 'resize-right'; - rect: DOMRect; - startX: number; - startMin: number; - startMax: number; - pointerId: number; - } | null = null; - - const onPointerDown = (e: PointerEvent): void => { - if (e.button !== undefined && e.button !== 0) { - return; - } - const target = e.target as HTMLElement; - const role = target.getAttribute?.('data-role'); - const rect = strip.getBoundingClientRect(); - let b = visibleBounds(); - const idxAtCursor = pxToIndex(e.clientX - rect.left, rect.width); - - let mode: 'pan' | 'resize-left' | 'resize-right'; - if (role === 'range-handle-left') { - mode = 'resize-left'; - } else if (role === 'range-handle-right') { - mode = 'resize-right'; - } else if (role === 'range-window') { - mode = 'pan'; - } else { - // Click on bare track: jump the window so its center lands at the - // cursor, then begin a pan drag. - const width = b.max - b.min; - const newMin = idxAtCursor - width / 2; - setRange(newMin, newMin + width); - b = visibleBounds(); - mode = 'pan'; - } - dragState = { - mode, - rect, - startX: e.clientX, - startMin: b.min, - startMax: b.max, - pointerId: e.pointerId, - }; - try { - strip.setPointerCapture(e.pointerId); - } catch { - // Pointer capture is best-effort; drag still works without it. - } - e.preventDefault(); - strip.classList.add('chart-range-strip--dragging'); - }; - - const onPointerMove = (e: PointerEvent): void => { - if (!dragState) { - return; - } - const n = commitCount(); - if (n <= 1) { - return; - } - const dxIdx = ((e.clientX - dragState.startX) / Math.max(1, dragState.rect.width)) * (n - 1); - if (dragState.mode === 'pan') { - setRange(dragState.startMin + dxIdx, dragState.startMax + dxIdx); - } else if (dragState.mode === 'resize-left') { - setRange(dragState.startMin + dxIdx, dragState.startMax); - } else { - setRange(dragState.startMin, dragState.startMax + dxIdx); - } - }; - - const onPointerUp = (): void => { - if (!dragState) { - return; - } - try { - strip.releasePointerCapture(dragState.pointerId); - } catch { - // Capture may already be released; nothing to do. - } - dragState = null; - strip.classList.remove('chart-range-strip--dragging'); - }; - - const { signal } = this.aborter; - strip.addEventListener('pointerdown', onPointerDown, { signal }); - strip.addEventListener('pointermove', onPointerMove, { signal }); - strip.addEventListener('pointerup', onPointerUp, { signal }); - strip.addEventListener('pointercancel', onPointerUp, { signal }); - - // Expose the strip's render so the toolbar slider, wheel-pan, and the - // throttled LTTB rebuild keep the strip in lockstep without knowing strip - // internals. - state.stripRender = render; - render(); - } - - /** - * Apply a toolbar/strip scope change. Preserves the visible center when the - * user has panned away from the right edge (see [`visibleRange`]). - * - * Like [`applyY`], the target scope is recorded BEFORE the chart-null guard - * (a deliberate deviation from v3, where the toolbar only bound after - * construction so pre-construction input was unreachable): a slider drag on - * a not-yet-hydrated card still selects the window construction renders. - */ - applyScope(scopeValue: string): void { - const state = this.state; - const scope = scopeValue === 'all' ? ('all' as const) : parseInt(scopeValue, 10); - state.ui.scope = scope; - const chart = state.chart; - if (!chart) { - return; - } - const commits = (chart.data.labels ?? []).length; - const sx = chart.options.scales?.x; - const currentRange = sx - ? { - min: typeof sx.min === 'number' ? sx.min : undefined, - max: typeof sx.max === 'number' ? sx.max : undefined, - } - : null; - const range = visibleRange(commits, scope, currentRange); - if (sx) { - sx.min = range.min; - sx.max = range.max; - } - this.rebuildVisibleAndUpdate( - chart, - range.min ?? 0, - range.max ?? Math.max(0, commits - 1), - true, - ); - state.stripRender?.(); - } - - /** - * Apply a Y-scale change. `userInitiated` toggles the sticky flag: once the - * user clicks the per-chart Y toolbar, the per-group Y broadcast skips this - * chart so the local click stays honored. - * - * Deliberate deviation from v3: `chart-init.js::applyY` no-ops entirely when - * the chart has not constructed yet, dropping the click; here the sticky flag - * and target scale are recorded BEFORE the chart-null guard so a click on a - * not-yet-hydrated card still applies once the chart constructs. - */ - applyY(yValue: 'linear' | 'log', userInitiated: boolean): void { - const state = this.state; - if (userInitiated) { - state.yUserSet = true; - } - state.ui.y = yValue; - this.cb.setY(yValue); - const chart = state.chart; - if (!chart) { - return; - } - const yAxis = chart.options.scales?.y; - if (yAxis) { - yAxis.type = yValue === 'log' ? 'logarithmic' : 'linear'; - if ('beginAtZero' in yAxis) { - yAxis.beginAtZero = yValue !== 'log'; - } - } - chart.update('none'); - } - - /** Whether the per-group Y broadcast should skip this chart. */ - yIsSticky(): boolean { - return this.state.yUserSet; - } - - /** Whether the error-dismiss timer should retry construction: a payload is - * waiting, no chart exists, and the bounded Chart.js import budget (3 - * attempts) is not exhausted. After the budget, the card stays blank until - * reload, which is v3's behavior for a failed static script. */ - shouldRetryConstruct(): boolean { - const state = this.state; - return ( - !state.disposed && state.chart === null && state.payload !== null && this.loadAttempts < 3 - ); - } - - /** - * Re-evaluate every dataset under the layered filter resolution: per-card - * legend overrides win, then explicit per-group show/hide choices, while the - * global engine/format filter supplies the fallback visibility. - */ - applyFilters(): void { - const state = this.state; - const chart = state.chart; - if (!chart) { - return; - } - const global = getGlobalFilterSnapshot(); - const group = this.groupSlug ? getGroupSnapshot(this.groupSlug) : emptyGroupSnapshot(); - for (const ds of benchDatasets(chart)) { - if (ds.label && state.overrides[ds.label]) { - continue; - } - // `dataset.hidden` directly (not `setDatasetVisibility`) so the legend - // stays in sync; the visibility map is a separate channel. - const globalVisible = seriesPassesFilter(ds.benchMeta, global.active, global.universe); - ds.hidden = !seriesPassesGroupFilter(group, ds.label ?? '', globalVisible); - } - chart.update('none'); - } - - /** Cancel any in-flight `?n=100` / `?n=all` request WITHOUT tearing down the - * controller, so closing a group (or its IO disconnect) frees server capacity - * and stops open/close from piling requests up. A reopen re-issues the fetch. - * The aborts reject the in-flight promises with `AbortError`, which the fetch - * catch paths treat as a silent cancellation. */ - abortInFlightFetches(): void { - this.state.initialFetchController?.abort(); - this.state.fullFetchController?.abort(); - // Drop the entry references so a reopen schedules a FRESH fetch rather than - // joining the now-aborting promise (which resolves to nothing and would leave - // the card blank). The aborted tasks still settle; their handlers' identity- - // guarded clears below no-op once a newer fetch owns these refs. - this.state.initialFetchController = null; - this.state.initialFetchEntry = null; - this.state.fullFetchController = null; - this.state.fullFetchEntry = null; - this.state.fullFetchPending = null; - } - - /** Tear down this controller: destroy the chart, remove every DOM listener - * it attached (wheel, strip pointers), and drop late async results. One-way; - * the mount effect constructs a fresh controller for the next mount. */ - destroy(): void { - this.state.disposed = true; - this.unsubscribeBundleRetry?.(); - this.aborter.abort(new DOMException('chart controller destroyed', 'AbortError')); - if (this.state.hoverDwellTimer !== null) { - clearTimeout(this.state.hoverDwellTimer); - this.state.hoverDwellTimer = null; - } - this.state.chart?.destroy(); - this.state.chart = null; - } -} - /** Props for one chart island. */ export interface ChartIslandProps { /** The chart's payload slug (`/api/chart/{slug}` and the permalink). */ diff --git a/bench/web/components/chart/card-dom.ts b/bench/web/components/chart/card-dom.ts new file mode 100644 index 0000000..3cedf14 --- /dev/null +++ b/bench/web/components/chart/card-dom.ts @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { DEFAULT_VISIBLE, MAX_VISIBLE_POINTS } from '@/lib/chart-format'; + +import type { CardState } from './types'; + +/** Mirror the chart's visible commit count onto the toolbar slider; called + * from every path that changes the visible range. Programmatic value writes + * do not fire the slider's `input` event, so this never re-enters + * `applyScope`. */ +export function setSliderFromRange(slider: HTMLInputElement | null, visibleCommits: number): void { + if (!slider) { + return; + } + const lo = parseInt(slider.min, 10) || 1; + const hi = parseInt(slider.max, 10) || visibleCommits; + slider.value = String(Math.max(lo, Math.min(hi, visibleCommits))); +} + +/** Show the badge when the visible range was downsampled. The numbers are + * commit counts, matching the slider's mental model. */ +export function renderDownsampleBadge( + badge: HTMLSpanElement | null, + keptCommits: number, + visibleCommits: number, + anyDownsampled: boolean, +): void { + if (!badge) { + return; + } + if (!anyDownsampled || keptCommits >= visibleCommits) { + badge.setAttribute('hidden', ''); + badge.textContent = ''; + return; + } + badge.removeAttribute('hidden'); + badge.textContent = `downsampled · ${keptCommits} / ${visibleCommits}`; + badge.setAttribute( + 'title', + `Showing ${keptCommits} of ${visibleCommits} commits in view. Each series renders at most ` + + `${MAX_VISIBLE_POINTS} points at a time; when more are in view, we apply LTTB (Largest ` + + `Triangle, Three Buckets), an algorithm that picks representative points by maximising ` + + `the area of triangles formed with neighbouring buckets. Visual peaks and valleys are ` + + `preserved while the chart stays responsive. Zoom in past ${MAX_VISIBLE_POINTS} visible ` + + `commits to see every raw measurement.`, + ); +} + +/** Render the per-card window chip from controller state. Imperative, like + * `syncDownsampleBadge`: the chip is hidden for charts born complete, and + * otherwise reflects windowed → loading → complete, with an error → retry + * path and a hover-revealed "load all N" action. */ +export function renderWindowChip(chip: HTMLButtonElement | null, state: CardState): void { + if (!chip) { + return; + } + const payload = state.payload; + if (!payload || !state.everWindowed) { + chip.setAttribute('hidden', ''); + chip.dataset.state = 'hidden'; + chip.textContent = ''; + chip.disabled = true; + chip.removeAttribute('title'); + return; + } + // Pin the locale so the grouped digits ("3,572") are deterministic across + // runtimes and CI ICU builds (and match the test expectations) rather than + // following the host's default locale. + const total = payload.history.total_commits.toLocaleString('en-US'); + const loaded = payload.history.loaded_commits.toLocaleString('en-US'); + chip.removeAttribute('hidden'); + if (state.fullLoaded) { + chip.dataset.state = 'complete'; + chip.disabled = true; + chip.textContent = `all ${total}`; + chip.removeAttribute('title'); + return; + } + if (state.fullUnavailable) { + chip.dataset.state = 'windowed'; + chip.disabled = true; + chip.textContent = `latest ${loaded} of ${total}`; + chip.removeAttribute('title'); + return; + } + if (state.fullFetchPending) { + chip.dataset.state = 'loading'; + chip.disabled = true; + chip.textContent = `loading all ${total}…`; + chip.removeAttribute('title'); + return; + } + if (state.chipError) { + chip.dataset.state = 'error'; + chip.disabled = false; + chip.textContent = 'retry'; + chip.setAttribute('title', 'Loading the full history failed. Click to retry.'); + return; + } + chip.dataset.state = 'windowed'; + chip.disabled = false; + chip.textContent = state.hovering ? `load all ${total}` : `latest ${loaded} of ${total}`; + chip.setAttribute( + 'title', + `Showing the latest ${loaded} of ${total} commits. Click to load the full history.`, + ); +} + +/** Cap the slider's `max` to the chart's full x-axis length; for a virtual + * latest-100 payload this is intentionally larger than the loaded count so + * "show all" can expose the unloaded older range while the full-history + * fetch is warming. */ +export function setSliderBounds(slider: HTMLInputElement | null, commitCount: number): void { + if (!slider) { + return; + } + const max = Math.max(5, commitCount); + slider.max = String(max); + // ~200 stops across the slider so dragging feels continuous regardless of + // history size. + slider.step = String(Math.max(1, Math.round(max / 200))); + const current = parseInt(slider.value, 10); + if (!Number.isFinite(current) || current > max) { + slider.value = String(Math.min(DEFAULT_VISIBLE, max)); + } +} diff --git a/bench/web/components/chart/config.ts b/bench/web/components/chart/config.ts new file mode 100644 index 0000000..9620124 --- /dev/null +++ b/bench/web/components/chart/config.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { + ActiveElement, + Chart as ChartJs, + ChartConfiguration, + ChartEvent, + LegendItem, + Plugin, +} from 'chart.js'; + +import { displaySeriesLabel, parsePrNumber, type VisibleRange } from '@/lib/chart-format'; + +import { externalTooltipHandler } from './tooltip'; +import type { BenchDataset, CardState } from './types'; + +/** Subtle, theme-neutral grid line color (slate-gray at low opacity), matching + * the v2 frontend's faint gridlines a reviewer preferred over the heavier + * Chart.js default. */ +const GRID_COLOR = 'rgba(148, 163, 184, 0.14)'; + +// --------------------------------------------------------------------------- +// Crosshair plugin: draws a vertical line at the chart's active hover index. +// An inline plugin is cheaper than chartjs-plugin-crosshair, which is overkill +// for this one feature. +// --------------------------------------------------------------------------- +const crosshairPlugin: Plugin<'line'> = { + id: 'benchCrosshair', + afterDatasetsDraw(chart) { + const active = chart.tooltip?.getActiveElements ? chart.tooltip.getActiveElements() : []; + if (!active || active.length === 0) { + return; + } + const x = active[0].element.x; + const ya = chart.scales?.y; + if (!ya || !Number.isFinite(x)) { + return; + } + const ctx = chart.ctx; + ctx.save(); + // `--muted` from the page theme, read lazily so dark mode picks up the + // right color. + const muted = + getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#9ca3af'; + ctx.strokeStyle = muted; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(x, ya.top); + ctx.lineTo(x, ya.bottom); + ctx.stroke(); + ctx.restore(); + }, +}; + +/** The Chart.js config for one card's line chart, built once at construction. */ +export function lineChartConfig(input: { + Chart: typeof ChartJs; + state: CardState; + tooltipHost: HTMLDivElement | null; + labels: string[]; + datasets: BenchDataset[]; + range: VisibleRange; + legendPosition: 'top' | 'bottom'; + onZoom: (chart: ChartJs) => void; +}): ChartConfiguration<'line', (number | null)[], string> { + const { Chart, state, tooltipHost, labels, datasets, range, legendPosition, onZoom } = input; + return { + type: 'line', + data: { labels, datasets }, + plugins: [crosshairPlugin], + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + // Snap to the single nearest commit THAT HAS RENDERED DATA. After + // LTTB most commit indices are null in `dataset.data`; `mode: + // "index"` would pick null indices (empty tooltip) and `mode: "x"` + // would pick multiple closely-packed LTTB columns at once (duplicate + // rows). `intersect: false` keeps the tooltip active anywhere on the + // chart and, combined with `pointer-events: none` on the host, is + // also the flicker fix. + interaction: { mode: 'nearest', intersect: false, axis: 'x' }, + onClick: (event: ChartEvent, _active: ActiveElement[], c: ChartJs) => { + const native = event.native; + if (!native) { + return; + } + const points = c.getElementsAtEventForMode( + native, + 'nearest', + { intersect: false, axis: 'x' }, + true, + ); + if (points.length === 0) { + return; + } + const commit = state.payload?.commits?.[points[0].index]; + if (!commit) { + return; + } + const pr = parsePrNumber(commit.message); + const url = pr ? `https://github.com/vortex-data/vortex/pull/${pr}` : commit.url; + if (url) { + window.open(url, '_blank', 'noopener'); + } + }, + scales: { + y: { + type: state.ui.y === 'log' ? 'logarithmic' : 'linear', + beginAtZero: state.ui.y !== 'log', + grid: { color: GRID_COLOR }, + // The axis title reflects the locked display unit; empty for + // dimensionless kinds so a "1.2x speedup" chart does not get an + // arbitrary label. + title: { + display: state.displayUnit.axisLabel !== '', + text: state.displayUnit.axisLabel, + }, + }, + x: { + min: range.min, + max: range.max, + grid: { color: GRID_COLOR }, + title: { display: false }, + // One tick per commit is unreadable on a 5000-commit history; + // Chart.js picks a sensible subset. + ticks: { maxTicksLimit: 12, autoSkip: true }, + }, + }, + plugins: { + legend: { + position: legendPosition, + // Rewrite only the displayed legend text (vortex-file-compressed -> + // vortex); `dataset.label` stays the real key the toggle/filter use. + labels: { + generateLabels: (ci) => { + const items = Chart.defaults.plugins.legend.labels.generateLabels(ci); + for (const item of items) { + if (typeof item.text === 'string') { + item.text = displaySeriesLabel(item.text); + } + } + return items; + }, + }, + // Wrap the default toggle to record the per-card override and + // keep `dataset.hidden` in sync with the legend's visibility + // flag; the filter passes write to `dataset.hidden`, so they + // need to track each other. + onClick: (_e: ChartEvent, item: LegendItem, legend) => { + const ci = legend.chart; + const ds = ci.data.datasets[item.datasetIndex ?? 0]; + const label = ds?.label; + if (label) { + state.overrides[label] = true; + } + const visible = ci.isDatasetVisible(item.datasetIndex ?? 0); + ci.setDatasetVisibility(item.datasetIndex ?? 0, !visible); + if (ds) { + // Flipped: was visible means now hidden, and vice versa. + ds.hidden = visible; + } + ci.update(); + }, + }, + tooltip: { + enabled: false, + external: externalTooltipHandler(state, tooltipHost), + }, + // A mouse drag draws a selection rectangle and zooms to it (the v3 + // behavior), never pans. Panning is reserved for the wheel (manual + // listener below) and the range strip, so a plain drag highlights a + // region without sliding the axes. Wheel-zoom is disabled because + // wheel means PAN here. + zoom: { + zoom: { + wheel: { enabled: false }, + drag: { enabled: true, backgroundColor: 'rgba(37, 99, 235, 0.10)' }, + mode: 'x', + onZoom: (ctx) => onZoom(ctx.chart), + }, + // Drag-to-pan is disabled so a drag is a zoom selection, not a pan. + // The plugin's `chart.pan()` API (used by the wheel listener and the + // range strip) is NOT gated by this flag, so wheel/strip panning is + // unaffected; only the built-in drag-pan gesture is removed. + pan: { + enabled: false, + mode: 'x', + }, + limits: { + x: { min: 0, max: Math.max(0, labels.length - 1), minRange: 4 }, + }, + }, + }, + }, + }; +} diff --git a/bench/web/components/chart/controller.ts b/bench/web/components/chart/controller.ts new file mode 100644 index 0000000..9c721d8 --- /dev/null +++ b/bench/web/components/chart/controller.ts @@ -0,0 +1,998 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Chart as ChartJs } from 'chart.js'; + +import { + CHART_FETCH_N, + chartHasRecentData, + collectAllValues, + commitDateLabel, + decimateSeries, + DEFAULT_VISIBLE, + FETCH_N, + FETCH_TIMEOUT_MS, + HOVER_DWELL_MS, + HOVER_PREFETCH_PRIORITY, + IDENTITY_UNIT, + INTERACTION_FULL_PRIORITY, + MAX_VISIBLE_POINTS, + normalizeChartPayload, + PAN_THROTTLE_MS, + pickDisplayUnit, + rangeTouchesUnloadedHistory, + seriesPassesFilter, + seriesPassesGroupFilter, + throttle, + visibleRange, +} from '@/lib/chart-format'; +import { loadChartJs } from '@/lib/chart-js'; +import { + chartIsHiddenAsEmpty, + emptyGroupSnapshot, + ensureGroupBundle, + fullHistoryQueue, + getCachedPayload, + getGlobalFilterSnapshot, + getGroupSnapshot, + hydrationQueue, + noteChartRecentData, + noteGroupSeries, + retryGroupBundle, + subscribeGroupBundleRetry, +} from '@/lib/chart-store'; +import type { ChartResponse } from '@/lib/queries'; + +import { + renderDownsampleBadge, + renderWindowChip, + setSliderBounds, + setSliderFromRange, +} from './card-dom'; +import { lineChartConfig } from './config'; +import { benchDatasets, buildDatasets } from './datasets'; +import { attachWheelPan, bindRangeStrip } from './pan-zoom'; +import type { CardCallbacks, CardElements, CardState } from './types'; + +/** + * The imperative per-island engine. Created once per MOUNT (not per component + * instance): React StrictMode runs every dev effect as mount, cleanup, remount, + * and `destroy()` is one-way, so the mount effect constructs a fresh controller + * each time it runs and tears the previous one down in its cleanup. Every + * method is a direct port of the corresponding `chart-init.js` function with + * the canvas field-stash replaced by [`CardState`]; the DOM listeners the + * controller attaches (wheel pan, range-strip pointers) are registered with its + * own abort signal so a teardown removes them from the still-mounted nodes + * before the next controller re-binds. + */ +export class ChartController { + private readonly state: CardState; + /** Aborted on [`destroy`]; every controller-attached DOM listener uses it. */ + private readonly aborter = new AbortController(); + /** Failed Chart.js dynamic-import attempts; bounds the error-dismiss retry. */ + private loadAttempts = 0; + private bundleFailed = false; + private readonly unsubscribeBundleRetry?: () => void; + + constructor( + private readonly slug: string, + private readonly groupSlug: string | undefined, + private readonly els: () => CardElements, + private readonly cb: CardCallbacks, + ) { + this.state = { + chart: null, + constructing: false, + payload: null, + ui: { y: 'linear', scope: DEFAULT_VISIBLE }, + overrides: {}, + displayUnit: IDENTITY_UNIT, + fullLoaded: false, + initialFetchEntry: null, + fullFetchEntry: null, + fullFetchPending: null, + initialFetchController: null, + fullFetchController: null, + everWindowed: false, + chipError: false, + hovering: false, + hoverDwellTimer: null, + fullUnavailable: false, + yUserSet: false, + stripRender: null, + rebuild: null, + wheelAttached: false, + disposed: false, + }; + if (groupSlug) { + this.unsubscribeBundleRetry = subscribeGroupBundleRetry(groupSlug, () => { + if (this.bundleFailed && this.groupIsOpen() && !this.state.disposed) { + this.bundleFailed = false; + this.cb.setError(null); + this.onGroupOpen(0); + } + }); + } + } + + /** Seed the permalink page's server-fetched payload before any fetch runs. */ + seedPayload(payload: ChartResponse): void { + const normalized = normalizeChartPayload(payload); + this.state.payload = normalized; + this.state.fullLoaded = normalized.history.complete; + if (!normalized.history.complete) { + this.state.everWindowed = true; + } + this.syncWindowChip(); + if (this.groupSlug) { + noteGroupSeries(this.groupSlug, normalized.series_meta); + } + } + + /** Whether the enclosing group disclosure (if any) is open. */ + private groupIsOpen(): boolean { + const card = this.els().card; + const group = card?.closest('.group-details'); + const details = group?.querySelector('details.group-disclosure'); + return !details || (details as HTMLDetailsElement).open; + } + + /** Whether the group store currently hides this card as an empty chart (no + * data in the latest-100 window, reveal toggle off). Read from the store, + * not the DOM: the store mutation precedes React's `display: none` commit, + * so gating on it suppresses construction without racing the render. */ + private hiddenAsEmpty(): boolean { + return ( + this.groupSlug !== undefined && + chartIsHiddenAsEmpty(getGroupSnapshot(this.groupSlug), this.slug) + ); + } + + /** + * Ensure this chart's default `?n=100` payload is loaded. Consults the session + * payload cache first (a sibling group-bundle fetch may have already cached + * it), then on the landing page drives one bundle fetch per group, falling + * back to the per-chart [`fetchInitialPayloadDirect`] only when the bundle does + * not cover this slug. The permalink page (no group slug) goes straight to the + * per-chart fetch. `showLoading` mirrors v3: the group-open path shows the + * per-card loading indicator, the pointer-intent prefetch stays silent. + */ + ensureInitialPayload(priority: number, showLoading: boolean): Promise<void> { + const state = this.state; + if (state.payload || state.disposed) { + return Promise.resolve(); + } + // Fast path: a sibling group-bundle fetch may have already cached this + // chart's default payload. Seed from it synchronously (the same steps as the + // fetch success path) so no per-chart request is issued. + const cached = getCachedPayload(this.slug); + if (cached) { + this.seedFromCachedPayload(cached); + return Promise.resolve(); + } + // On the landing page (a group slug is present), drive one bundle fetch per + // group and hydrate from it. Only fall through to the per-chart fetch when + // the bundle is unavailable (404) or this slug is missing. + if (this.groupSlug) { + if (showLoading) { + this.cb.setError(null); + this.cb.setLoading(true); + this.cb.setRetryable(false); + } + const groupSlug = this.groupSlug; + this.bundleFailed = false; + return ensureGroupBundle(groupSlug, priority).then((result) => { + // The group can close while this card awaits the in-flight bundle. The + // close runs `abortInFlightFetches` + `abortGroupBundle` already, so a + // per-chart fallback issued now would never be aborted and would defeat + // the "closing a group frees server capacity" contract. Bail when the + // group is no longer open. + if (state.disposed || state.payload || !this.groupIsOpen() || result.status === 'aborted') { + return; + } + const fromBundle = getCachedPayload(this.slug); + if (fromBundle) { + this.seedFromCachedPayload(fromBundle); + return; + } + if (result.status === 'failed') { + this.bundleFailed = true; + this.cb.setLoading(false); + this.cb.setError('failed to load group charts'); + this.cb.setRetryable(true); + return; + } + // Bundle did not cover this chart: fall back to the per-chart fetch. + return this.fetchInitialPayloadDirect(priority, showLoading); + }); + } + return this.fetchInitialPayloadDirect(priority, showLoading); + } + + /** Seed state from a cached default payload (the bundle/cache hit path), + * mirroring the per-chart fetch's success handler. Does NOT call + * `maybeConstruct`; the caller does after the returned promise resolves. */ + private seedFromCachedPayload(raw: ChartResponse): void { + const state = this.state; + // A concurrent full-history upgrade may already have constructed from the + // `?n=all` payload; the late cache seed must not clobber that back to the + // bounded window (same invariant as the per-chart success handler). + if (state.fullLoaded) { + return; + } + const normalized = normalizeChartPayload(raw); + state.payload = normalized; + state.fullLoaded = normalized.history.complete; + if (!normalized.history.complete) { + state.everWindowed = true; + } + this.syncWindowChip(); + this.cb.setLoading(false); + this.cb.setError(null); + this.cb.setRetryable(false); + if (this.groupSlug) { + noteGroupSeries(this.groupSlug, normalized.series_meta); + } + } + + /** + * Queue the initial `?n=100` fetch through the bounded hydration queue (or + * bump its priority if already queued). The per-chart fallback for the bundle + * path and the only path on the permalink page. `showLoading` mirrors v3: the + * group-open path shows the per-card loading indicator, the pointer-intent + * prefetch stays silent. + */ + private fetchInitialPayloadDirect(priority: number, showLoading: boolean): Promise<void> { + const state = this.state; + if (state.initialFetchEntry) { + if (priority > state.initialFetchEntry.priority) { + state.initialFetchEntry.priority = priority; + hydrationQueue.drain(); + } + if (showLoading) { + this.cb.setLoading(true); + this.cb.setRetryable(false); + } + return state.initialFetchEntry.promise.then( + () => undefined, + () => undefined, + ); + } + if (showLoading) { + this.cb.setLoading(true); + this.cb.setRetryable(false); + } + const url = `/api/chart/${encodeURIComponent(this.slug)}?n=${encodeURIComponent(CHART_FETCH_N)}`; + const fc = new AbortController(); + state.initialFetchController = fc; + // Bridge the controller-lifetime aborter to this per-fetch controller so + // `destroy()` cancels the in-flight request. `{ once: true }` self-removes + // the listener after a single abort; the `finally` removes it on the no-abort + // path. Propagate the parent's reason so a destroy reads as `AbortError`. + const onParentAbort = (): void => fc.abort(this.aborter.signal.reason); + this.aborter.signal.addEventListener('abort', onParentAbort, { once: true }); + if (this.aborter.signal.aborted) { + fc.abort(this.aborter.signal.reason); + } + const entry = hydrationQueue.schedule(async () => { + // The timeout starts when the task actually runs (not while queued), so it + // measures fetch duration, not queue wait. A `TimeoutError` reason lets the + // catch tell a timeout apart from a close/destroy `AbortError`. + const timer = setTimeout( + () => fc.abort(new DOMException('Fetch timed out', 'TimeoutError')), + FETCH_TIMEOUT_MS, + ); + try { + const r = await fetch(url, { headers: { accept: 'application/json' }, signal: fc.signal }); + if (!r.ok) { + throw new Error(r.status === 404 ? 'not found' : `HTTP ${r.status}`); + } + return (await r.json()) as ChartResponse; + } finally { + clearTimeout(timer); + this.aborter.signal.removeEventListener('abort', onParentAbort); + } + }, priority); + state.initialFetchEntry = entry; + return entry.promise.then( + (raw) => { + if (state.initialFetchEntry === entry) { + state.initialFetchEntry = null; + } + if (state.initialFetchController === fc) { + state.initialFetchController = null; + } + if (state.disposed) { + return; + } + // A concurrent full-history upgrade (hover dwell or chip click) may have + // already resolved and constructed from the `?n=all` payload while this + // `?n=100` window was still in flight. The late window resolution must + // not clobber that full payload back to the bounded window (which would + // diverge `payload` from the rendered datasets, regress the chip, and + // re-arm a redundant pan-triggered refetch). + if (state.fullLoaded) { + return; + } + const normalized = normalizeChartPayload(raw as ChartResponse); + state.payload = normalized; + state.fullLoaded = normalized.history.complete; + if (!normalized.history.complete) { + state.everWindowed = true; + } + this.syncWindowChip(); + this.cb.setLoading(false); + if (this.groupSlug) { + noteGroupSeries(this.groupSlug, normalized.series_meta); + // Classify the empty-window state from this `?n=100` payload (the + // per-chart analogue of the bundle-prime classification). + noteChartRecentData(this.groupSlug, this.slug, chartHasRecentData(normalized)); + } + void this.maybeConstruct(); + }, + (err: unknown) => { + if (state.initialFetchEntry === entry) { + state.initialFetchEntry = null; + } + if (state.initialFetchController === fc) { + state.initialFetchController = null; + } + if (state.disposed) { + return; + } + // A close/destroy cancellation aborts with `AbortError`: stay silent and + // do NOT touch the loading state, which may now belong to a fresh fetch + // scheduled after a reopen (clearing it here would extinguish that + // newer fetch's spinner). A timeout (`TimeoutError`) or a genuine + // network/HTTP failure clears loading and surfaces the error indicator. + if (err instanceof DOMException && err.name === 'AbortError') { + return; + } + this.cb.setLoading(false); + const message = + err instanceof DOMException && err.name === 'TimeoutError' + ? 'timed out' + : err instanceof Error + ? err.message + : 'unknown error'; + this.cb.setError(`failed to load: ${message}`); + this.cb.setRetryable(true); + }, + ); + } + + /** Re-issue the initial `?n=100` fetch after a failure/timeout. User-initiated + * (the error region's retry control), so it is naturally bounded; clears the + * error first and schedules at the top of the hydration queue. + */ + retryInitialPayload(): void { + if (this.state.disposed || this.state.payload) { + return; + } + if (this.groupSlug && this.bundleFailed && retryGroupBundle(this.groupSlug)) { + return; + } + this.cb.setError(null); + this.cb.setRetryable(false); + void this.ensureInitialPayload(0, true).then(() => { + if (this.state.disposed) { + return; + } + void this.maybeConstruct(); + }); + } + + /** + * Hydrate this chart's latest-100 window at `priority` and construct. Full + * history is NOT warmed here; it loads only on explicit per-chart intent + * (window-chip click, hover dwell, or pan/zoom into the unloaded region). The + * priority is the negated visual `index`, so top cards drain ahead of lower + * ones (the queue drains highest-priority-first). + */ + onGroupOpen(priority: number): void { + void this.ensureInitialPayload(priority, true).then(() => { + if (this.state.disposed) { + return; + } + void this.maybeConstruct(); + }); + } + + /** + * Queue the one-shot `?n=all` full-history upgrade (or promote the queued + * entry's priority). Triggered only by explicit intent — window-chip click + * (`INTERACTION_FULL_PRIORITY`), hover dwell (`HOVER_PREFETCH_PRIORITY`), or + * pan/zoom touching the unloaded region — never as an automatic warmup. + */ + ensureFullHistory(priority: number): Promise<void> { + const state = this.state; + // `fullUnavailable` is checked here (not only at the chip/hover call sites) + // so every intent path shares one terminal-404 guard, including the pan/zoom + // `rangeTouchesUnloadedHistory` promotion, which must not re-issue a fetch + // that already 404'd. + if (state.fullLoaded || state.fullUnavailable || state.disposed) { + return Promise.resolve(); + } + if (state.fullFetchEntry) { + if (priority > state.fullFetchEntry.priority) { + state.fullFetchEntry.priority = priority; + fullHistoryQueue.drain(); + } + return state.fullFetchPending ?? Promise.resolve(); + } + const url = `/api/chart/${encodeURIComponent(this.slug)}?n=${encodeURIComponent(FETCH_N)}`; + const fc = new AbortController(); + state.fullFetchController = fc; + const onParentAbort = (): void => fc.abort(this.aborter.signal.reason); + this.aborter.signal.addEventListener('abort', onParentAbort, { once: true }); + if (this.aborter.signal.aborted) { + fc.abort(this.aborter.signal.reason); + } + const entry = fullHistoryQueue.schedule(async () => { + const timer = setTimeout( + () => fc.abort(new DOMException('Fetch timed out', 'TimeoutError')), + FETCH_TIMEOUT_MS, + ); + try { + const r = await fetch(url, { headers: { accept: 'application/json' }, signal: fc.signal }); + if (r.status === 404) { + return null; + } + if (!r.ok) { + throw new Error(`HTTP ${r.status}`); + } + return (await r.json()) as ChartResponse; + } finally { + clearTimeout(timer); + this.aborter.signal.removeEventListener('abort', onParentAbort); + } + }, priority); + state.fullFetchEntry = entry; + state.fullFetchPending = entry.promise + .then((full) => { + if (state.disposed) { + return; + } + if (full === null) { + state.fullUnavailable = true; + return; + } + this.replaceChartPayload(full as ChartResponse); + state.fullLoaded = true; + if (state.fullFetchController === fc) { + state.fullFetchController = null; + } + state.chipError = false; + this.cb.setLoading(false); + if (!state.chart && this.groupIsOpen()) { + void this.maybeConstruct(); + } + }) + .catch((err: unknown) => { + if (state.fullFetchController === fc) { + state.fullFetchController = null; + } + // A close/destroy cancellation is silent; a timeout or genuine failure + // leaves the chip's retry affordance (chipError) so the user can re-try. + if (err instanceof DOMException && err.name === 'AbortError') { + return; + } + // Quiet: the latest-100 payload is still usable. Surface to the console + // for debugging; the chip exposes the retry affordance. + console.warn('bench: full history fetch failed', err); + state.chipError = true; + }) + .then(() => { + if (state.fullFetchEntry === entry) { + state.fullFetchEntry = null; + state.fullFetchController = null; + state.fullFetchPending = null; + } + // Always re-sync the chip so the UI reflects the latest state even when + // the guard above skips the stale entry clears. + this.syncWindowChip(); + }); + this.syncWindowChip(); + return state.fullFetchPending; + } + + /** + * Construct the Chart.js instance when the payload is present and the + * enclosing group (if any) is open. Loads Chart.js lazily on first need. + * Idempotent across overlapping calls via the `constructing` latch. + */ + async maybeConstruct(): Promise<void> { + const state = this.state; + if (state.chart || state.constructing || !state.payload || state.disposed) { + return; + } + // A card hidden as empty is `display: none`; constructing into it would + // yield a zero-size chart. The reveal effect re-enters when the toggle + // shows it (mirroring how the toggle-open path re-enters via onGroupOpen). + if (!this.groupIsOpen() || this.hiddenAsEmpty()) { + return; + } + const { canvas, tooltipHost, card } = this.els(); + if (!canvas || !card) { + return; + } + state.constructing = true; + try { + let Chart: typeof ChartJs; + try { + Chart = await loadChartJs(); + } catch (err) { + // A failed chunk load (deploy rotated the hashed assets, flaky + // network) surfaces like a fetch failure; the loader has already reset + // its cache, and the error indicator's auto-dismiss retries + // construction (bounded by `shouldRetryConstruct`) so charts whose + // only construction trigger already fired (the permalink page's + // one-shot IntersectionObserver) are not left permanently blank. + this.loadAttempts += 1; + if (!state.disposed) { + const message = err instanceof Error ? err.message : 'unknown error'; + this.cb.setError(`failed to load chart library: ${message}`); + } + return; + } + if (state.disposed || state.chart || !state.payload) { + return; + } + // Re-check the disclosure AND the empty-hide AFTER the await: the group + // can close (or the bundle can classify this card empty) while the + // Chart.js chunk loads (a window v3 never had, its library being + // preloaded), and constructing into the display:none grid would render a + // zero-size chart. The next toggle-open re-enters via onGroupOpen; the + // empty-reveal effect re-enters via maybeConstruct. + if (!this.groupIsOpen() || this.hiddenAsEmpty()) { + return; + } + const payload = state.payload; + + // Lock the display unit for the lifetime of this loaded payload; it is + // recomputed only when `replaceChartPayload` swaps in the wider window. + state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); + + const labels = payload.commits.map(commitDateLabel); + const datasets = buildDatasets(payload); + const range = visibleRange(labels.length, state.ui.scope); + const legendPosition = window.matchMedia?.('(max-width: 768px)').matches ? 'top' : 'bottom'; + + // Throttled rebuild for pan/zoom: both axes mutate `scales.x.min/max` + // continuously during interaction, so the rendered points re-derive at + // most every PAN_THROTTLE_MS and the range strip refreshes in the same + // call so LTTB and the strip never diverge. + const throttledRebuild = throttle((chart: ChartJs) => { + const sx = chart.scales?.x; + if (!sx) { + return; + } + this.rebuildVisibleAndUpdate(chart, sx.min, sx.max, true); + state.stripRender?.(); + }, PAN_THROTTLE_MS); + + const chart = new Chart( + canvas, + lineChartConfig({ + Chart, + state, + tooltipHost, + labels, + datasets, + range, + legendPosition, + onZoom: throttledRebuild, + }), + ); + + state.chart = chart; + this.cb.setConstructed(true); + state.rebuild = throttledRebuild; + this.attachWheelPan(canvas, chart, throttledRebuild); + this.syncSliderBounds(labels.length); + // Initial render: populate the null data for the initial window, then + // bind the strip so its first paint reflects the same range. + this.rebuildVisibleAndUpdate(chart, range.min ?? 0, range.max ?? 0, false); + this.bindRangeStrip(chart); + state.stripRender?.(); + // `buildDatasets` seeded `hidden` from the global filter; reapply through + // the layered helper so a per-group filter set before this card hydrated + // also takes effect. + this.applyFilters(); + if (this.groupSlug) { + noteGroupSeries(this.groupSlug, payload.series_meta); + } + } finally { + state.constructing = false; + } + } + + /** + * The single source of truth for the rendered point count, ported verbatim: + * build the per-commit max-across-series union over `[rangeMin, rangeMax]`, + * keep at most [`MAX_VISIBLE_POINTS`] shared commit indices (LTTB above the + * cap), and write the kept values into every `dataset.data` in place. + */ + rebuildVisibleAndUpdate( + chart: ChartJs, + rangeMin: number, + rangeMax: number, + allowFullFetch: boolean, + ): void { + const state = this.state; + // The throttled pan/zoom wrapper preserves its trailing call, which can + // land after teardown; updating a destroyed Chart.js instance throws. + if (state.disposed) { + return; + } + const payload = state.payload; + if (!payload) { + return; + } + const datasets = benchDatasets(chart); + const n = payload.commits.length; + if (n === 0) { + return; + } + + const min = Math.max(0, Math.floor(rangeMin)); + let max = Math.min(n - 1, Math.ceil(rangeMax)); + if (max < min) { + max = min; + } + + // Pick the kept commit indices by downsampling each series INDEPENDENTLY + // (see [`decimateSeries`]). The earlier approach LTTB'd a single + // max-across-series "virtual series" and shared its indices across every + // dataset; a series that never drove that maximum then lost its own points + // once the window exceeded the cap (the "data disappears after loading all" + // bug). The kept set is still capped at [`MAX_VISIBLE_POINTS`] distinct + // x-positions, split across the series with data in view. + const { kept: keptSet, downsampled: anyDownsampled } = decimateSeries( + datasets.map((ds) => ds.rawData), + min, + max, + MAX_VISIBLE_POINTS, + ); + + // Write the kept set into every dataset, scaled by the locked display-unit + // multiplier (applied here, not on ingest or in SQL, so the wire payload + // stays in base units). Values outside `[min, max]` stay null: planting + // off-screen neighbours would blow up the y-axis auto-scale (it reads + // every non-null value regardless of `scales.x.min/max`). + const multiplier = state.displayUnit.multiplier; + for (const ds of datasets) { + const dsRaw = ds.rawData; + if (!Array.isArray(dsRaw)) { + continue; + } + let data = ds.data; + if (!Array.isArray(data) || data.length !== n) { + data = new Array<number | null>(n); + ds.data = data; + } + for (let z = 0; z < n; z++) { + data[z] = null; + } + for (const idx of keptSet) { + const val = dsRaw[idx]; + if (val !== null && val !== undefined && !Number.isNaN(val)) { + data[idx] = val * multiplier; + } + } + } + + const visibleCommits = max - min + 1; + const keptCommits = keptSet.size; + chart.update('none'); + this.syncSliderFromRange(visibleCommits); + this.syncDownsampleBadge(keptCommits, visibleCommits, anyDownsampled); + // If the user moves into the virtual, not-yet-loaded part of the x-axis, + // promote this chart's queued full-history fetch ahead of background work. + if (allowFullFetch && rangeTouchesUnloadedHistory(payload, min, max)) { + void this.ensureFullHistory(INTERACTION_FULL_PRIORITY); + } + } + + /** + * Swap the chart's labels + datasets to the freshly fetched unbounded payload + * while preserving the current x-range. The virtual latest-100 payload and + * the full payload share a full-history x-axis, so the chart does not jump + * when the real older values arrive. + */ + replaceChartPayload(rawPayload: ChartResponse): void { + const state = this.state; + const payload = normalizeChartPayload(rawPayload); + state.payload = payload; + if (this.groupSlug) { + // [`chartHasRecentData`] reads only the trailing latest-100 slots, so the + // full-history payload yields the same verdict as the `?n=100` window; + // recording here (before the chart-null guard) covers the rare race where + // the full upgrade resolves before the initial window fetch, whose own + // handler then early-returns without classifying. + noteChartRecentData(this.groupSlug, this.slug, chartHasRecentData(payload)); + } + const chart = state.chart; + if (!chart) { + return; + } + // Re-pick the display unit against the now-wider window: the refetch may + // surface older commits with a different magnitude, and moving the y-axis + // once at the refetch boundary beats leaving the chart on a stale unit. + state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); + const yAxis = chart.options.scales?.y; + if (yAxis && 'title' in yAxis && yAxis.title) { + yAxis.title.display = state.displayUnit.axisLabel !== ''; + yAxis.title.text = state.displayUnit.axisLabel; + } + const newLabels = payload.commits.map(commitDateLabel); + const newDatasets = buildDatasets(payload); + // Honour any explicit legend toggles the user had made already. + for (const ds of newDatasets) { + if (ds.label && state.overrides[ds.label]) { + const prev = chart.data.datasets.find((p) => p.label === ds.label); + if (prev) { + ds.hidden = Boolean(prev.hidden); + } + } + } + chart.data.labels = newLabels; + chart.data.datasets = newDatasets; + this.applyFilters(); + if (this.groupSlug) { + noteGroupSeries(this.groupSlug, payload.series_meta); + } + const newMaxIdx = Math.max(0, newLabels.length - 1); + const zoomLimits = chart.options.plugins?.zoom?.limits?.x; + if (zoomLimits) { + zoomLimits.max = newMaxIdx; + } + this.syncSliderBounds(newLabels.length); + const sx = chart.options.scales?.x; + if (!sx) { + return; + } + // v3's `replaceChartPayload` carries an unreachable non-finite else branch + // here; the values are coerced finite above, so only the clamp survives. + const prevMin = typeof sx.min === 'number' && Number.isFinite(sx.min) ? sx.min : 0; + const prevMax = typeof sx.max === 'number' && Number.isFinite(sx.max) ? sx.max : 0; + sx.min = Math.max(0, Math.min(newMaxIdx, prevMin)); + sx.max = Math.max(sx.min, Math.min(newMaxIdx, prevMax)); + this.rebuildVisibleAndUpdate(chart, sx.min, sx.max, false); + state.stripRender?.(); + } + + private syncSliderFromRange(visibleCommits: number): void { + setSliderFromRange(this.els().slider, visibleCommits); + } + + private syncDownsampleBadge( + keptCommits: number, + visibleCommits: number, + anyDownsampled: boolean, + ): void { + renderDownsampleBadge(this.els().badge, keptCommits, visibleCommits, anyDownsampled); + } + + private syncWindowChip(): void { + renderWindowChip(this.els().chip, this.state); + } + + /** Window-chip click: load the full history at top priority, or retry after a + * failure. A no-op once full history is loaded or a fetch is already pending. */ + onWindowChipClick(): void { + const state = this.state; + if (state.disposed || state.fullLoaded || state.fullFetchPending || state.fullUnavailable) { + return; + } + state.chipError = false; + void this.ensureFullHistory(INTERACTION_FULL_PRIORITY); + } + + /** Pointer resting on the card: reveal the chip's action immediately and arm + * the dwell-prefetch timer. Only a deliberate dwell (not a sweep) fetches. */ + onCardHoverStart(): void { + const state = this.state; + if (state.disposed) { + return; + } + state.hovering = true; + this.syncWindowChip(); + if ( + state.fullLoaded || + state.fullFetchPending || + state.fullUnavailable || + state.hoverDwellTimer !== null + ) { + return; + } + state.hoverDwellTimer = setTimeout(() => { + state.hoverDwellTimer = null; + if (state.disposed) { + return; + } + void this.ensureFullHistory(HOVER_PREFETCH_PRIORITY); + }, HOVER_DWELL_MS); + } + + /** Pointer left the card: restore the chip label and cancel a pending dwell. */ + onCardHoverEnd(): void { + const state = this.state; + if (state.disposed) { + return; + } + state.hovering = false; + if (state.hoverDwellTimer !== null) { + clearTimeout(state.hoverDwellTimer); + state.hoverDwellTimer = null; + } + this.syncWindowChip(); + } + + private syncSliderBounds(commitCount: number): void { + setSliderBounds(this.els().slider, commitCount); + } + + private attachWheelPan( + canvas: HTMLCanvasElement, + chart: ChartJs, + rebuild: (chart: ChartJs) => void, + ): void { + attachWheelPan(canvas, chart, this.state, rebuild, this.aborter.signal); + } + + private bindRangeStrip(chart: ChartJs): void { + bindRangeStrip( + chart, + this.els(), + this.state, + (c, min, max) => this.rebuildVisibleAndUpdate(c, min, max, true), + this.aborter.signal, + ); + } + + /** + * Apply a toolbar/strip scope change. Preserves the visible center when the + * user has panned away from the right edge (see [`visibleRange`]). + * + * Like [`applyY`], the target scope is recorded BEFORE the chart-null guard + * (a deliberate deviation from v3, where the toolbar only bound after + * construction so pre-construction input was unreachable): a slider drag on + * a not-yet-hydrated card still selects the window construction renders. + */ + applyScope(scopeValue: string): void { + const state = this.state; + const scope = scopeValue === 'all' ? ('all' as const) : parseInt(scopeValue, 10); + state.ui.scope = scope; + const chart = state.chart; + if (!chart) { + return; + } + const commits = (chart.data.labels ?? []).length; + const sx = chart.options.scales?.x; + const currentRange = sx + ? { + min: typeof sx.min === 'number' ? sx.min : undefined, + max: typeof sx.max === 'number' ? sx.max : undefined, + } + : null; + const range = visibleRange(commits, scope, currentRange); + if (sx) { + sx.min = range.min; + sx.max = range.max; + } + this.rebuildVisibleAndUpdate( + chart, + range.min ?? 0, + range.max ?? Math.max(0, commits - 1), + true, + ); + state.stripRender?.(); + } + + /** + * Apply a Y-scale change. `userInitiated` toggles the sticky flag: once the + * user clicks the per-chart Y toolbar, the per-group Y broadcast skips this + * chart so the local click stays honored. + * + * Deliberate deviation from v3: `chart-init.js::applyY` no-ops entirely when + * the chart has not constructed yet, dropping the click; here the sticky flag + * and target scale are recorded BEFORE the chart-null guard so a click on a + * not-yet-hydrated card still applies once the chart constructs. + */ + applyY(yValue: 'linear' | 'log', userInitiated: boolean): void { + const state = this.state; + if (userInitiated) { + state.yUserSet = true; + } + state.ui.y = yValue; + this.cb.setY(yValue); + const chart = state.chart; + if (!chart) { + return; + } + const yAxis = chart.options.scales?.y; + if (yAxis) { + yAxis.type = yValue === 'log' ? 'logarithmic' : 'linear'; + if ('beginAtZero' in yAxis) { + yAxis.beginAtZero = yValue !== 'log'; + } + } + chart.update('none'); + } + + /** Whether the per-group Y broadcast should skip this chart. */ + yIsSticky(): boolean { + return this.state.yUserSet; + } + + /** Whether the error-dismiss timer should retry construction: a payload is + * waiting, no chart exists, and the bounded Chart.js import budget (3 + * attempts) is not exhausted. After the budget, the card stays blank until + * reload, which is v3's behavior for a failed static script. */ + shouldRetryConstruct(): boolean { + const state = this.state; + return ( + !state.disposed && state.chart === null && state.payload !== null && this.loadAttempts < 3 + ); + } + + /** + * Re-evaluate every dataset under the layered filter resolution: per-card + * legend overrides win, then explicit per-group show/hide choices, while the + * global engine/format filter supplies the fallback visibility. + */ + applyFilters(): void { + const state = this.state; + const chart = state.chart; + if (!chart) { + return; + } + const global = getGlobalFilterSnapshot(); + const group = this.groupSlug ? getGroupSnapshot(this.groupSlug) : emptyGroupSnapshot(); + for (const ds of benchDatasets(chart)) { + if (ds.label && state.overrides[ds.label]) { + continue; + } + // `dataset.hidden` directly (not `setDatasetVisibility`) so the legend + // stays in sync; the visibility map is a separate channel. + const globalVisible = seriesPassesFilter(ds.benchMeta, global.active, global.universe); + ds.hidden = !seriesPassesGroupFilter(group, ds.label ?? '', globalVisible); + } + chart.update('none'); + } + + /** Cancel any in-flight `?n=100` / `?n=all` request WITHOUT tearing down the + * controller, so closing a group (or its IO disconnect) frees server capacity + * and stops open/close from piling requests up. A reopen re-issues the fetch. + * The aborts reject the in-flight promises with `AbortError`, which the fetch + * catch paths treat as a silent cancellation. */ + abortInFlightFetches(): void { + this.state.initialFetchController?.abort(); + this.state.fullFetchController?.abort(); + // Drop the entry references so a reopen schedules a FRESH fetch rather than + // joining the now-aborting promise (which resolves to nothing and would leave + // the card blank). The aborted tasks still settle; their handlers' identity- + // guarded clears below no-op once a newer fetch owns these refs. + this.state.initialFetchController = null; + this.state.initialFetchEntry = null; + this.state.fullFetchController = null; + this.state.fullFetchEntry = null; + this.state.fullFetchPending = null; + } + + /** Tear down this controller: destroy the chart, remove every DOM listener + * it attached (wheel, strip pointers), and drop late async results. One-way; + * the mount effect constructs a fresh controller for the next mount. */ + destroy(): void { + this.state.disposed = true; + this.unsubscribeBundleRetry?.(); + this.aborter.abort(new DOMException('chart controller destroyed', 'AbortError')); + if (this.state.hoverDwellTimer !== null) { + clearTimeout(this.state.hoverDwellTimer); + this.state.hoverDwellTimer = null; + } + this.state.chart?.destroy(); + this.state.chart = null; + } +} diff --git a/bench/web/components/chart/datasets.ts b/bench/web/components/chart/datasets.ts new file mode 100644 index 0000000..75a533f --- /dev/null +++ b/bench/web/components/chart/datasets.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Chart as ChartJs } from 'chart.js'; + +import { + seriesOrder, + seriesPassesFilter, + seriesStyle, + type NormalizedChartPayload, +} from '@/lib/chart-format'; +import { getGlobalFilterSnapshot } from '@/lib/chart-store'; + +import type { BenchDataset } from './types'; + +/** Read a chart's datasets with the bench carry-alongs visible to the types. */ +export function benchDatasets(chart: ChartJs): BenchDataset[] { + return chart.data.datasets as BenchDataset[]; +} + +/** + * Build the per-series dataset shells. `data` starts as a full-length + * null-padded array; `rebuildVisibleAndUpdate` fills it in based on the current + * visible range. `rawData` holds a reference to the original payload so the + * tooltip can show raw values regardless of LTTB. + */ +export function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { + const raw = payload.series ?? {}; + const meta = payload.series_meta ?? {}; + const n = payload.commits.length; + const global = getGlobalFilterSnapshot(); + return Object.keys(raw) + .sort() + .map((name) => { + const seriesMeta = meta[name] ?? {}; + const rawValues = Array.isArray(raw[name]) ? raw[name] : []; + // Tier-based style: the named Vortex/Parquet series get vivid, thicker + // lines; everything else is muted and thin (see `seriesStyle`). + const style = seriesStyle(name, seriesMeta); + const coldRandomAccess = name.endsWith(':cold'); + // `data` starts null-padded; `rebuildVisibleAndUpdate` fills the current + // visible window with raw or LTTB-kept values. With `spanGaps: true` the + // line connects across nulls, so a series with partial coverage still + // draws as a continuous trend; markers only appear at non-null indices. + const data = new Array<number | null>(n).fill(null); + return { + label: name, + data, + rawData: rawValues, + borderColor: style.color, + backgroundColor: `${style.color}20`, + borderWidth: style.width, + borderDash: coldRandomAccess ? [6, 4] : undefined, + // Layer the hero series on top: Vortex in front ... arrow at the back, + // datafusion ahead of duckdb. Lower `order` renders on top in Chart.js. + order: seriesOrder(name, seriesMeta), + spanGaps: true, + tension: 0, + pointRadius: 2, + pointHoverRadius: 5, + pointHitRadius: 8, + pointStyle: 'cross', + benchMeta: { engine: seriesMeta.engine, format: seriesMeta.format }, + hidden: !seriesPassesFilter(seriesMeta, global.active, global.universe), + } satisfies BenchDataset; + }); +} diff --git a/bench/web/components/chart/pan-zoom.ts b/bench/web/components/chart/pan-zoom.ts new file mode 100644 index 0000000..12955a5 --- /dev/null +++ b/bench/web/components/chart/pan-zoom.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Chart as ChartJs } from 'chart.js'; + +import { clampRangeWindow } from '@/lib/chart-format'; + +import type { CardElements, CardState } from './types'; + +/** Wheel means horizontal pan (the zoom plugin only offers wheel-zoom), so a + * manual `wheel` listener translates the dominant delta into `chart.pan`. */ +export function attachWheelPan( + canvas: HTMLCanvasElement, + chart: ChartJs, + state: CardState, + rebuild: (chart: ChartJs) => void, + signal: AbortSignal, +): void { + if (state.wheelAttached) { + return; + } + state.wheelAttached = true; + canvas.addEventListener( + 'wheel', + (e: WheelEvent) => { + // Horizontal-wheel-or-shift+wheel pans horizontally; plain vertical + // wheel also pans so trackpad scroll moves through commit history + // without modifier keys. + const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (!dx) { + return; + } + e.preventDefault(); + chart.pan({ x: dx * 0.5 }, undefined, 'none'); + rebuild(chart); + }, + { passive: false, signal }, + ); +} + +/** + * Bind the range scrollbar strip below the canvas: the highlighted window + * matches the chart's visible x-range and can be dragged or edge-resized to + * pan/zoom. Ported verbatim from v3's pointer math. + */ +export function bindRangeStrip( + chart: ChartJs, + els: Pick<CardElements, 'strip' | 'stripWindow'>, + state: CardState, + rebuildRange: (chart: ChartJs, min: number, max: number) => void, + signal: AbortSignal, +): void { + const { strip, stripWindow } = els; + if (!strip || !stripWindow) { + return; + } + + const commitCount = (): number => (chart.data.labels ?? []).length; + + const visibleBounds = (): { min: number; max: number } => { + const n = commitCount(); + if (n <= 0) { + return { min: 0, max: 0 }; + } + const maxIdx = n - 1; + const sx = chart.options.scales?.x ?? {}; + let min = typeof sx.min === 'number' && Number.isFinite(sx.min) ? sx.min : 0; + let max = typeof sx.max === 'number' && Number.isFinite(sx.max) ? sx.max : maxIdx; + min = Math.max(0, Math.min(maxIdx, min)); + max = Math.max(min, Math.min(maxIdx, max)); + return { min, max }; + }; + + const render = (): void => { + const n = commitCount(); + if (n <= 0) { + stripWindow.style.left = '0%'; + stripWindow.style.width = '100%'; + return; + } + const b = visibleBounds(); + const span = Math.max(1, n - 1); + let leftPct = (b.min / span) * 100; + let widthPct = ((b.max - b.min) / span) * 100; + // A minimum visible width keeps the handles grabbable when zoomed in + // tight on a single commit. + if (widthPct < 1.5) { + widthPct = 1.5; + } + if (leftPct + widthPct > 100) { + leftPct = 100 - widthPct; + } + stripWindow.style.left = `${leftPct}%`; + stripWindow.style.width = `${widthPct}%`; + strip.setAttribute('aria-valuenow', String(Math.round(leftPct))); + }; + + const setRange = (rawMin: number, rawMax: number): void => { + const n = commitCount(); + if (n <= 0) { + return; + } + const maxIdx = n - 1; + // Looser than the plugin's `limits.x.minRange = 4`; the strip allows a + // tighter window. `clampRangeWindow` collapses the minimum span to zero on + // a single-commit chart so a bare-track click cannot push `x.max` one slot + // past the only label. + const { min: newMin, max: newMax } = clampRangeWindow(maxIdx, rawMin, rawMax); + const sx = chart.options.scales?.x; + if (!sx) { + return; + } + sx.min = newMin; + sx.max = newMax; + // Track scope so the toolbar slider stays consistent on later drags. + state.ui.scope = Math.round(newMax - newMin + 1); + rebuildRange(chart, newMin, newMax); + render(); + }; + + const pxToIndex = (px: number, trackWidth: number): number => { + const n = commitCount(); + if (n <= 1 || trackWidth <= 0) { + return 0; + } + const pct = Math.max(0, Math.min(1, px / trackWidth)); + return pct * (n - 1); + }; + + let dragState: { + mode: 'pan' | 'resize-left' | 'resize-right'; + rect: DOMRect; + startX: number; + startMin: number; + startMax: number; + pointerId: number; + } | null = null; + + const onPointerDown = (e: PointerEvent): void => { + if (e.button !== undefined && e.button !== 0) { + return; + } + const target = e.target as HTMLElement; + const role = target.getAttribute?.('data-role'); + const rect = strip.getBoundingClientRect(); + let b = visibleBounds(); + const idxAtCursor = pxToIndex(e.clientX - rect.left, rect.width); + + let mode: 'pan' | 'resize-left' | 'resize-right'; + if (role === 'range-handle-left') { + mode = 'resize-left'; + } else if (role === 'range-handle-right') { + mode = 'resize-right'; + } else if (role === 'range-window') { + mode = 'pan'; + } else { + // Click on bare track: jump the window so its center lands at the + // cursor, then begin a pan drag. + const width = b.max - b.min; + const newMin = idxAtCursor - width / 2; + setRange(newMin, newMin + width); + b = visibleBounds(); + mode = 'pan'; + } + dragState = { + mode, + rect, + startX: e.clientX, + startMin: b.min, + startMax: b.max, + pointerId: e.pointerId, + }; + try { + strip.setPointerCapture(e.pointerId); + } catch { + // Pointer capture is best-effort; drag still works without it. + } + e.preventDefault(); + strip.classList.add('chart-range-strip--dragging'); + }; + + const onPointerMove = (e: PointerEvent): void => { + if (!dragState) { + return; + } + const n = commitCount(); + if (n <= 1) { + return; + } + const dxIdx = ((e.clientX - dragState.startX) / Math.max(1, dragState.rect.width)) * (n - 1); + if (dragState.mode === 'pan') { + setRange(dragState.startMin + dxIdx, dragState.startMax + dxIdx); + } else if (dragState.mode === 'resize-left') { + setRange(dragState.startMin + dxIdx, dragState.startMax); + } else { + setRange(dragState.startMin, dragState.startMax + dxIdx); + } + }; + + const onPointerUp = (): void => { + if (!dragState) { + return; + } + try { + strip.releasePointerCapture(dragState.pointerId); + } catch { + // Capture may already be released; nothing to do. + } + dragState = null; + strip.classList.remove('chart-range-strip--dragging'); + }; + + strip.addEventListener('pointerdown', onPointerDown, { signal }); + strip.addEventListener('pointermove', onPointerMove, { signal }); + strip.addEventListener('pointerup', onPointerUp, { signal }); + strip.addEventListener('pointercancel', onPointerUp, { signal }); + + // Expose the strip's render so the toolbar slider, wheel-pan, and the + // throttled LTTB rebuild keep the strip in lockstep without knowing strip + // internals. + state.stripRender = render; + render(); +} diff --git a/bench/web/components/chart/tooltip.ts b/bench/web/components/chart/tooltip.ts new file mode 100644 index 0000000..835879d --- /dev/null +++ b/bench/web/components/chart/tooltip.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Chart as ChartJs, TooltipModel } from 'chart.js'; + +import { + displaySeriesLabel, + escapeHtml, + firstLine, + formatDisplayValue, + IDENTITY_UNIT, + predecessorValue, + shortDate, + shortSha, + truncate, +} from '@/lib/chart-format'; + +import { benchDatasets } from './datasets'; +import type { CardState } from './types'; + +/** + * The external tooltip handler factory, ported verbatim from v3 including the + * flicker fix: the tooltip host is ALWAYS `pointer-events: none` (via CSS); the + * previous v2 implementation flipped it to `auto` when visible and the cursor + * would oscillate between canvas and tooltip at event-loop frequency. Clicks on + * a data point are handled by the chart's `onClick`, so the tooltip itself + * never needs to be interactive. + */ +export function externalTooltipHandler(state: CardState, host: HTMLDivElement | null) { + return (context: { chart: ChartJs; tooltip: TooltipModel<'line'> }) => { + const tt = context.tooltip; + if (!host) { + return; + } + if (tt.opacity === 0) { + host.style.opacity = '0'; + return; + } + + const chart = context.chart; + const firstDp = tt.dataPoints?.[0]; + if (!firstDp) { + host.style.opacity = '0'; + return; + } + // Snap to a single commit: `mode: "nearest"` means `firstDp.dataIndex` is + // the single closest data point to the cursor (skipping nulls). If the + // cursor falls between two LTTB-kept points, exactly one wins. + const idx = firstDp.dataIndex; + const commit = state.payload?.commits?.[idx] ?? null; + const displayUnit = state.displayUnit ?? IDENTITY_UNIT; + + // Build one row per dataset from each series' `rawData` so the tooltip + // shows raw measurements even when LTTB nulled out `dataset.data[idx]`. + // Iterating `chart.data.datasets` directly (instead of `tt.dataPoints`) + // guarantees one row per series at this single commit. + const rowItems = benchDatasets(chart) + .map((ds, dsIndex) => { + const meta = chart.getDatasetMeta(dsIndex); + if (meta?.hidden || ds.hidden) { + return null; + } + const raw = ds.rawData?.[idx]; + if (raw === null || raw === undefined || Number.isNaN(raw)) { + return null; + } + // Per-row delta is `(current - previous) / previous`, where "previous" + // is the chronologically preceding commit per the BAN-pinned `idx - 1` + // oldest-first walk in [`predecessorValue`]. + const prevRaw = predecessorValue(ds.rawData ?? [], idx); + let deltaHtml = ''; + if (prevRaw !== null && prevRaw !== 0) { + const pct = ((raw - prevRaw) / prevRaw) * 100; + const cls = + pct > 0 + ? 'tt-delta tt-delta--worse' + : pct < 0 + ? 'tt-delta tt-delta--better' + : 'tt-delta'; + const sign = pct > 0 ? '+' : ''; + deltaHtml = `<span class="${cls}">${sign}${pct.toFixed(1)}%</span>`; + } + return { label: ds.label ?? '', color: String(ds.borderColor), raw, deltaHtml }; + }) + .filter((r): r is NonNullable<typeof r> => r !== null); + + // Top-to-bottom order matches the visual stack of lines at this x. + rowItems.sort((a, b) => b.raw - a.raw); + + const rows = rowItems + .map( + (r) => + `<div class="tt-row">` + + `<span class="tt-swatch" style="background:${r.color}"></span>` + + `<span class="tt-label">${escapeHtml(displaySeriesLabel(r.label))}</span>` + + `<span class="tt-value">${escapeHtml(formatDisplayValue(r.raw, displayUnit))}</span>` + + r.deltaHtml + + `</div>`, + ) + .join(''); + + // If every series was hidden or had no value at this commit, treat this as + // a no-op hover instead of flashing an empty popup. + if (!rows) { + host.style.opacity = '0'; + return; + } + + const titleHtml = + `<div class="tt-title">` + + `${escapeHtml(shortSha(commit?.sha))} · ${escapeHtml(shortDate(commit?.timestamp))}` + + `</div>`; + + // Short SHA + first-line commit message, truncated. The full URL (or PR + // URL) is wired up via the chart's onClick handler. + const msg = truncate(firstLine(commit?.message ?? ''), 80); + const footerLine = commit?.sha + ? msg + ? `${escapeHtml(shortSha(commit.sha))} · ${escapeHtml(msg)}` + : escapeHtml(shortSha(commit.sha)) + : escapeHtml(msg); + const footerHtml = footerLine + ? `<div class="tt-footer"><div class="tt-msg">${footerLine}</div></div>` + : ''; + + host.innerHTML = `${titleHtml}<div class="tt-rows">${rows}</div>${footerHtml}`; + + // Position the tooltip relative to its container, offset 12px from the + // cursor; flip horizontally if it would overflow. + const canvasRect = context.chart.canvas.getBoundingClientRect(); + const parent = host.parentNode as HTMLElement; + const hostRect = parent.getBoundingClientRect(); + const x = canvasRect.left - hostRect.left + tt.caretX; + const y = canvasRect.top - hostRect.top + tt.caretY; + host.style.opacity = '1'; + host.style.left = `${x}px`; + host.style.top = `${y}px`; + // Measure after the content swap so flipping is correct. + const ttWidth = host.offsetWidth || 0; + const containerWidth = parent.clientWidth || 0; + const flip = x + ttWidth + 24 > containerWidth; + host.style.transform = flip ? 'translate(calc(-100% - 12px), 12px)' : 'translate(12px, 12px)'; + }; +} diff --git a/bench/web/components/chart/types.ts b/bench/web/components/chart/types.ts new file mode 100644 index 0000000..4fe41dd --- /dev/null +++ b/bench/web/components/chart/types.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Chart as ChartJs, ChartDataset } from 'chart.js'; + +import type { DisplayUnit, NormalizedChartPayload } from '@/lib/chart-format'; +import type { QueueEntry } from '@/lib/chart-store'; + +/** A Chart.js line dataset extended with the bench-specific carry-alongs. */ +export interface BenchDataset extends ChartDataset<'line', (number | null)[]> { + /** The unmodified payload values; the tooltip reads these regardless of + * which indices LTTB kept in `data`. */ + rawData: (number | null)[]; + /** Engine/format tag used by the global filter's bulk hide/show. */ + benchMeta: { engine?: string; format?: string }; +} + +/** The per-island mutable state, the port of the v3 `canvas.__bench_*` contract. */ +export interface CardState { + chart: ChartJs | null; + constructing: boolean; + payload: NormalizedChartPayload | null; + ui: { y: 'linear' | 'log'; scope: number | 'all' }; + /** Series labels the user has explicitly legend-toggled on this card. Once + * set, the global/group filters no longer drive that label here. */ + overrides: Record<string, true>; + displayUnit: DisplayUnit; + /** v3 also tracked `__bench_inline_trimmed`; it was write-only there and is + * dropped here. `payload.history.complete` carries the same information. */ + fullLoaded: boolean; + initialFetchEntry: QueueEntry | null; + fullFetchEntry: QueueEntry | null; + fullFetchPending: Promise<void> | null; + /** The in-flight `?n=100` fetch's per-fetch aborter; lets a group close or + * destroy cancel it without aborting the controller-lifetime `aborter`. */ + initialFetchController: AbortController | null; + /** The in-flight `?n=all` fetch's per-fetch aborter; same role as above. */ + fullFetchController: AbortController | null; + /** True once this card has rendered a bounded window (so the chip is shown); + * a chart born complete never sets it and shows no chip. */ + everWindowed: boolean; + /** The most recent full-history fetch failed; the chip offers a retry. */ + chipError: boolean; + /** The pointer is currently resting on this card (chip shows the action). */ + hovering: boolean; + /** Pending hover-dwell prefetch timer; cleared on `pointerleave`/destroy. */ + hoverDwellTimer: ReturnType<typeof setTimeout> | null; + /** A full-history fetch returned 404: there is nothing beyond the window to + * load, so the chip stops offering the action and hovers stop re-fetching. */ + fullUnavailable: boolean; + yUserSet: boolean; + stripRender: (() => void) | null; + rebuild: ((chart: ChartJs) => void) | null; + wheelAttached: boolean; + disposed: boolean; +} + +/** The DOM handles the controller operates on, resolved from refs at call time. */ +export interface CardElements { + card: HTMLElement | null; + canvas: HTMLCanvasElement | null; + tooltipHost: HTMLDivElement | null; + slider: HTMLInputElement | null; + badge: HTMLSpanElement | null; + chip: HTMLButtonElement | null; + strip: HTMLDivElement | null; + stripWindow: HTMLDivElement | null; +} + +/** React-state setters the controller drives (low-frequency UI bits only). */ +export interface CardCallbacks { + setY: (y: 'linear' | 'log') => void; + setLoading: (on: boolean) => void; + setError: (msg: string | null) => void; + /** Show/hide the initial-fetch retry control in the error region. */ + setRetryable: (on: boolean) => void; + /** Flip once the Chart.js instance exists, so the pre-data placeholder hides. */ + setConstructed: (on: boolean) => void; +} From e7858cc976551fdad67f29d34ad410b8ecf2a272 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:32:28 +0100 Subject: [PATCH 27/34] Remove bench helpers that only tests used PALETTE, colorFor, assignStableColors and labelForCommit in lib/chart-format.ts, and commitWindowUrlValue in lib/window.ts, have no importer outside their own tests. Delete them and their six tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/web/lib/chart-format.test.ts | 45 --------------------- bench/web/lib/chart-format.ts | 63 ++---------------------------- bench/web/lib/window.test.ts | 8 ---- bench/web/lib/window.ts | 10 ----- 4 files changed, 4 insertions(+), 122 deletions(-) diff --git a/bench/web/lib/chart-format.test.ts b/bench/web/lib/chart-format.test.ts index 004f7d5..0f4b1b4 100644 --- a/bench/web/lib/chart-format.test.ts +++ b/bench/web/lib/chart-format.test.ts @@ -4,12 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - assignStableColors, canonicalHistory, chartHasRecentData, clampRangeWindow, collectAllValues, - colorFor, commitDateLabel, decimateSeries, displayFormat, @@ -23,12 +21,10 @@ import { IDENTITY_UNIT, INTERACTION_FULL_PRIORITY, LAZY_HYDRATION_ROOT_MARGIN, - labelForCommit, lttbIndices, magnitudeReference, MAX_VISIBLE_POINTS, normalizeChartPayload, - PALETTE, parseFilterCsv, parsePrNumber, pickDisplayUnit, @@ -106,17 +102,6 @@ describe('small formatting helpers', () => { it('escapes the five HTML-significant characters', () => { expect(escapeHtml(`<a href="x">&'`)).toBe('<a href="x">&''); }); - - it('labels commits by short SHA and virtual slots as empty', () => { - expect(labelForCommit(commit('abcdef0123'))).toBe('abcdef0'); - expect(labelForCommit(null)).toBe(''); - }); - - it('cycles the palette', () => { - expect(colorFor(0)).toBe(PALETTE[0]); - expect(colorFor(PALETTE.length)).toBe(PALETTE[0]); - expect(colorFor(3)).toBe(PALETTE[3]); - }); }); describe('seriesStyle', () => { @@ -384,36 +369,6 @@ describe('lttbIndices', () => { }); }); -describe('assignStableColors', () => { - it('colors a fresh series set by sorted first-seen order', () => { - const colors = assignStableColors(new Map(), ['b', 'a', 'c']); - expect(colors.get('a')).toBe(colorFor(0)); - expect(colors.get('b')).toBe(colorFor(1)); - expect(colors.get('c')).toBe(colorFor(2)); - }); - - it('keeps an existing series on its color when ?n=all surfaces an older series', () => { - // The latest-100 payload only has the always-running series. - const windowed = assignStableColors(new Map(), ['duckdb:parquet']); - expect(windowed.get('duckdb:parquet')).toBe(colorFor(0)); - // Loading all data surfaces an older series that SORTS FIRST. Under the old - // index-within-payload coloring `duckdb:parquet` would shift to colorFor(1) - // and `aengine:aformat` (which has no recent data) would steal colorFor(0), - // making the recent points look like they vanished. The existing series must - // instead keep its color and the new one take the next slot. - const all = assignStableColors(windowed, ['aengine:aformat', 'duckdb:parquet']); - expect(all.get('duckdb:parquet')).toBe(colorFor(0)); - expect(all.get('aengine:aformat')).toBe(colorFor(1)); - expect(all.get('aengine:aformat')).not.toBe(all.get('duckdb:parquet')); - }); - - it('is idempotent for an unchanged series set', () => { - const first = assignStableColors(new Map(), ['x', 'y']); - const second = assignStableColors(first, ['x', 'y']); - expect([...second.entries()]).toEqual([...first.entries()]); - }); -}); - describe('decimateSeries', () => { it('keeps every commit with data and reports no downsampling under the cap', () => { const a = [1, 2, null, 4, 5]; diff --git a/bench/web/lib/chart-format.ts b/bench/web/lib/chart-format.ts index fb36315..42da3de 100644 --- a/bench/web/lib/chart-format.ts +++ b/bench/web/lib/chart-format.ts @@ -66,59 +66,6 @@ export const LAZY_HYDRATION_ROOT_MARGIN = '300px 0px'; */ export const MAX_VISIBLE_POINTS = 500; -// --------------------------------------------------------------------------- -// Palette + small formatting helpers. -// --------------------------------------------------------------------------- - -/** The fixed series palette; series are colored by sorted-name index. */ -export const PALETTE = [ - '#2563eb', - '#dc2626', - '#16a34a', - '#ea580c', - '#7c3aed', - '#0891b2', - '#ca8a04', - '#db2777', - '#65a30d', - '#475569', -] as const; - -/** Color for the `i`-th series (wraps around the palette). */ -export function colorFor(i: number): string { - return PALETTE[i % PALETTE.length]; -} - -/** - * Stable per-series colors, keyed by series label. Series are colored by - * FIRST-SEEN order across the lifetime of a chart rather than by their index - * within whichever payload is currently loaded. - * - * Index-within-payload coloring is unstable across a payload reshape: the - * `?n=all` upgrade adds series that only ran on older commits (and so are absent - * from the latest-100 window), and any such series that sorts ahead of an - * existing one shifts every later series onto the next palette slot. That makes - * recent points appear to vanish -- a color that was drawing a recent-data - * series gets reassigned to the newly surfaced, recent-data-less older series. - * - * `prev` carries the colors already assigned for this chart; existing names keep - * their color, and new names (folded in sorted order for determinism) take the - * next palette slots after them. Returns a NEW map so callers can swap it in - * wholesale. - */ -export function assignStableColors( - prev: ReadonlyMap<string, string>, - names: readonly string[], -): Map<string, string> { - const next = new Map(prev); - for (const name of [...names].sort()) { - if (!next.has(name)) { - next.set(name, colorFor(next.size)); - } - } - return next; -} - // --------------------------------------------------------------------------- // Series styling by importance, hand-tuned per `engine:format`. Color carries // the signal and lines stay solid. The palette is mode-independent: every color @@ -247,6 +194,10 @@ export function seriesOrder( return formatRank * 10 + engineRank; } +// --------------------------------------------------------------------------- +// Small formatting helpers. +// --------------------------------------------------------------------------- + /** * The user-facing display name for a format string. Vortex's on-disk format is * stored as `vortex-file-compressed` everywhere in the data (series keys, filter @@ -326,12 +277,6 @@ export function escapeHtml(s: unknown): string { .replace(/'/g, '''); } -/** The x-axis label for one commit slot: the short SHA, or `''` for a virtual - * (not yet loaded) slot. */ -export function labelForCommit(commit: CommitPoint | null | undefined): string { - return commit?.sha ? shortSha(commit.sha) : ''; -} - const MONTH_ABBR = [ 'Jan', 'Feb', diff --git a/bench/web/lib/window.test.ts b/bench/web/lib/window.test.ts index 59b8e86..793a0fd 100644 --- a/bench/web/lib/window.test.ts +++ b/bench/web/lib/window.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest'; import { commitWindowLimit, - commitWindowUrlValue, DEFAULT_COMMIT_WINDOW, MAX_NUMERIC_COMMIT_WINDOW, parseCommitWindow, @@ -53,10 +52,3 @@ describe('commitWindowLimit', () => { expect(commitWindowLimit(parseCommitWindow(undefined))).toBe(100); }); }); - -describe('commitWindowUrlValue', () => { - it('renders the URL value', () => { - expect(commitWindowUrlValue({ kind: 'last', n: 100 })).toBe('100'); - expect(commitWindowUrlValue({ kind: 'all' })).toBe('all'); - }); -}); diff --git a/bench/web/lib/window.ts b/bench/web/lib/window.ts index a7b9951..687e574 100644 --- a/bench/web/lib/window.ts +++ b/bench/web/lib/window.ts @@ -62,13 +62,3 @@ export function parseCommitWindow(raw: string | null | undefined): CommitWindow export function commitWindowLimit(window: CommitWindow): number | null { return window.kind === 'all' ? null : window.n; } - -/** - * Render the window as the value the URL carries (`"100"` / `"all"`). Ported - * ahead of its consumer: v3's `CommitWindow::url_value` is used by the HTML - * toolbar and permalink links, which arrive with the PR-4.4.b client islands. - * Until then this is exercised only by its unit test. - */ -export function commitWindowUrlValue(window: CommitWindow): string { - return window.kind === 'all' ? 'all' : String(window.n); -} From a0198f8a03266380d82c78303fa3a29ae6045c9d Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:45:49 +0100 Subject: [PATCH 28/34] Type-check bench with the site's stricter flags Move noUncheckedIndexedAccess and exactOptionalPropertyTypes from the site's tsconfig.json into tsconfig.base.json, so bench inherits them. The site's effective config does not change. TypeScript 7 reported 162 errors in bench: 41 in app code, 121 in tests. The fixes change types only: - Optional properties that receive undefined get `| undefined`. - An index access gets `!` only where the index is in range by construction. commitDateLabel gets an explicit undefined check that returns '' as before. - Chart.js's types do not let a line chart or its datasets widen to the generic types under exactOptionalPropertyTypes, so three casts at that boundary bridge them. - Tests use `!`, or `?.` where Biome forbids `!` after `?.`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/web/app/page.tsx | 4 +- .../components/Chart.lazy-hydration.test.tsx | 58 ++++++++--------- bench/web/components/Chart.lifecycle.test.tsx | 2 +- bench/web/components/Chart.loading.test.tsx | 20 +++--- bench/web/components/Header.tsx | 2 +- bench/web/components/SummaryCard.tsx | 2 +- bench/web/components/chart/config.ts | 4 +- bench/web/components/chart/controller.ts | 10 ++- bench/web/components/chart/datasets.ts | 4 +- bench/web/components/chart/types.ts | 2 +- bench/web/lib/cache-fill.test.ts | 8 +-- bench/web/lib/chart-format.test.ts | 2 +- bench/web/lib/chart-format.ts | 33 +++++----- bench/web/lib/db.ts | 2 +- bench/web/lib/group-charts.test.ts | 4 +- bench/web/lib/groups.test.ts | 64 +++++++++---------- bench/web/lib/queries.test.ts | 6 +- bench/web/lib/queries.ts | 12 ++-- bench/web/lib/random-access.ts | 2 +- bench/web/lib/schema-version.test.ts | 2 +- bench/web/lib/slug.test.ts | 4 +- bench/web/lib/summary.test.ts | 44 ++++++------- bench/web/lib/test-harness.ts | 2 +- tsconfig.base.json | 2 + tsconfig.json | 2 - 25 files changed, 153 insertions(+), 144 deletions(-) diff --git a/bench/web/app/page.tsx b/bench/web/app/page.tsx index 0f4206e..85f61df 100644 --- a/bench/web/app/page.tsx +++ b/bench/web/app/page.tsx @@ -79,7 +79,7 @@ async function HomeContent({ groups={groups.map((group, i) => ({ name: group.name, slug: group.slug, - anchor: anchors[i], + anchor: anchors[i]!, }))} /> <main> @@ -99,7 +99,7 @@ async function HomeContent({ <GroupSection key={group.slug} group={group} - anchor={anchors[i]} + anchor={anchors[i]!} startIndex={startIndex} universe={universe} initialHiddenSeries={ diff --git a/bench/web/components/Chart.lazy-hydration.test.tsx b/bench/web/components/Chart.lazy-hydration.test.tsx index cd6003f..4caf9a3 100644 --- a/bench/web/components/Chart.lazy-hydration.test.tsx +++ b/bench/web/components/Chart.lazy-hydration.test.tsx @@ -152,8 +152,8 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { it('only intersecting (in-viewport) cards hydrate on group open', async () => { await renderGroup(5); await act(async () => { - MockIO.instances[0].fire(true); - MockIO.instances[1].fire(true); + MockIO.instances[0]!.fire(true); + MockIO.instances[1]!.fire(true); await Promise.resolve(); await Promise.resolve(); }); @@ -164,7 +164,7 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { await renderGroup(5); expect(windowFetchCount()).toBe(0); await act(async () => { - MockIO.instances[4].fire(true); + MockIO.instances[4]!.fire(true); await Promise.resolve(); await Promise.resolve(); }); @@ -175,8 +175,8 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { const scheduleSpy = vi.spyOn(hydrationQueue, 'schedule'); await renderGroup(5); await act(async () => { - MockIO.instances[0].fire(true); - MockIO.instances[3].fire(true); + MockIO.instances[0]!.fire(true); + MockIO.instances[3]!.fire(true); await Promise.resolve(); }); const priorities = scheduleSpy.mock.calls.map((c) => c[1]); @@ -201,7 +201,7 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { await renderGroup(2); // Fire card 0's initial observer so it hydrates before close. await act(async () => { - MockIO.instances[0].fire(true); + MockIO.instances[0]!.fire(true); await Promise.resolve(); await Promise.resolve(); }); @@ -231,7 +231,7 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { // last one to trigger card 1's fetch. const fetchCountBeforeRefire = windowFetchCount(); await act(async () => { - MockIO.instances[MockIO.instances.length - 1].fire(true); + MockIO.instances[MockIO.instances.length - 1]!.fire(true); await Promise.resolve(); await Promise.resolve(); }); @@ -270,7 +270,7 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { // Fire card 0's observer. Its `?n=100` task is queued behind the blockers // (all concurrency slots are taken); no fetch runs yet. await act(async () => { - MockIO.instances[0].fire(true); + MockIO.instances[0]!.fire(true); await Promise.resolve(); }); const scheduledAfterOpen = scheduleSpy.mock.calls.length; @@ -289,7 +289,7 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { await act(async () => { details.open = true; details.dispatchEvent(new Event('toggle')); - MockIO.instances[MockIO.instances.length - 1].fire(true); + MockIO.instances[MockIO.instances.length - 1]!.fire(true); await Promise.resolve(); }); @@ -328,18 +328,18 @@ describe('PR-5.0.95 landing-page lazy hydration', () => { }); await renderGroup(2); await act(async () => { - MockIO.instances[0].fire(true); + MockIO.instances[0]!.fire(true); await Promise.resolve(); }); expect(signals.length).toBe(1); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); const details = container.querySelector('details.group-disclosure') as HTMLDetailsElement; await act(async () => { details.open = false; details.dispatchEvent(new Event('toggle')); await Promise.resolve(); }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); expect(MockIO.instances.every((io) => io.disconnected)).toBe(true); }); }); @@ -448,12 +448,12 @@ describe('PR-5.0.97 group-bundle hydration', () => { }); expect(bundleFetchCount()).toBe(1); await act(async () => { - MockIO.instances[0].fire(true); + MockIO.instances[0]!.fire(true); await Promise.resolve(); }); // Firing the IO disconnects it and drives construction (Chart.js load is a // parked stub here, so the observable construction signal is the disconnect). - expect(MockIO.instances[0].disconnected).toBe(true); + expect(MockIO.instances[0]!.disconnected).toBe(true); expect(chartFetchCount()).toBe(0); }); @@ -483,8 +483,8 @@ describe('PR-5.0.97 group-bundle hydration', () => { }); await renderGroup(3); await act(async () => { - MockIO.instances[0].fire(); - MockIO.instances[1].fire(); + MockIO.instances[0]!.fire(); + MockIO.instances[1]!.fire(); await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); expect(container.querySelectorAll('[data-role="fetch-retry"]')).toHaveLength(2); @@ -493,15 +493,15 @@ describe('PR-5.0.97 group-bundle hydration', () => { // A later card sees the same failure without starting another request. await act(async () => { - MockIO.instances[2].fire(); + MockIO.instances[2]!.fire(); await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS * 2); }); expect(container.querySelectorAll('[data-role="fetch-retry"]')).toHaveLength(3); expect(bundleFetchCount()).toBe(1); const retries = container.querySelectorAll<HTMLButtonElement>('[data-role="fetch-retry"]'); await act(async () => { - retries[0].click(); - retries[1].click(); + retries[0]!.click(); + retries[1]!.click(); }); expect(bundleFetchCount()).toBe(2); expect(chartFetchCount()).toBe(0); @@ -529,14 +529,14 @@ describe('PR-5.0.97 group-bundle hydration', () => { await Promise.resolve(); }); expect(signals.length).toBe(1); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); const details = container.querySelector('details.group-disclosure') as HTMLDetailsElement; await act(async () => { details.open = false; details.dispatchEvent(new Event('toggle')); await Promise.resolve(); }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); }); it('reopen AFTER the bundle succeeded issues ZERO new fetches (cache hit)', async () => { @@ -587,8 +587,8 @@ describe('PR-5.0.97 group-bundle hydration', () => { // The per-chart fallback only fires once a card is intersected (construction // is IO-gated). Fire both observers; only `s1` (uncovered) must refetch. await act(async () => { - MockIO.instances[0].fire(true); - MockIO.instances[1].fire(true); + MockIO.instances[0]!.fire(true); + MockIO.instances[1]!.fire(true); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -614,9 +614,9 @@ describe('PR-5.0.97 group-bundle hydration', () => { expect(bundleFetchCount()).toBe(1); // Every card intersecting after the 404 falls back to its own per-chart fetch. await act(async () => { - MockIO.instances[0].fire(true); - MockIO.instances[1].fire(true); - MockIO.instances[2].fire(true); + MockIO.instances[0]!.fire(true); + MockIO.instances[1]!.fire(true); + MockIO.instances[2]!.fire(true); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -650,7 +650,7 @@ describe('PR-5.0.97 group-bundle hydration', () => { // Fire the card's observer so it calls `ensureInitialPayload` and begins // awaiting the still-pending bundle. await act(async () => { - MockIO.instances[0].fire(true); + MockIO.instances[0]!.fire(true); await Promise.resolve(); }); expect(bundleFetchCount()).toBe(1); @@ -716,8 +716,8 @@ describe('PR-5.0.97 group-bundle hydration', () => { await renderGroup(2); // Let the eager bundle fetch settle as a 404, then let each island fall back. await act(async () => { - MockIO.instances[0].fire(true); - MockIO.instances[1].fire(true); + MockIO.instances[0]!.fire(true); + MockIO.instances[1]!.fire(true); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); diff --git a/bench/web/components/Chart.lifecycle.test.tsx b/bench/web/components/Chart.lifecycle.test.tsx index fcb40c9..4cf1618 100644 --- a/bench/web/components/Chart.lifecycle.test.tsx +++ b/bench/web/components/Chart.lifecycle.test.tsx @@ -104,7 +104,7 @@ describe('Chart island lifecycle (StrictMode effect replay)', () => { ); expect(bundleFetches).toHaveLength(1); expect(signals).toHaveLength(1); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); }); it('replays a pre-existing group-Y override on mount (store outlives mounts)', async () => { diff --git a/bench/web/components/Chart.loading.test.tsx b/bench/web/components/Chart.loading.test.tsx index 84095cd..7722350 100644 --- a/bench/web/components/Chart.loading.test.tsx +++ b/bench/web/components/Chart.loading.test.tsx @@ -390,7 +390,7 @@ describe('Chart opt-in full-history loading', () => { } }); expect(charts).toHaveLength(1); - const stub = charts[0]; + const stub = charts[0]!; expect(stub.options.plugins.tooltip.enabled).toBe(false); expect(stub.options.plugins.zoom.limits.x).toEqual({ min: 0, max: 19, minRange: 4 }); @@ -436,18 +436,18 @@ describe('Chart opt-in full-history loading', () => { const { signals } = stubAbortableWindowFetch(); await renderOpenGroup(); expect(signals.length).toBeGreaterThanOrEqual(1); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); }); it('aborts a stalled window fetch at FETCH_TIMEOUT_MS and shows an error', async () => { vi.useFakeTimers(); const { signals } = stubAbortableWindowFetch(); await renderOpenGroup(); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); const err = container.querySelector('.chart-error'); expect(err).not.toBeNull(); vi.useRealTimers(); @@ -456,12 +456,12 @@ describe('Chart opt-in full-history loading', () => { it('unmount (destroy) aborts an in-flight window fetch', async () => { const { signals } = stubAbortableWindowFetch(); await renderOpenGroup(); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); await act(async () => { root?.unmount(); root = null; }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); }); it('a close/destroy abort is silent (no error indicator)', async () => { @@ -471,7 +471,7 @@ describe('Chart opt-in full-history loading', () => { root?.unmount(); root = null; }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); // destroy sets disposed, so the rejected fetch must not paint an error. expect(container.querySelector('.chart-error')).toBeNull(); }); @@ -515,14 +515,14 @@ describe('Chart opt-in full-history loading', () => { await Promise.resolve(); }); expect(signals.length).toBeGreaterThanOrEqual(1); - expect(signals[0].aborted).toBe(false); + expect(signals[0]!.aborted).toBe(false); // Unmounting destroys the controller; the per-fetch aborter is bridged // from the controller-lifetime aborter so it must fire immediately. await act(async () => { root?.unmount(); root = null; }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); }); it('a stalled full-history fetch times out and the chip offers retry', async () => { @@ -545,7 +545,7 @@ describe('Chart opt-in full-history loading', () => { await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); - expect(signals[0].aborted).toBe(true); + expect(signals[0]!.aborted).toBe(true); expect(chip?.dataset.state).toBe('error'); expect(chip?.textContent).toBe('retry'); vi.useRealTimers(); diff --git a/bench/web/components/Header.tsx b/bench/web/components/Header.tsx index 7921998..d2c8083 100644 --- a/bench/web/components/Header.tsx +++ b/bench/web/components/Header.tsx @@ -36,7 +36,7 @@ export function Header({ initialEngines, initialFormats, }: { - universe?: FilterUniverse; + universe?: FilterUniverse | undefined; initialEngines?: string[] | null; initialFormats?: string[] | null; }) { diff --git a/bench/web/components/SummaryCard.tsx b/bench/web/components/SummaryCard.tsx index 5420cb7..1b1e32a 100644 --- a/bench/web/components/SummaryCard.tsx +++ b/bench/web/components/SummaryCard.tsx @@ -40,7 +40,7 @@ function formatThroughput(valueGbS: number, operation: 'encode' | 'decode'): str * * Returns `null` when there is no summary or the variant has no content. */ -export function SummaryCard({ summary }: { summary?: Summary }) { +export function SummaryCard({ summary }: { summary?: Summary | undefined }) { if (summary === undefined) { return null; } diff --git a/bench/web/components/chart/config.ts b/bench/web/components/chart/config.ts index 9620124..1e511db 100644 --- a/bench/web/components/chart/config.ts +++ b/bench/web/components/chart/config.ts @@ -32,7 +32,7 @@ const crosshairPlugin: Plugin<'line'> = { if (!active || active.length === 0) { return; } - const x = active[0].element.x; + const x = active[0]!.element.x; const ya = chart.scales?.y; if (!ya || !Number.isFinite(x)) { return; @@ -96,7 +96,7 @@ export function lineChartConfig(input: { if (points.length === 0) { return; } - const commit = state.payload?.commits?.[points[0].index]; + const commit = state.payload?.commits?.[points[0]!.index]; if (!commit) { return; } diff --git a/bench/web/components/chart/controller.ts b/bench/web/components/chart/controller.ts index 9c721d8..c0ba447 100644 --- a/bench/web/components/chart/controller.ts +++ b/bench/web/components/chart/controller.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -import type { Chart as ChartJs } from 'chart.js'; +import type { Chart as ChartJs, ChartDataset } from 'chart.js'; import { CHART_FETCH_N, @@ -571,6 +571,8 @@ export class ChartController { state.stripRender?.(); }, PAN_THROTTLE_MS); + // Chart.js's types do not let a line chart widen to `Chart` under + // `exactOptionalPropertyTypes`. The cast changes no value. const chart = new Chart( canvas, lineChartConfig({ @@ -583,7 +585,7 @@ export class ChartController { legendPosition, onZoom: throttledRebuild, }), - ); + ) as ChartJs; state.chart = chart; this.cb.setConstructed(true); @@ -737,7 +739,9 @@ export class ChartController { } } chart.data.labels = newLabels; - chart.data.datasets = newDatasets; + // Line datasets widen to the generic `ChartDataset` only by a cast, as the + // chart does in `maybeConstruct`. The cast changes no value. + chart.data.datasets = newDatasets as ChartDataset[]; this.applyFilters(); if (this.groupSlug) { noteGroupSeries(this.groupSlug, payload.series_meta); diff --git a/bench/web/components/chart/datasets.ts b/bench/web/components/chart/datasets.ts index 75a533f..24be465 100644 --- a/bench/web/components/chart/datasets.ts +++ b/bench/web/components/chart/datasets.ts @@ -50,7 +50,9 @@ export function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { borderColor: style.color, backgroundColor: `${style.color}20`, borderWidth: style.width, - borderDash: coldRandomAccess ? [6, 4] : undefined, + // `undefined` draws a solid line. Chart.js's types forbid an explicit + // `undefined` under `exactOptionalPropertyTypes`; the cast changes no value. + borderDash: (coldRandomAccess ? [6, 4] : undefined) as number[], // Layer the hero series on top: Vortex in front ... arrow at the back, // datafusion ahead of duckdb. Lower `order` renders on top in Chart.js. order: seriesOrder(name, seriesMeta), diff --git a/bench/web/components/chart/types.ts b/bench/web/components/chart/types.ts index 4fe41dd..96d74cb 100644 --- a/bench/web/components/chart/types.ts +++ b/bench/web/components/chart/types.ts @@ -12,7 +12,7 @@ export interface BenchDataset extends ChartDataset<'line', (number | null)[]> { * which indices LTTB kept in `data`. */ rawData: (number | null)[]; /** Engine/format tag used by the global filter's bulk hide/show. */ - benchMeta: { engine?: string; format?: string }; + benchMeta: { engine?: string | undefined; format?: string | undefined }; } /** The per-island mutable state, the port of the v3 `canvas.__bench_*` contract. */ diff --git a/bench/web/lib/cache-fill.test.ts b/bench/web/lib/cache-fill.test.ts index 0cbb3f4..b5985c8 100644 --- a/bench/web/lib/cache-fill.test.ts +++ b/bench/web/lib/cache-fill.test.ts @@ -34,9 +34,9 @@ describe('group cache fill lock', () => { await expect(withGroupCacheFillLock(fill)).resolves.toBe('groups'); expect(fill).toHaveBeenCalledOnce(); - expect(query.mock.calls[0][0]).toContain('pg_try_advisory_lock'); - expect(query.mock.calls[1][0]).toContain('pg_advisory_unlock'); - expect(query.mock.calls[0][1]).toEqual(query.mock.calls[1][1]); + expect(query.mock.calls[0]![0]).toContain('pg_try_advisory_lock'); + expect(query.mock.calls[1]![0]).toContain('pg_advisory_unlock'); + expect(query.mock.calls[0]![1]).toEqual(query.mock.calls[1]![1]); expect(release).toHaveBeenCalledOnce(); }); @@ -63,7 +63,7 @@ describe('group cache fill lock', () => { }), ).rejects.toBe(failure); - expect(query.mock.calls[1][0]).toContain('pg_advisory_unlock'); + expect(query.mock.calls[1]![0]).toContain('pg_advisory_unlock'); expect(release).toHaveBeenCalledOnce(); }); }); diff --git a/bench/web/lib/chart-format.test.ts b/bench/web/lib/chart-format.test.ts index 0f4b1b4..0edbe7d 100644 --- a/bench/web/lib/chart-format.test.ts +++ b/bench/web/lib/chart-format.test.ts @@ -364,7 +364,7 @@ describe('lttbIndices', () => { expect(kept).toContain(50); // Indices are strictly increasing. for (let i = 1; i < kept.length; i++) { - expect(kept[i]).toBeGreaterThan(kept[i - 1]); + expect(kept[i]).toBeGreaterThan(kept[i - 1]!); } }); }); diff --git a/bench/web/lib/chart-format.ts b/bench/web/lib/chart-format.ts index 42da3de..3a4f3c0 100644 --- a/bench/web/lib/chart-format.ts +++ b/bench/web/lib/chart-format.ts @@ -13,7 +13,7 @@ * `lib/chart-store.ts`; the Chart.js wiring lives in `components/Chart.tsx`. */ -import type { ChartHistory, ChartResponse, CommitPoint, SeriesTag, UnitKind } from '@/lib/queries'; +import type { ChartHistory, ChartResponse, CommitPoint, UnitKind } from '@/lib/queries'; // --------------------------------------------------------------------------- // Constants (ported verbatim from `chart-init.js`). @@ -189,8 +189,8 @@ export function seriesOrder( ): number { const { engine, format } = seriesDims(name, meta); const formatRank = - format && format in FORMAT_ORDER ? FORMAT_ORDER[format] : FORMAT_ORDER_FALLBACK; - const engineRank = engine && engine in ENGINE_ORDER ? ENGINE_ORDER[engine] : 0; + format && format in FORMAT_ORDER ? FORMAT_ORDER[format]! : FORMAT_ORDER_FALLBACK; + const engineRank = engine && engine in ENGINE_ORDER ? ENGINE_ORDER[engine]! : 0; return formatRank * 10 + engineRank; } @@ -260,7 +260,7 @@ export function parsePrNumber(message: unknown): string | null { return null; } const m = message.match(/\(#(\d+)\)/); - return m ? m[1] : null; + return m ? m[1]! : null; } /** @@ -305,6 +305,9 @@ export function commitDateLabel(commit: CommitPoint | null | undefined): string return ''; } const [year, month, day] = ts.slice(0, 10).split('-'); + if (month === undefined || day === undefined) { + return ''; + } const monthIdx = Number.parseInt(month, 10) - 1; const dayNum = Number.parseInt(day, 10); if (!(monthIdx >= 0 && monthIdx < 12) || !Number.isFinite(dayNum)) { @@ -413,7 +416,7 @@ export function magnitudeReference(values: readonly (number | null | undefined)[ } sample.sort((a, b) => a - b); const mid = Math.floor(sample.length / 2); - return sample.length % 2 ? sample[mid] : (sample[mid - 1] + sample[mid]) / 2; + return sample.length % 2 ? sample[mid]! : (sample[mid - 1]! + sample[mid]!) / 2; } /** @@ -621,8 +624,8 @@ export function lttbIndices( let ax = 0; let ay = 0; for (let j = nextStart; j < nextEnd; j++) { - ax += xs[j]; - ay += ys[j]; + ax += xs[j]!; + ay += ys[j]!; } ax /= count; ay /= count; @@ -631,12 +634,12 @@ export function lttbIndices( // `(a, avg_next)`. const rangeStart = Math.floor(bi * bucket) + 1; const rangeEnd = Math.floor((bi + 1) * bucket) + 1; - const pax = xs[a]; - const pay = ys[a]; + const pax = xs[a]!; + const pay = ys[a]!; let maxArea = -1; let maxIdx = rangeStart; for (let k = rangeStart; k < rangeEnd; k++) { - const area = Math.abs((pax - ax) * (ys[k] - pay) - (pax - xs[k]) * (ay - pay)) * 0.5; + const area = Math.abs((pax - ax) * (ys[k]! - pay) - (pax - xs[k]!) * (ay - pay)) * 0.5; if (area > maxArea) { maxArea = area; maxIdx = k; @@ -710,7 +713,7 @@ export function decimateSeries( const budget = Math.max(2, Math.floor(maxPoints / perSeries.length)); for (const series of perSeries) { for (const local of lttbIndices(series.idxs, series.vals, budget)) { - kept.add(series.idxs[local]); + kept.add(series.idxs[local]!); } } return { kept, downsampled: true }; @@ -737,7 +740,7 @@ export interface NormalizedChartPayload extends Omit<ChartResponse, 'commits'> { * or derived from full coverage. */ export function canonicalHistory( - payload: Pick<ChartResponse, 'commits'> & { history?: Partial<ChartHistory> | null }, + payload: Pick<ChartResponse, 'commits'> & { history?: Partial<ChartHistory> | null | undefined }, ): ChartHistory { const commits = Array.isArray(payload?.commits) ? payload.commits : []; const history = payload?.history ?? {}; @@ -780,7 +783,7 @@ export function normalizeChartPayload(payload: ChartResponse): NormalizedChartPa const start = history.start_index; const normalizedCommits = new Array<CommitPoint | null>(total).fill(null); for (let ci = 0; ci < commits.length && start + ci < total; ci++) { - normalizedCommits[start + ci] = commits[ci]; + normalizedCommits[start + ci] = commits[ci]!; } const rawSeries = payload.series ?? {}; @@ -789,7 +792,7 @@ export function normalizeChartPayload(payload: ChartResponse): NormalizedChartPa const out = new Array<number | null>(total).fill(null); if (Array.isArray(values)) { for (let vi = 0; vi < values.length && start + vi < total; vi++) { - out[start + vi] = values[vi]; + out[start + vi] = values[vi]!; } } normalizedSeries[name] = out; @@ -968,7 +971,7 @@ export function seedActiveFormats( * have no engine dimension. */ export function seriesPassesFilter( - meta: SeriesTag | undefined, + meta: { engine?: string | undefined; format?: string | undefined } | undefined, active: GlobalFilterState, universe: FilterUniverse, ): boolean { diff --git a/bench/web/lib/db.ts b/bench/web/lib/db.ts index 99a6be4..9c2a80c 100644 --- a/bench/web/lib/db.ts +++ b/bench/web/lib/db.ts @@ -160,7 +160,7 @@ function createPool(config: DbConfig = readConfig()): Pool { // A single pool is shared across warm serverless invocations and survives // Next.js dev HMR module reloads by caching on `globalThis`. -const globalForPool = globalThis as unknown as { __benchDbPool?: Pool }; +const globalForPool = globalThis as unknown as { __benchDbPool?: Pool | undefined }; /** Returns the process-wide singleton connection pool, creating it on first use. */ export function getPool(): Pool { diff --git a/bench/web/lib/group-charts.test.ts b/bench/web/lib/group-charts.test.ts index a5dd76f..3f11193 100644 --- a/bench/web/lib/group-charts.test.ts +++ b/bench/web/lib/group-charts.test.ts @@ -528,7 +528,7 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads at the window edges', () it('gives each random-access and vector-search chart its own window', async () => { const window = parseCommitWindow('4'); - const [ra, rb] = await groupChartPayloads(...OTHER_FAMILIES[0], window); + const [ra, rb] = await groupChartPayloads(...OTHER_FAMILIES[0]!, window); expect(ra?.history).toEqual({ total_commits: 6, start_index: 2, @@ -543,7 +543,7 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads at the window edges', () }); expect(rb?.commits.map((c) => c.sha)).toEqual([D, E, F]); expect(rb?.series).toEqual({ 'f1:hot': [null, 51, 52] }); - const [, tenth, short] = await groupChartPayloads(...OTHER_FAMILIES[1], window); + const [, tenth, short] = await groupChartPayloads(...OTHER_FAMILIES[1]!, window); expect(tenth?.history).toEqual({ total_commits: 4, start_index: 0, diff --git a/bench/web/lib/groups.test.ts b/bench/web/lib/groups.test.ts index a6c66fc..18a9263 100644 --- a/bench/web/lib/groups.test.ts +++ b/bench/web/lib/groups.test.ts @@ -85,9 +85,9 @@ describe.skipIf(!databaseAvailable)( if (summary.type !== 'randomAccess') { throw new Error(`expected randomAccess summary, got ${summary.type}`); } - expect(summary.hotRankings[0].name).toBe('vortex-file-compressed'); - expect(summary.hotRankings[1].name).toBe('parquet'); - expect(summary.hotRankings[2].name).toBe('arrow-ipc'); + expect(summary.hotRankings[0]!.name).toBe('vortex-file-compressed'); + expect(summary.hotRankings[1]!.name).toBe('parquet'); + expect(summary.hotRankings[2]!.name).toBe('arrow-ipc'); expect(summary.coldRankings.map((r) => r.name)).toEqual([ 'vortex-file-compressed', 'parquet', @@ -95,12 +95,12 @@ describe.skipIf(!databaseAvailable)( ]); // The fixture's newest commit has vortex=100_500 and parquet=201_000 on // the one `taxi` chart. Arrow IPC is 301_500 on the same chart. - expect(summary.hotRankings[0].score).toBeCloseTo(1.0, 6); - expect(summary.hotRankings[1].score).toBeCloseTo(1.9999005, 6); - expect(summary.hotRankings[2].score).toBeCloseTo(2.999801, 6); - expect(summary.hotRankings[0].totalRuntime).toBeCloseTo(100_500, 6); - expect(summary.hotRankings[1].totalRuntime).toBeCloseTo(201_000, 6); - expect(summary.hotRankings[2].totalRuntime).toBeCloseTo(301_500, 6); + expect(summary.hotRankings[0]!.score).toBeCloseTo(1.0, 6); + expect(summary.hotRankings[1]!.score).toBeCloseTo(1.9999005, 6); + expect(summary.hotRankings[2]!.score).toBeCloseTo(2.999801, 6); + expect(summary.hotRankings[0]!.totalRuntime).toBeCloseTo(100_500, 6); + expect(summary.hotRankings[1]!.totalRuntime).toBeCloseTo(201_000, 6); + expect(summary.hotRankings[2]!.totalRuntime).toBeCloseTo(301_500, 6); expect(summary.hotRankings.map((r) => [r.measured, r.total])).toEqual([ [1, 1], [1, 1], @@ -119,9 +119,9 @@ describe.skipIf(!databaseAvailable)( } expect(summary.title).toBe('Vector Search Performance'); expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-turboquant']); - expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); + expect(summary.rankings[0]!.score).toBeCloseTo(1.0, 6); // The newest commit's value for the group's single threshold. - expect(summary.rankings[0].totalRuntime).toBeCloseTo(107_000, 6); + expect(summary.rankings[0]!.totalRuntime).toBeCloseTo(107_000, 6); }); it('computes compression rankings with Parquet and Lance baselines', async () => { @@ -193,9 +193,9 @@ describe.skipIf(!databaseAvailable)( if (summary.type !== 'queryBenchmark') { throw new Error(`expected queryBenchmark summary, got ${summary.type}`); } - expect(summary.rankings[0].name).toBe('datafusion:vortex-file-compressed'); - expect(summary.rankings[1].name).toBe('duckdb:parquet'); - expect(summary.rankings[0].score).toBeLessThan(summary.rankings[1].score); + expect(summary.rankings[0]!.name).toBe('datafusion:vortex-file-compressed'); + expect(summary.rankings[1]!.name).toBe('duckdb:parquet'); + expect(summary.rankings[0]!.score).toBeLessThan(summary.rankings[1]!.score); // Exact geomean scores pin the penalty model (not just the ordering). At the // latest commit datafusion has Q1=1_100_000, Q2=700_000 and duckdb has only // Q1=900_000; bestByQuery is {Q1: 900_000, Q2: 700_000}. datafusion scores @@ -203,10 +203,10 @@ describe.skipIf(!databaseAvailable)( // missing Q2 so it takes the penalty max(900_000,300_000)*2 = 1_800_000, // scoring sqrt(1 * (10+1_800_000)/(10+700_000)) = sqrt(1800010/700010). The // penalty is what pushes duckdb (best raw Q1) below datafusion. - expect(summary.rankings[0].score).toBeCloseTo(1.10554, 5); - expect(summary.rankings[1].score).toBeCloseTo(1.60356, 5); + expect(summary.rankings[0]!.score).toBeCloseTo(1.10554, 5); + expect(summary.rankings[1]!.score).toBeCloseTo(1.60356, 5); // datafusion series has Q1 + Q2; duckdb series has Q1 only. - expect(summary.rankings[0].totalRuntime).toBeCloseTo(1_800_000, 6); + expect(summary.rankings[0]!.totalRuntime).toBeCloseTo(1_800_000, 6); expect(JSON.stringify(summary)).toContain('"totalRuntime"'); }); @@ -260,7 +260,7 @@ describe.skipIf(!databaseAvailable)( 'TPC-H benchmark queries on local NVMe storage at SF=1 (~1GB of data)', ); expect(group.charts.map((c) => c.name)).toEqual(['Q1', 'Q2']); - const q1 = group.charts[0]; + const q1 = group.charts[0]!; // The chart link's slug round-trips to the same `/api/chart` slug. expect(q1.slug).toBe( chartKeyToSlug({ @@ -345,7 +345,7 @@ describe.skipIf(!databaseAvailable)( ), 'cached-only random access group', ); - expect(Object.keys(group.charts[0].series).sort()).toEqual([ + expect(Object.keys(group.charts[0]!.series).sort()).toEqual([ 'arrow-ipc:hot', 'parquet:hot', 'vortex-file-compressed:hot', @@ -483,8 +483,8 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg for (const [index, storage] of (['nvme', 's3'] as const).entries()) { const datasets = storage === 'nvme' ? ['taxi', 'taxi/uniform'] : ['taxi-s3', 'taxi-s3/uniform']; - expect(groupKeyFromSlug(groups[index].slug)).toEqual({ k: 'RandomAccessGroup', storage }); - expect(groups[index].charts).toEqual( + expect(groupKeyFromSlug(groups[index]!.slug)).toEqual({ k: 'RandomAccessGroup', storage }); + expect(groups[index]!.charts).toEqual( datasets.map((dataset) => ({ name: dataset, slug: chartKeyToSlug({ k: 'RandomAccess', dataset }), @@ -543,9 +543,9 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg } expect(summary.hotRankings.map((r) => r.name)).toEqual(['vortex-file-compressed', 'lance']); // vortex: cbrt(1100000/350000 * 1 * 1); lance: cbrt(1 * 3 * 3). - expect(summary.hotRankings[0].score).toBeCloseTo(Math.cbrt(1_100_010 / 350_010), 5); - expect(summary.hotRankings[1].score).toBeCloseTo(Math.cbrt((3_000_010 / 1_000_010) ** 2), 5); - expect(summary.hotRankings[0].totalRuntime).toBeCloseTo(3_100_000 / 3, 6); + expect(summary.hotRankings[0]!.score).toBeCloseTo(Math.cbrt(1_100_010 / 350_010), 5); + expect(summary.hotRankings[1]!.score).toBeCloseTo(Math.cbrt((3_000_010 / 1_000_010) ** 2), 5); + expect(summary.hotRankings[0]!.totalRuntime).toBeCloseTo(3_100_000 / 3, 6); }); it('keeps an intermittently benchmarked format at its own latest run', async () => { @@ -642,9 +642,9 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg throw new Error(`expected vectorSearch summary, got ${summary.type}`); } expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-turboquant', 'vortex-flat']); - expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); + expect(summary.rankings[0]!.score).toBeCloseTo(1.0, 6); // sqrt((2010/1010) * (8010/2010)). - expect(summary.rankings[1].score).toBeCloseTo(Math.sqrt((2010 / 1010) * (8010 / 2010)), 6); + expect(summary.rankings[1]!.score).toBeCloseTo(Math.sqrt((2010 / 1010) * (8010 / 2010)), 6); }); it('keeps a sub-second latest commit timestamp (no whole-second truncation)', async () => { @@ -683,8 +683,8 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg 'vortex-file-compressed', 'parquet', ]); - expect(size.rankings[0].ratio).toBeCloseTo(0.25, 6); - expect(size.rankings[1].ratio).toBeCloseTo(1.0, 6); + expect(size.rankings[0]!.ratio).toBeCloseTo(0.25, 6); + expect(size.rankings[1]!.ratio).toBeCloseTo(1.0, 6); }); it('selects one commit when the latest timestamps collide', async () => { @@ -750,7 +750,7 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg 'encode:vortex-file-compressed', 'encode:parquet', ]); - expect(summary.rankings[0].ratio).toBeCloseTo(3.0, 6); + expect(summary.rankings[0]!.ratio).toBeCloseTo(3.0, 6); }); it('falls back to the latest decode timestamp when there is no encode pair', async () => { @@ -769,7 +769,7 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg 'decode:vortex-file-compressed', 'decode:parquet', ]); - expect(summary.rankings[0].ratio).toBeCloseTo(2.0, 6); + expect(summary.rankings[0]!.ratio).toBeCloseTo(2.0, 6); }); it('applies the 300_000 penalty floor so a missing query flips the ranking', async () => { @@ -805,8 +805,8 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg 'datafusion:vortex-file-compressed', 'duckdb:parquet', ]); - expect(summary.rankings[0].score).toBeCloseTo(1.414143, 5); - expect(summary.rankings[1].score).toBeCloseTo(1.732022, 5); + expect(summary.rankings[0]!.score).toBeCloseTo(1.414143, 5); + expect(summary.rankings[1]!.score).toBeCloseTo(1.732022, 5); }); // A cold group fill runs one chart query per chart. pg fails a query that diff --git a/bench/web/lib/queries.test.ts b/bench/web/lib/queries.test.ts index 8a4ea81..3c8b883 100644 --- a/bench/web/lib/queries.test.ts +++ b/bench/web/lib/queries.test.ts @@ -145,7 +145,7 @@ describe.skipIf(!databaseAvailable)('chartPayload (testcontainers Postgres)', () expect(compTime?.series_meta?.['vortex-file-compressed:encode']).toEqual({ format: 'vortex-file-compressed', }); - expect(compTime?.series_meta?.['vortex-file-compressed:encode'].engine).toBeUndefined(); + expect(compTime?.series_meta?.['vortex-file-compressed:encode']?.engine).toBeUndefined(); }); it('omits series_meta entirely for vector-search charts (no engine/format)', async () => { @@ -175,7 +175,7 @@ describe.skipIf(!databaseAvailable)('chartPayload (testcontainers Postgres)', () const one = await chartPayload(QUERY_Q1, parseCommitWindow('1')); expect(one?.commits).toHaveLength(1); // ?n keeps the most recent commit (oldest-first array, so the last one). - expect(one?.commits[0].sha).toBe('3'.repeat(40)); + expect(one?.commits[0]?.sha).toBe('3'.repeat(40)); const all = await chartPayload(QUERY_Q1, parseCommitWindow('all')); expect(all?.commits).toHaveLength(3); @@ -313,7 +313,7 @@ describe.skipIf(!databaseAvailable)('chartPayload (testcontainers Postgres)', () }); expect(ra?.series_meta?.['parquet:hot']).toEqual({ format: 'parquet' }); expect(ra?.series_meta?.['parquet:cold']).toEqual({ format: 'parquet' }); - expect(ra?.series_meta?.['parquet:hot'].engine).toBeUndefined(); + expect(ra?.series_meta?.['parquet:hot']?.engine).toBeUndefined(); expect(ra?.series['parquet:cold']).toEqual([10_000, 1_010_000, 2_010_000]); const cs = await chartPayload( diff --git a/bench/web/lib/queries.ts b/bench/web/lib/queries.ts index bedfc37..c221281 100644 --- a/bench/web/lib/queries.ts +++ b/bench/web/lib/queries.ts @@ -279,7 +279,7 @@ async function seededCommitsInWindow( ORDER BY timestamp ASC, commit_sha ASC `; const rows = (await getPool().query<SeededCommitRow>(text, params.values)).rows; - const totalCommits = rows.length > 0 ? rows[0].total_commits : 0; + const totalCommits = rows.length > 0 ? rows[0]!.total_commits : 0; const commits: CommitPoint[] = rows.map((row) => ({ sha: row.commit_sha, timestamp: row.ts_text, @@ -1056,18 +1056,18 @@ function payloadsFromRows( for (const row of rows) { totals[row.ord - 1] = row.total_commits; if (row.commit_sha !== null) { - rowsByChart[row.ord - 1].push(row); + rowsByChart[row.ord - 1]!.push(row); } } return charts.map((key, i) => { - const total = totals[i]; + const total = totals[i]!; if (total === 0) { return null; } const history = historyFor(total, window); const acc = new SeriesAccumulator(); acc.seedCommits(commits.slice(commits.length - history.loaded_commits)); - for (const row of rowsByChart[i]) { + for (const row of rowsByChart[i]!) { recordRow(acc, key.k, row); } return acc.finish(chartDisplayName(key), chartUnitKind(key), history); @@ -1581,7 +1581,7 @@ async function mapWithConcurrency<T, R>( if (i >= items.length) { return; } - results[i] = await fn(items[i], i); + results[i] = await fn(items[i]!, i); } }); await Promise.all(workers); @@ -1650,7 +1650,7 @@ export async function collectGroupCharts( ); const charts: NamedChartResponse[] = []; group.charts.forEach((link, i) => { - const chart = payloads[i]; + const chart = payloads[i]!; if (chart !== null) { charts.push({ name: link.name, slug: link.slug, ...chart }); } diff --git a/bench/web/lib/random-access.ts b/bench/web/lib/random-access.ts index 20c7526..9e1eaf3 100644 --- a/bench/web/lib/random-access.ts +++ b/bench/web/lib/random-access.ts @@ -8,5 +8,5 @@ export type RandomAccessStorage = 'nvme' | 's3'; * `taxi-s3`), since random_access_times has no separate storage dimension. */ export function randomAccessStorage(dataset: string): RandomAccessStorage { - return dataset.split('/')[0].endsWith('-s3') ? 's3' : 'nvme'; + return dataset.split('/')[0]!.endsWith('-s3') ? 's3' : 'nvme'; } diff --git a/bench/web/lib/schema-version.test.ts b/bench/web/lib/schema-version.test.ts index f79bb47..d7c8074 100644 --- a/bench/web/lib/schema-version.test.ts +++ b/bench/web/lib/schema-version.test.ts @@ -33,7 +33,7 @@ function versionFromFile(relPath: string, regex: RegExp, label: string): number `moved — update this consistency check and CONTRACT.md together.`, ); } - return Number.parseInt(match[1], 10); + return Number.parseInt(match[1]!, 10); } describe('SCHEMA_VERSION', () => { diff --git a/bench/web/lib/slug.test.ts b/bench/web/lib/slug.test.ts index 5a4c3c2..c7f9717 100644 --- a/bench/web/lib/slug.test.ts +++ b/bench/web/lib/slug.test.ts @@ -116,7 +116,7 @@ describe('canonical slug encoding', () => { query_idx: 7, }).split('.'); expect(prefix).toBe('qm'); - expect(Buffer.from(payload, 'base64url').toString('utf8')).toBe( + expect(Buffer.from(payload!, 'base64url').toString('utf8')).toBe( '{"k":"QueryMeasurement","dataset":"tpch","dataset_variant":null,"scale_factor":"1","storage":"nvme","query_idx":7}', ); }); @@ -129,7 +129,7 @@ describe('canonical slug encoding', () => { scale_factor: '1', storage: 'nvme', }).split('.')[1]; - expect(Buffer.from(payload, 'base64url').toString('utf8')).toBe( + expect(Buffer.from(payload!, 'base64url').toString('utf8')).toBe( '{"k":"QueryGroup","dataset":"tpch","dataset_variant":null,"scale_factor":"1","storage":"nvme"}', ); }); diff --git a/bench/web/lib/summary.test.ts b/bench/web/lib/summary.test.ts index 4b5ee39..d92f3a6 100644 --- a/bench/web/lib/summary.test.ts +++ b/bench/web/lib/summary.test.ts @@ -96,7 +96,7 @@ describe('compression summaries', () => { if (summary === null || summary.type !== 'compression') { throw new Error('expected a compression summary'); } - const ranking = summary.rankings[0]; + const ranking = summary.rankings[0]!; expect(ranking.ratio).toBeCloseTo(1, 6); expect(ranking.throughputGbS).toBeCloseTo(7.5, 6); @@ -159,8 +159,8 @@ describe('compression summaries', () => { 'arrow-ipc', ]; const calls = query.mock.calls as Array<[string, unknown[]]>; - expect(calls[0][1]).toEqual(timingParams); - expect(calls[1][1]).toEqual(sizeParams); + expect(calls[0]![1]).toEqual(timingParams); + expect(calls[1]![1]).toEqual(sizeParams); for (const [text] of calls) { expect(text).toContain('format = ANY($1::text[])'); expect(text).toContain('format = ANY($2::text[])'); @@ -175,19 +175,19 @@ describe('compression summaries', () => { // another hard-coded format branch or a per-dataset history scan. expect(text).not.toContain("format = 'lance'"); } - expect(calls[0][0]).toContain('latest_uncompressed_sizes'); + expect(calls[0]![0]).toContain('latest_uncompressed_sizes'); for (const [text] of calls) { // Read the column directly: `to_jsonb(s)` serialized every row (a // whole-table CPU cost on every summary) just to reach one field. expect(text).toMatch(/\bs\.uncompressed_bytes\b/); expect(text).not.toContain('to_jsonb('); } - expect(calls[0][0]).toContain('LEFT JOIN latest_uncompressed_sizes'); - expect(calls[1][0]).toContain('latest_uncompressed_sizes'); - expect(calls[1][0]).toContain('latest_arrow_ipc'); - expect(calls[1][0]).toContain('selected_with_arrow_ipc'); - expect(calls[1][0]).toContain('LEFT JOIN latest_uncompressed_sizes'); - expect(calls[1][0]).toContain( + expect(calls[0]![0]).toContain('LEFT JOIN latest_uncompressed_sizes'); + expect(calls[1]![0]).toContain('latest_uncompressed_sizes'); + expect(calls[1]![0]).toContain('latest_arrow_ipc'); + expect(calls[1]![0]).toContain('selected_with_arrow_ipc'); + expect(calls[1]![0]).toContain('LEFT JOIN latest_uncompressed_sizes'); + expect(calls[1]![0]).toContain( 'COALESCE(uncompressed.uncompressed_bytes, selected.uncompressed_bytes)', ); }); @@ -250,9 +250,9 @@ describe('timing summaries (shared ranking model)', () => { throw new Error('expected a randomAccess summary'); } expect(summary.hotRankings.map((r) => r.name)).toEqual(['vortex', 'lance']); - expect(summary.hotRankings[0].score).toBeCloseTo(Math.cbrt(1_100_010 / 350_010), 6); - expect(summary.hotRankings[1].score).toBeCloseTo(Math.cbrt((3_000_010 / 1_000_010) ** 2), 6); - expect(summary.hotRankings[0].totalRuntime).toBeCloseTo(3_100_000 / 3, 6); + expect(summary.hotRankings[0]!.score).toBeCloseTo(Math.cbrt(1_100_010 / 350_010), 6); + expect(summary.hotRankings[1]!.score).toBeCloseTo(Math.cbrt((3_000_010 / 1_000_010) ** 2), 6); + expect(summary.hotRankings[0]!.totalRuntime).toBeCloseTo(3_100_000 / 3, 6); expect(summary.hotRankings.map((r) => r.measured)).toEqual([3, 3]); expect(summary.hotRankings.map((r) => r.total)).toEqual([3, 3]); expect(summary.coldRankings).toEqual([]); @@ -406,8 +406,8 @@ describe('timing summaries (shared ranking model)', () => { ['lance', 1, 2, 100_000], ['vortex', 1, 2, 200_000], ]); - expect(summary.hotRankings[0].score).toBeCloseTo(Math.sqrt(2), 6); - expect(summary.hotRankings[1].score).toBeCloseTo(Math.sqrt(2 * (200_010 / 100_010)), 6); + expect(summary.hotRankings[0]!.score).toBeCloseTo(Math.sqrt(2), 6); + expect(summary.hotRankings[1]!.score).toBeCloseTo(Math.sqrt(2 * (200_010 / 100_010)), 6); expect( summary.coldRankings.map((ranking) => [ ranking.name, @@ -419,8 +419,8 @@ describe('timing summaries (shared ranking model)', () => { ['vortex', 2, 3, 200_000], ['lance', 2, 3, 300_000], ]); - expect(summary.coldRankings[0].score).toBeCloseTo(Math.cbrt(2), 6); - expect(summary.coldRankings[1].score).toBeCloseTo(Math.cbrt(2 * (400_010 / 200_010)), 6); + expect(summary.coldRankings[0]!.score).toBeCloseTo(Math.cbrt(2), 6); + expect(summary.coldRankings[1]!.score).toBeCloseTo(Math.cbrt(2 * (400_010 / 200_010)), 6); }); it('reports zero coverage when no random-access dataset is complete', async () => { @@ -483,8 +483,8 @@ describe('timing summaries (shared ranking model)', () => { expect(summary.hotRankings.map((ranking) => ranking.name)).toEqual(['vortex', 'parquet']); expect(summary.coldRankings.map((ranking) => ranking.name)).toEqual(['parquet', 'vortex']); - expect(summary.hotRankings[0].totalRuntime).toBe(100_000); - expect(summary.coldRankings[0].totalRuntime).toBe(300_000); + expect(summary.hotRankings[0]!.totalRuntime).toBe(100_000); + expect(summary.coldRankings[0]!.totalRuntime).toBe(300_000); }); it('summarizes a vector-search group across its thresholds', async () => { @@ -507,9 +507,9 @@ describe('timing summaries (shared ranking model)', () => { } expect(summary.title).toBe('Vector Search Performance'); expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-turboquant', 'vortex-flat']); - expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); - expect(summary.rankings[1].score).toBeCloseTo(Math.sqrt((2010 / 1010) * (8010 / 2010)), 6); - expect(summary.rankings[1].totalRuntime).toBeCloseTo(10_000, 6); + expect(summary.rankings[0]!.score).toBeCloseTo(1.0, 6); + expect(summary.rankings[1]!.score).toBeCloseTo(Math.sqrt((2010 / 1010) * (8010 / 2010)), 6); + expect(summary.rankings[1]!.totalRuntime).toBeCloseTo(10_000, 6); }); it.each([ diff --git a/bench/web/lib/test-harness.ts b/bench/web/lib/test-harness.ts index 2463f8b..ceba525 100644 --- a/bench/web/lib/test-harness.ts +++ b/bench/web/lib/test-harness.ts @@ -268,7 +268,7 @@ export async function seedChartFixture(pool: Pool): Promise<void> { ); } for (let i = 0; i < COMMITS.length; i += 1) { - const sha = COMMITS[i][0]; + const sha = COMMITS[i]![0]; const bias = i * 50_000; // query_measurements: Q1 has two engine/format series, Q2 has one. const qm: ReadonlyArray<readonly [number, string, string, number]> = [ diff --git a/tsconfig.base.json b/tsconfig.base.json index 44ca102..75b3e0f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,6 +10,8 @@ "moduleResolution": "bundler", "noUnusedLocals": true, "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", diff --git a/tsconfig.json b/tsconfig.json index 4231e45..9db3d7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,6 @@ "alwaysStrict": true, "target": "ES2022", "forceConsistentCasingInFileNames": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, "paths": { "@/*": ["./src/*"], "#site/content": ["./.velite"] From 31e5d0f7a13649d3fa27ebfa28c369f99f7e99f2 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:46:51 +0100 Subject: [PATCH 29/34] Point bench docs at the split modules lib/chart-store.ts is now lib/chart-store/, and the Chart.js wiring left components/Chart.tsx for components/chart/. Fix the links and file references in AGENTS.md, performance.md (two links were broken) and design-decisions.md, and the chart-format.ts header. Two moved comments named the wrong place: config.ts now names pan-zoom.ts for the wheel listener, and card-dom.ts names renderDownsampleBadge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/AGENTS.md | 9 +++++---- bench/docs/architecture/design-decisions.md | 6 +++--- bench/docs/architecture/performance.md | 7 ++++--- bench/web/components/chart/card-dom.ts | 2 +- bench/web/components/chart/config.ts | 8 ++++---- bench/web/lib/chart-format.ts | 2 +- 6 files changed, 18 insertions(+), 16 deletions(-) diff --git a/bench/AGENTS.md b/bench/AGENTS.md index c30e73f..032024a 100644 --- a/bench/AGENTS.md +++ b/bench/AGENTS.md @@ -39,9 +39,9 @@ rules below are the parts that were worth keeping. LTTB on the visible commit range only. - **Don't refetch on every scope change.** Once a chart's payload is in memory, pan/zoom/slider/range-strip all rebuild in place via the in-memory LTTB pass on the cached - payload ([`web/components/Chart.tsx`](web/components/Chart.tsx)). The single exception is the - latest-100 to full-history zoom-out path: when the user first zooms past the materialized - window, the chart lazy-fetches `?n=all` once and replaces the payload in place. + payload ([`web/components/chart/controller.ts`](web/components/chart/controller.ts)). The single + exception is the latest-100 to full-history zoom-out path: when the user first zooms past the + materialized window, the chart lazy-fetches `?n=all` once and replaces the payload in place. - **Group docs links mirror the monorepo's `Benchmark::doc_path`.** [`web/lib/benchmark-docs.ts`](web/lib/benchmark-docs.ts) maps each SQL dataset name to the benchmark's explainer doc in `vortex-data/vortex`, which is the same path that the monorepo's PR @@ -71,7 +71,8 @@ rules below are the parts that were worth keeping. ## Footguns we have already hit -All in [`web/components/Chart.tsx`](web/components/Chart.tsx): +All in the chart island, [`web/components/Chart.tsx`](web/components/Chart.tsx) and the modules in +[`web/components/chart/`](web/components/chart/): - **Reverse predecessor walk in the tooltip.** `payload.commits[]` is sorted oldest-first by SQL - `commits[0]` is the oldest, `commits[N-1]` diff --git a/bench/docs/architecture/design-decisions.md b/bench/docs/architecture/design-decisions.md index 449e4b0..29f93ec 100644 --- a/bench/docs/architecture/design-decisions.md +++ b/bench/docs/architecture/design-decisions.md @@ -159,7 +159,7 @@ v3 auto-queued a background `?n=all` fetch for every chart on group open; on a the windowed fetches a user actually waits on. v4 removed the auto-warmup: full history loads only on a deliberate per-chart signal — a window-chip click, a ~1000ms hover dwell, or a pan/zoom into the unloaded region. -→ `web/components/Chart.tsx`, `web/lib/chart-format.ts`. +→ `web/components/chart/controller.ts`, `web/lib/chart-format.ts`. **Bounded windows render on the full-length virtual x-axis.** Loading a small window and then "more" normally re-bases the Chart.js x-axis @@ -181,7 +181,7 @@ Each fetch carries an `AbortController` and a 30s timeout; closing/reopening a group cancels its in-flight fetches rather than piling load on the server, and a stall aborts instead of spinning forever. 30s is headroom over the ~7.8s cold first-hit, so a slow-but-live request is not falsely killed. -→ `web/components/Chart.tsx`, `web/lib/chart-store.ts`. +→ `web/components/chart/controller.ts`, `web/lib/chart-store/bundles.ts`. **Expand All loads one bundle per group, not N per-chart requests.** The viewport-gated per-chart path loads only what is on screen — the opposite of @@ -190,7 +190,7 @@ bundle into a session-lifetime payload cache, priming every chart with a single request; the `IntersectionObserver` still gates the CPU-bound Chart.js construction. The cache is per-session by design (a close/reopen costs zero fetches). -→ `web/lib/chart-store.ts` (`ensureGroupBundle`). +→ `web/lib/chart-store/bundles.ts` (`ensureGroupBundle`). **A warmer cron keeps the function and its DB connections hot.** Caches handle repeat reads but not the first read after idle. A Vercel-native diff --git a/bench/docs/architecture/performance.md b/bench/docs/architecture/performance.md index 147dc9f..e5cd3a1 100644 --- a/bench/docs/architecture/performance.md +++ b/bench/docs/architecture/performance.md @@ -169,8 +169,9 @@ becomes the bottleneck. The server makes a single payload cheap; the client decides *when and how many* to ask for. The pure helpers below live in [`web/lib/chart-format.ts`](../../web/lib/chart-format.ts); the fetch queues are -in [`web/lib/chart-store.ts`](../../web/lib/chart-store.ts) and the Chart.js -wiring in [`web/components/Chart.tsx`](../../web/components/Chart.tsx). +in [`web/lib/chart-store/queues.ts`](../../web/lib/chart-store/queues.ts), the +chart island in [`web/components/Chart.tsx`](../../web/components/Chart.tsx) and +the Chart.js wiring in [`web/components/chart/`](../../web/components/chart/). ### Lazy hydration, top first @@ -229,7 +230,7 @@ chip's retry re-issues the *fetch* (an earlier version only retried chart "Expand All" should load everything quickly, but the per-chart, viewport-gated path does the opposite — it loads only what is on screen. So a toggle-open kicks a single `GET /api/group/{slug}?n=100` **bundle** fetch -(`ensureGroupBundle`, [`web/lib/chart-store.ts`](../../web/lib/chart-store.ts)) +(`ensureGroupBundle`, [`web/lib/chart-store/bundles.ts`](../../web/lib/chart-store/bundles.ts)) into a session-lifetime `payloadCache`, priming every chart in the group with one request (~150–300 KB gzipped) instead of dozens. The `IntersectionObserver` still gates the *Chart.js construction* (the actual CPU cost), and bundles run at diff --git a/bench/web/components/chart/card-dom.ts b/bench/web/components/chart/card-dom.ts index 3cedf14..05e47a6 100644 --- a/bench/web/components/chart/card-dom.ts +++ b/bench/web/components/chart/card-dom.ts @@ -48,7 +48,7 @@ export function renderDownsampleBadge( } /** Render the per-card window chip from controller state. Imperative, like - * `syncDownsampleBadge`: the chip is hidden for charts born complete, and + * `renderDownsampleBadge`: the chip is hidden for charts born complete, and * otherwise reflects windowed → loading → complete, with an error → retry * path and a hover-revealed "load all N" action. */ export function renderWindowChip(chip: HTMLButtonElement | null, state: CardState): void { diff --git a/bench/web/components/chart/config.ts b/bench/web/components/chart/config.ts index 1e511db..c98b589 100644 --- a/bench/web/components/chart/config.ts +++ b/bench/web/components/chart/config.ts @@ -170,10 +170,10 @@ export function lineChartConfig(input: { external: externalTooltipHandler(state, tooltipHost), }, // A mouse drag draws a selection rectangle and zooms to it (the v3 - // behavior), never pans. Panning is reserved for the wheel (manual - // listener below) and the range strip, so a plain drag highlights a - // region without sliding the axes. Wheel-zoom is disabled because - // wheel means PAN here. + // behavior), never pans. Panning is reserved for the wheel (the manual + // listener in `pan-zoom.ts`) and the range strip, so a plain drag + // highlights a region without sliding the axes. Wheel-zoom is disabled + // because wheel means PAN here. zoom: { zoom: { wheel: { enabled: false }, diff --git a/bench/web/lib/chart-format.ts b/bench/web/lib/chart-format.ts index 3a4f3c0..3899962 100644 --- a/bench/web/lib/chart-format.ts +++ b/bench/web/lib/chart-format.ts @@ -10,7 +10,7 @@ * Everything here is pure (no DOM, no module state), so it is importable from * server components, client components, and node-environment vitest alike. The * stateful counterparts (fetch queues, filter stores) live in - * `lib/chart-store.ts`; the Chart.js wiring lives in `components/Chart.tsx`. + * `lib/chart-store/`; the Chart.js wiring lives in `components/chart/`. */ import type { ChartHistory, ChartResponse, CommitPoint, UnitKind } from '@/lib/queries'; From a2f133e02d0b0907990a50d217d64b1d3a06cada Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 02:55:53 +0100 Subject: [PATCH 30/34] Keep null visible in two bench index reads payloads[i]! in collectGroupCharts and values[vi]! in normalizeChartPayload removed null from the type as well as undefined. A chart with no rows is null, so the null check that follows looked dead to the compiler. Cast to the element type instead; this removes only undefined. Also state the parallel-length precondition of lttbIndices, pin the undefined-month and undefined-day paths of commitDateLabel with a test, and correct CLAUDE.md: the two strict flags now live in tsconfig.base.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- CLAUDE.md | 2 +- bench/web/lib/chart-format.test.ts | 9 +++++++++ bench/web/lib/chart-format.ts | 7 ++++--- bench/web/lib/queries.ts | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0859703..0fc243e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ The repository holds two independent apps: They share one Bun workspace and the CI policy, and deploy separately: -- **Toolchain.** `bench/web` is a member of the root Bun workspace: one `bun.lock`, one `bunfig.toml`, one `bun install` at the root. Bun's isolated linker gives each package its own dependencies; both apps run Next.js 16.4.1, React 19.3 and TypeScript 7. The root `biome.json` lints and formats both apps: a `bench/**` override keeps bench's old Prettier style (single quotes, trailing commas, 100 columns) and sets bench's lint rules (see `bench/AGENTS.md`), and `bun run check:ci` at the root checks all of it. Both `tsconfig.json` files extend the root `tsconfig.base.json`, which holds the compiler options the apps share, and each keeps its own target, paths and extra flags (the site's stricter `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`, bench's `noFallthroughCasesInSwitch` and `noUncheckedSideEffectImports`). The root `tsconfig.json` excludes `bench/`, and `bench/web/postcss.config.mjs` keeps Next.js from picking up the root's Tailwind config. +- **Toolchain.** `bench/web` is a member of the root Bun workspace: one `bun.lock`, one `bunfig.toml`, one `bun install` at the root. Bun's isolated linker gives each package its own dependencies; both apps run Next.js 16.4.1, React 19.3 and TypeScript 7. The root `biome.json` lints and formats both apps: a `bench/**` override keeps bench's old Prettier style (single quotes, trailing commas, 100 columns) and sets bench's lint rules (see `bench/AGENTS.md`), and `bun run check:ci` at the root checks all of it. Both `tsconfig.json` files extend the root `tsconfig.base.json`, which holds the compiler options the apps share, including `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. Each app keeps its own target, paths and extra flags (bench's `noFallthroughCasesInSwitch` and `noUncheckedSideEffectImports`). The root `tsconfig.json` excludes `bench/`, and `bench/web/postcss.config.mjs` keeps Next.js from picking up the root's Tailwind config. - **CI.** `.github/actions/changed-paths` reports which app a change touches. `ci.yml` skips its site jobs on bench-only changes, and `bench-ci.yml` skips on site-only changes. The workspace's root files (`package.json`, `bun.lock`, `bunfig.toml`, `biome.json`, `tsconfig*.json`) count as both. Both gate with `if:`, not `on.paths`, so a skipped required check still passes. Dependency review, the secret scan and REUSE run on every change. - **Deploys.** The `vortex` Vercel project builds the site through the git integration, and `scripts/vercel-ignore-build.sh` skips bench-only commits. bench deploys to the `benchmarks-website` Vercel project (Root Directory `bench/web`) from `bench-deploy.yml`, with the Vercel CLI, on changes to `bench/` and to the root `package.json`, `bun.lock`, `bunfig.toml` and `tsconfig.base.json`. Schema migrations go through `bench-schema-deploy.yml`, which only runs manually from `main` because the AWS role trusts nothing else. diff --git a/bench/web/lib/chart-format.test.ts b/bench/web/lib/chart-format.test.ts index 0edbe7d..98f23a2 100644 --- a/bench/web/lib/chart-format.test.ts +++ b/bench/web/lib/chart-format.test.ts @@ -232,6 +232,15 @@ describe('commitDateLabel', () => { expect(commitDateLabel(null)).toBe(''); expect(commitDateLabel({ sha: 'x', timestamp: '', message: '', url: '' })).toBe(''); }); + + it('returns empty when the date prefix has no month or no day', () => { + const label = (timestamp: string) => + commitDateLabel({ sha: 'x', timestamp, message: '', url: '' }); + expect(label('2026/06/19T00:00:00Z')).toBe(''); + expect(label('abcdefghij')).toBe(''); + // A valid month but no day. + expect(label('1-1xxxxxxxxx')).toBe(''); + }); }); describe('magnitudeReference', () => { diff --git a/bench/web/lib/chart-format.ts b/bench/web/lib/chart-format.ts index 3899962..d13d517 100644 --- a/bench/web/lib/chart-format.ts +++ b/bench/web/lib/chart-format.ts @@ -592,8 +592,9 @@ export function clampRangeWindow( /** * Return the indices into `xs` / `ys` to keep, including index 0 and `n - 1`. - * `xs` must be strictly increasing. When `threshold >= n` or `threshold < 3`, - * returns `[0, 1, ..., n - 1]` unchanged. + * `xs` must be strictly increasing, and `ys` must have the same length as `xs`. + * When `threshold >= n` or `threshold < 3`, returns `[0, 1, ..., n - 1]` + * unchanged. * * Algorithm: <https://skemman.is/handle/1946/15343>. Per bucket, pick the point * that forms the largest triangle with the previously kept point and the @@ -792,7 +793,7 @@ export function normalizeChartPayload(payload: ChartResponse): NormalizedChartPa const out = new Array<number | null>(total).fill(null); if (Array.isArray(values)) { for (let vi = 0; vi < values.length && start + vi < total; vi++) { - out[start + vi] = values[vi]!; + out[start + vi] = values[vi] as number | null; } } normalizedSeries[name] = out; diff --git a/bench/web/lib/queries.ts b/bench/web/lib/queries.ts index c221281..9987870 100644 --- a/bench/web/lib/queries.ts +++ b/bench/web/lib/queries.ts @@ -1650,7 +1650,7 @@ export async function collectGroupCharts( ); const charts: NamedChartResponse[] = []; group.charts.forEach((link, i) => { - const chart = payloads[i]!; + const chart = payloads[i] as ChartResponse | null; if (chart !== null) { charts.push({ name: link.name, slug: link.slug, ...chart }); } From 541fd776ab4766c2cf048b459ce88c8873cdf595 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 03:33:36 +0100 Subject: [PATCH 31/34] Discard a bench client whose connection drops groupChartPayloads and the cache-fill lock hold a client from pool.connect(). pg emits a dropped connection as 'error' on the client, and the pool listens only while the client is idle, so a drop during a group build or a locked fill was an uncaught exception. pool.query, which the build used before, attaches such a listener for its query. Both now listen while they hold the client, and release it with the error so the pool discards it. The lock skips its unlock when the connection dropped, since the session took the lock with it, and a failed unlock now discards the client too, so its session cannot keep the lock. The success path does not change. Tests: a build whose backend is terminated rejects with no uncaught exception and leaves no client checked out; the lock client cases use the existing mocks. The statement checks now also assert that every statement ran on one client and that no listener is left behind. windowCommits' doc names the snapshot it runs in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/web/lib/cache-fill.test.ts | 36 +++++++++++++++++++- bench/web/lib/cache-fill.ts | 22 +++++++++++-- bench/web/lib/group-charts.test.ts | 53 +++++++++++++++++++++++++++++- bench/web/lib/queries.ts | 14 ++++++-- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/bench/web/lib/cache-fill.test.ts b/bench/web/lib/cache-fill.test.ts index b5985c8..e303d5d 100644 --- a/bench/web/lib/cache-fill.test.ts +++ b/bench/web/lib/cache-fill.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +import { EventEmitter } from 'node:events'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { connect, query, release } = vi.hoisted(() => ({ @@ -20,11 +21,15 @@ import { } from '@/lib/cache-fill'; describe('group cache fill lock', () => { + // An `EventEmitter`, like pg's client: emitting `'error'` with no listener throws. + let client: EventEmitter; + beforeEach(() => { connect.mockReset(); query.mockReset(); release.mockReset(); - connect.mockResolvedValue({ query, release }); + client = Object.assign(new EventEmitter(), { query, release }); + connect.mockResolvedValue(client); }); it('holds the advisory lock for the fill and releases its client', async () => { @@ -38,6 +43,8 @@ describe('group cache fill lock', () => { expect(query.mock.calls[1]![0]).toContain('pg_advisory_unlock'); expect(query.mock.calls[0]![1]).toEqual(query.mock.calls[1]![1]); expect(release).toHaveBeenCalledOnce(); + expect(release.mock.calls[0]![0]).toBeUndefined(); + expect(client.listenerCount('error')).toBe(0); }); it('fails fast on contention without running the fill', async () => { @@ -66,6 +73,33 @@ describe('group cache fill lock', () => { expect(query.mock.calls[1]![0]).toContain('pg_advisory_unlock'); expect(release).toHaveBeenCalledOnce(); }); + + it('discards a client whose connection drops during the fill', async () => { + query.mockResolvedValueOnce({ rows: [{ acquired: true }] }); + const dropped = new Error('Connection terminated unexpectedly'); + + await expect( + withGroupCacheFillLock(async () => { + client.emit('error', dropped); + return 'groups'; + }), + ).resolves.toBe('groups'); + + // The lock went with the session, so there is nothing to unlock. + expect(query).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledExactlyOnceWith(dropped); + expect(client.listenerCount('error')).toBe(0); + }); + + it('discards a client whose unlock fails, so its session cannot keep the lock', async () => { + const failure = new Error('canceling statement due to statement timeout'); + query.mockResolvedValueOnce({ rows: [{ acquired: true }] }).mockRejectedValueOnce(failure); + + await expect(withGroupCacheFillLock(async () => 'groups')).rejects.toBe(failure); + + expect(release).toHaveBeenCalledExactlyOnceWith(failure); + expect(client.listenerCount('error')).toBe(0); + }); }); describe('group cache fill retry', () => { diff --git a/bench/web/lib/cache-fill.ts b/bench/web/lib/cache-fill.ts index 1253378..9eb7e76 100644 --- a/bench/web/lib/cache-fill.ts +++ b/bench/web/lib/cache-fill.ts @@ -28,6 +28,15 @@ export class GroupCacheFillInProgressError extends Error { */ export async function withGroupCacheFillLock<T>(fill: () => Promise<T>): Promise<T> { const client = await getPool().connect(); + // A client whose connection dropped or whose unlock failed is broken; release it with the error + // so the pool discards it, which ends its session and any lock the session still holds. pg emits + // a dropped connection as `'error'` on the client, and the pool listens only while the client is + // idle, so without this listener the event would be an uncaught exception. + let broken: Error | undefined; + const onError = (error: Error): void => { + broken ??= error; + }; + client.on('error', onError); let acquired = false; try { const result = await client.query<{ acquired: boolean }>( @@ -41,11 +50,18 @@ export async function withGroupCacheFillLock<T>(fill: () => Promise<T>): Promise return await fill(); } finally { try { - if (acquired) { - await client.query('SELECT pg_advisory_unlock($1, $2)', [...GROUP_CACHE_FILL_LOCK]); + // A dropped connection ended the session, and the lock with it. + if (acquired && broken === undefined) { + await client + .query('SELECT pg_advisory_unlock($1, $2)', [...GROUP_CACHE_FILL_LOCK]) + .catch((unlockError: unknown) => { + broken ??= unlockError instanceof Error ? unlockError : new Error(String(unlockError)); + throw unlockError; + }); } } finally { - client.release(); + client.removeListener('error', onError); + client.release(broken); } } } diff --git a/bench/web/lib/group-charts.test.ts b/bench/web/lib/group-charts.test.ts index 3f11193..f03a7e8 100644 --- a/bench/web/lib/group-charts.test.ts +++ b/bench/web/lib/group-charts.test.ts @@ -101,7 +101,9 @@ function sqlText(query: unknown): string { /** * Run `build` and return what it issued on pooled clients: its data statements * without the transaction's `BEGIN` and `COMMIT`, which it checks are there. It - * also checks that nothing went through `pool.query`. + * also checks that nothing went through `pool.query`, that every statement ran + * on one client, which the shared snapshot depends on, and that the build took + * its `'error'` listener off that client. */ async function statementsOf<T>( build: () => Promise<T>, @@ -111,6 +113,10 @@ async function statementsOf<T>( const result = await build(); const texts = clientQuery.mock.calls.map((call) => sqlText(call[0]).trim()); expect(poolQuery).not.toHaveBeenCalled(); + const clients = new Set(clientQuery.mock.contexts as Client[]); + expect(clients.size).toBe(1); + // Only the pool's own idle listener is left on the released client. + expect([...clients][0]!.listenerCount('error')).toBe(1); expect(texts[0]).toBe('BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY'); expect(texts.at(-1)).toBe('COMMIT'); clientQuery.mockRestore(); @@ -222,6 +228,51 @@ describe.skipIf(!databaseAvailable)('groupChartPayloads on the canonical fixture expect(payloads[0]).not.toBeNull(); }); + // pg rejects the running statement when the connection drops, then emits `'error'` on the + // client. A checked-out client has no pool listener for it, so the build must hold one, or the + // event is an uncaught exception. + it('discards its client when the connection drops mid-build', async () => { + const pool = getPool(); + const original = Client.prototype.query; + let backendPid: number | undefined; + const clientQuery = vi.spyOn(Client.prototype, 'query').mockImplementation(function ( + this: Client, + ...args: unknown[] + ) { + if (sqlText(args[0]).includes('commit_times')) { + backendPid = (this as Client & { processID: number }).processID; + return (original as (...a: unknown[]) => unknown).call(this, 'SELECT pg_sleep(5)'); + } + return (original as (...a: unknown[]) => unknown).apply(this, args); + } as never); + const uncaught = vi.fn(); + process.on('uncaughtException', uncaught); + try { + const build = groupChartPayloads(TPCH, [Q1], parseCommitWindow(null)); + await vi.waitFor( + async () => { + expect(backendPid).toBeDefined(); + const { rows } = await pool.query<{ wait_event: string | null }>( + 'SELECT wait_event FROM pg_stat_activity WHERE pid = $1', + [backendPid], + ); + expect(rows[0]?.wait_event).toBe('PgSleep'); + }, + { timeout: 4_000 }, + ); + await pool.query('SELECT pg_terminate_backend($1)', [backendPid]); + await expect(build).rejects.toThrow('terminating connection due to administrator command'); + } finally { + process.off('uncaughtException', uncaught); + } + expect(uncaught).not.toHaveBeenCalled(); + clientQuery.mockRestore(); + expect(pool.idleCount).toBe(pool.totalCount); + // The pool replaces the discarded client. + const payloads = await groupChartPayloads(TPCH, [Q1], parseCommitWindow(null)); + expect(payloads[0]).not.toBeNull(); + }); + it('returns null in place of a chart with no rows', async () => { const window = parseCommitWindow(null); const payloads = await groupChartPayloads(TPCH, [Q1, { ...Q1, query_idx: 99 }], window); diff --git a/bench/web/lib/queries.ts b/bench/web/lib/queries.ts index 9987870..35d604c 100644 --- a/bench/web/lib/queries.ts +++ b/bench/web/lib/queries.ts @@ -674,7 +674,9 @@ type CommitRow = { commit_sha: string; ts_text: string; message: string; url: st /** * The last `n` commits of all (every commit for `?n=all`), oldest first. Each - * chart's x-axis is a suffix of this list. A `null` `LIMIT` is no limit. + * chart's x-axis is a suffix of this list. A `null` `LIMIT` is no limit. It + * runs on [`groupChartPayloads`]'s client, inside that build's `REPEATABLE READ` + * transaction, so it sees the same snapshot as the group's rows statements. */ async function windowCommits(client: PoolClient, window: CommitWindow): Promise<CommitPoint[]> { const text = ` @@ -1020,8 +1022,15 @@ export async function groupChartPayloads( return []; } const client = await getPool().connect(); - // A client whose ROLLBACK failed is broken; release it with the error so the pool discards it. + // A client whose connection dropped or whose ROLLBACK failed is broken; release it with the + // error so the pool discards it. pg emits a dropped connection as `'error'` on the client, and + // the pool listens only while the client is idle, so without this listener the event would be + // an uncaught exception. (`pool.query` attaches the same kind of listener for its query.) let broken: Error | undefined; + const onError = (error: Error): void => { + broken ??= error; + }; + client.on('error', onError); try { await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY'); const commits = await windowCommits(client, window); @@ -1040,6 +1049,7 @@ export async function groupChartPayloads( } throw error; } finally { + client.removeListener('error', onError); client.release(broken); } } From 84f8587b6f8f42e587fe4053bcad0b66a924adae Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 03:33:43 +0100 Subject: [PATCH 32/34] Correct bench's pool-size test and two stale notes The pool-size test wrapped pool.query, so it could not see the group build's connect()ed client, and its comment still described a fan-out of one query per chart. It now runs a fill under the advisory lock, as the Data Cache does, with nine query groups so the summaries fill all seven slots, and counts connection requests: at most 8, the lock and seven summaries. It counts requests, not checked-out clients, because a pool of 8 never checks out more than 8, so a wider fan-out would only queue. The PR 2 plan gets an "As built" note under Task 3: one REPEATABLE READ transaction on one client, sequential statements, slices of 25 charts, and the four SQL changes. The bench-checks comment no longer says a runner without Docker would skip the suite silently; the harness fails it in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- .github/actions/bench-checks/action.yml | 6 +- .../docs/plans/2026-10-29-cleanup-pr2-plan.md | 11 +++ bench/web/lib/groups.test.ts | 76 ++++++++++++------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/.github/actions/bench-checks/action.yml b/.github/actions/bench-checks/action.yml index 695bda0..9e01c93 100644 --- a/.github/actions/bench-checks/action.yml +++ b/.github/actions/bench-checks/action.yml @@ -70,9 +70,9 @@ runs: shell: bash run: bun run --cwd bench/web build - # testcontainers self-skips the Postgres integration suite when no Docker daemon - # is available (a local-dev convenience); in CI that would silently drop the only - # Postgres integration coverage, so fail loudly if the daemon is missing. + # The Postgres integration suite runs on Docker here. Locally it skips when there + # is neither Docker nor BENCH_TEST_DATABASE_URL; with CI=true the test harness + # fails the suite instead. This step fails sooner, with a plainer cause. - name: Require Docker for the integration suite shell: bash run: docker info > /dev/null diff --git a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md index 30466da..4d15ce6 100644 --- a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md +++ b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md @@ -167,6 +167,17 @@ it('hands the password to pg as a string', () => { ### Task 3: One query per group +**As built.** The steps below are the plan as written. The code differs from them here: + +- `groupChartPayloads` runs its statements one after another on one `getPool().connect()` client, in one `REPEATABLE READ, READ ONLY` transaction, not in parallel with `Promise.all` on the pool. Every statement sees one snapshot, so an ingest that lands mid-build cannot make the shared window and a chart's commit total disagree. +- The rows statement runs in slices of `GROUP_ROWS_CHUNK = 25` charts, so the 99-chart TPC-DS group runs five. As one result, TPC-DS at `?n=all` (about 2.7M rows) peaked at 1.6× the memory of the old per-chart fan-out. +- The SQL differs from Step 4 in four places. On one connection, every chart's cost is paid in turn: + - A chart's first timestamp comes from an aggregate `LATERAL` subquery (`e`). The planner cannot flatten an aggregate into the outer query and run it once per output row. + - `total_commits` is a binary search, `width_bucket` over the sorted commit timestamps, not a `count(*)` of `commits` per chart (99 scans for TPC-DS). + - For query charts, the first timestamp comes from a skip scan of `idx_query_measurements_summary` with one descent per series (`queryChartEarliest`), not from a `MIN` that reads the chart's whole history (about 27K rows per TPC-DS chart). + - The compression families put NULL-variant and non-NULL-variant charts in separate blocks, each pinned sargably. `IS NOT DISTINCT FROM ch.dataset_variant` planned as a one-row match and rescanned the window for each fact row. +- `groups.test.ts`'s pool-size test is now `keeps a cached group fill within the pool size`. It counts connection requests during a fill under the advisory lock, and the bound is 8: the lock and seven summaries. + **Files:** - Modify: `bench/web/lib/queries.ts` - Create: `bench/web/lib/group-charts.test.ts` diff --git a/bench/web/lib/groups.test.ts b/bench/web/lib/groups.test.ts index 18a9263..a6fa1e0 100644 --- a/bench/web/lib/groups.test.ts +++ b/bench/web/lib/groups.test.ts @@ -34,6 +34,7 @@ import { } from './test-harness'; import { parseCommitWindow } from './window'; import { getPool, resetPool } from './db'; +import { withGroupCacheFillLock } from './cache-fill'; import { GET as getGroups } from '@/app/api/groups/route'; import { GET as getGroup } from '@/app/api/group/[slug]/route'; @@ -809,47 +810,66 @@ describe.skipIf(!databaseAvailable)('summary math fidelity (testcontainers Postg expect(summary.rankings[1]!.score).toBeCloseTo(1.732022, 5); }); - // A cold group fill runs one chart query per chart. pg fails a query that - // waits BENCH_DB_CONNECTION_TIMEOUT_MS for a free pool slot, so the fill must - // keep its fan-out below the pool size. Otherwise a large group (99 TPC-DS - // charts) fails on a healthy database. The bound is 7: one below the default - // pool size of 8, because a cached fill holds one connection for its lock. - it('keeps a group fill within the pool size', async () => { + // A cached group fill holds one connection for its advisory lock while it runs the group + // discovery (one statement per family, five at once) and the summaries (QUERY_CONCURRENCY, 7, at + // once) through `pool.query`, then builds the group's charts on one `connect()`ed client. pg fails + // a request that waits BENCH_DB_CONNECTION_TIMEOUT_MS for a free pool slot, so the fill must never + // ask for more connections at once than the default pool size, 8: the lock plus 7 summaries. The + // test counts requests, not checked-out clients, because a pool of 8 never checks out more than + // 8: a wider fan-out would only queue. + it('keeps a cached group fill within the pool size', async () => { const sha = 'b'.repeat(40); await insertCommit(sha, '2026-04-23T12:00:00Z'); for (let queryIdx = 1; queryIdx <= 12; queryIdx += 1) { await insertQuery(sha, queryIdx, 'datafusion', 'parquet', 100_000); } + // Eight sibling groups, so the summaries fill all seven slots. + for (let scaleFactor = 2; scaleFactor <= 9; scaleFactor += 1) { + await getPool().query( + `INSERT INTO query_measurements + (measurement_id, commit_sha, dataset, dataset_variant, scale_factor, + query_idx, storage, engine, format, value_ns, all_runtimes_ns, + commit_timestamp) + VALUES ($1, $2, 'tpch', NULL, $3, 1, 'nvme', 'datafusion', 'parquet', 100000, + '{1}'::bigint[], (SELECT timestamp FROM commits WHERE commit_sha = $2))`, + [nextId(), sha, String(scaleFactor)], + ); + } const pool = getPool(); - const original = pool.query; - let inFlight = 0; + const connect = pool.connect.bind(pool); + let requested = 0; let peak = 0; - pool.query = (async (...args: unknown[]) => { - inFlight += 1; - peak = Math.max(peak, inFlight); - try { - return await (original as (...a: unknown[]) => Promise<unknown>).apply(pool, args); - } finally { - inFlight -= 1; - } - }) as typeof pool.query; + // `pool.query` takes its client through `this.connect`, so this sees every request. + const connectSpy = vi.spyOn(pool, 'connect').mockImplementation(((...args: unknown[]) => { + requested += 1; + peak = Math.max(peak, requested); + return (connect as (...a: unknown[]) => unknown)(...args); + }) as never); + const onRelease = (): void => { + requested -= 1; + }; + pool.on('release', onRelease); try { - const bundle = await collectGroupCharts( - { - k: 'QueryGroup', - dataset: 'tpch', - dataset_variant: null, - scale_factor: '1', - storage: 'nvme', - }, - parseCommitWindow(null), + const bundle = await withGroupCacheFillLock(() => + collectGroupCharts( + { + k: 'QueryGroup', + dataset: 'tpch', + dataset_variant: null, + scale_factor: '1', + storage: 'nvme', + }, + parseCommitWindow(null), + ), ); expect(bundle?.charts).toHaveLength(12); } finally { - pool.query = original; + pool.off('release', onRelease); + connectSpy.mockRestore(); } - expect(peak).toBeLessThanOrEqual(7); + expect(requested).toBe(0); + expect(peak).toBeLessThanOrEqual(8); }); }); From b4caf7c1f96f0c675f62eeddbc91975b1271aac3 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 03:51:18 +0100 Subject: [PATCH 33/34] Mark bench's fake Postgres credential for the secret scan TruffleHog reports the test-harness parser's made-up connection URL (and its copy in the PR 2 plan) as an unverified Postgres result, which fails the scan. Mark both lines with trufflehog:ignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- bench/docs/plans/2026-10-29-cleanup-pr2-plan.md | 2 +- bench/web/lib/test-harness.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md index 4d15ce6..b565bc9 100644 --- a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md +++ b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md @@ -72,7 +72,7 @@ describe('testDatabaseSettings', () => { }); }); it('decodes the password and defaults the port and admin database', () => { - expect(testDatabaseSettings('postgresql://will:p%40ss@localhost')).toEqual({ + expect(testDatabaseSettings('postgresql://will:p%40ss@localhost')).toEqual({ // trufflehog:ignore host: 'localhost', port: 5432, user: 'will', password: 'p@ss', adminDatabase: 'postgres', }); }); diff --git a/bench/web/lib/test-harness.test.ts b/bench/web/lib/test-harness.test.ts index b7903db..61c747f 100644 --- a/bench/web/lib/test-harness.test.ts +++ b/bench/web/lib/test-harness.test.ts @@ -16,7 +16,9 @@ describe('testDatabaseSettings', () => { }); it('decodes the password and defaults the port and admin database', () => { - expect(testDatabaseSettings('postgresql://will:p%40ss@localhost')).toEqual({ + // A made-up credential for the parser, not a secret. + const url = 'postgresql://will:p%40ss@localhost'; // trufflehog:ignore + expect(testDatabaseSettings(url)).toEqual({ host: 'localhost', port: 5432, user: 'will', From 4365deeac7b9b35fa49e6519db90bbae32db9a99 Mon Sep 17 00:00:00 2001 From: Will Manning <will@willmanning.io> Date: Thu, 29 Oct 2026 06:43:10 +0100 Subject: [PATCH 34/34] Remove bench's working plans from the repository The design and plan documents for the toolchain move and the cleanup were working notes for those changes. The code, the architecture docs and CLAUDE.md now record what they decided. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io> --- .../plans/2026-10-28-toolchain-and-cleanup.md | 232 ---------- .../plans/2026-10-28-toolchain-pr1-plan.md | 240 ---------- .../docs/plans/2026-10-29-cleanup-pr2-plan.md | 435 ------------------ 3 files changed, 907 deletions(-) delete mode 100644 bench/docs/plans/2026-10-28-toolchain-and-cleanup.md delete mode 100644 bench/docs/plans/2026-10-28-toolchain-pr1-plan.md delete mode 100644 bench/docs/plans/2026-10-29-cleanup-pr2-plan.md diff --git a/bench/docs/plans/2026-10-28-toolchain-and-cleanup.md b/bench/docs/plans/2026-10-28-toolchain-and-cleanup.md deleted file mode 100644 index b624844..0000000 --- a/bench/docs/plans/2026-10-28-toolchain-and-cleanup.md +++ /dev/null @@ -1,232 +0,0 @@ -<!-- -SPDX-License-Identifier: CC-BY-4.0 -SPDX-FileCopyrightText: Copyright the Vortex contributors ---> - -# bench: upgrade, share the site's toolchain, clean up - -Status: design, 2026-10-28. Step 1 of 3. - -## Why - -bench (`bench/web`) came from the archived `vortex-data/benchmarks-website` -repository. It runs on a second toolchain beside the marketing site: pnpm, -ESLint, Prettier, TypeScript 5.9 and Next.js 15.5, against the site's Bun, -Biome, TypeScript 7 and Next.js 16. Its packages are behind, and two of its -largest files (`components/Chart.tsx`, 2,200 lines; `lib/chart-store.ts`, 632 -lines) are hard to change safely. - -The goal is maintainability: one toolchain and one set of supply-chain rules -for both apps, current dependencies, and smaller, clearer code. The two apps -keep separate deployments. - -This is the first of three steps: - -1. **This document.** Upgrade and clean up bench with no change in behavior. -2. A new landing page: one page, every group folded with its headline numbers, - and opening a group loads its charts in place. -3. One visual redesign across vortex.dev, bench, explore and docs. Open PR - vortex-data/vortex.dev#120 is held for that step. - -## Goals - -- bench and the site share one Bun workspace, one lockfile, one Biome config - and one TypeScript base config. -- bench is on Next.js 16, React 19.3, TypeScript 7 and current versions of its - other packages, inside the 14-day cooldown. -- The code is smaller and clearer where that is cheap: dead paths removed, the - two largest client files split into modules, and a group's charts fetched - with one query instead of one per chart. -- No change in behavior that a person could notice, and each PR proves it. - Small bug fixes and subtle changes from library upgrades are acceptable if - they are negligible; every difference the parity check finds is listed and - explained in the PR. - -## Non-goals - -- Any change a visitor can see. The landing page is step 2; the visual - redesign is step 3. -- Replacing Vitest. It stays: its 8,000 lines of tests are the main evidence - that behavior did not change. -- Porting `bench/scripts/migrate-schema.py` to Bun. That comes later and is - not urgent. -- Database schema, ingest contract (`bench/CONTRACT.md`) or AWS changes. -- Merging the two Vercel projects or changing either app's domain. - -## Anti-goals - -The work fails, even if it builds, if any of these happen: - -- A person can notice a change in a page, API response, response header, - cache rule or URL. -- The supply-chain gates get weaker: the 14-day cooldown, explicit overrides, - the lifecycle-script allowlist, SHA-pinned actions, `ubuntu-24.04` runners. -- The marketing site's build, behavior or deploy changes, beyond installing - from a shared lockfile. -- The work lands as a long stack of small PRs. Will smoke-tests each PR by hand - in its Vercel preview, so fewer, larger PRs are better. - -## Constraints - -- **Order.** vortex-data/vortex.dev#124 (database pool lifecycle) merged on - 2026-10-28; build on it. PR 1 deletes `bench/web/pnpm-workspace.yaml`, so its - exact-version cooldown exemptions go with it. A version still inside the - window then needs a `bunfig.toml` exemption, with a removal date. -- **Supply chain.** CLAUDE.md "Supply chain hardening" is the policy. Bun - enforces the cooldown through `bunfig.toml`, so the pnpm settings move as - follows: - - The `overrides` in `bench/web/pnpm-workspace.yaml` (postcss, sharp and - zod) move to the root `package.json` `overrides`. Each entry keeps its - advisory and removal trigger. Drop postcss if the version Next.js 16 pins - is already patched. Bun applies overrides to the whole workspace, so the - zod override (`^4.6.7`, for `@vercel/functions` > `@vercel/oidc` > - `@vercel/cli-config`, which pins 4.1.11) also moves Lighthouse CI's - `chromium-bidi` from zod 3 to zod 4. That reverses a CLAUDE.md decision on - purpose: the Lighthouse job must still pass, and it also clears the site's - accepted zod advisory. If Lighthouse CI breaks, drop the override and - record bench's zod 4.1.11 as an accepted advisory instead (it parses only - local Vercel CLI config files). - - GitHub's dependency graph doesn't read `bun.lock`. Once bench leaves - `pnpm-lock.yaml`, `dependency-review` would no longer see its transitive - packages; the site already has that gap. A workflow submits a dependency - snapshot built from `bun.lock` to the Dependency Submission API on pushes - to `main` and on pull requests, so `dependency-review` sees every resolved - package for both apps. It must run before `dependency-review` reads the - pull request's dependencies. - - `allowBuilds` moves to `trustedDependencies`. Today only `sharp` and - `unrs-resolver` may build, and `unrs-resolver` goes away with ESLint. - - The bench parts of CLAUDE.md ("bench/ (pnpm)", "bench advisories") are - rewritten to match. -- **Required checks.** The `main` ruleset requires a check named - `Bench: format, lint, build, test`. Keep that job name. Renaming it needs a - ruleset change. -- **Settings changes need Will's confirmation first.** That covers GitHub, - Vercel and AWS settings. The `benchmarks-website` Vercel project builds on - the GitHub runner (`vercel build` in `.github/workflows/bench-deploy.yml`), - so the install command probably changes only in the workflow. If a project - setting must change, ask first. -- **The site's Vercel project** (`vortex`) builds through the git - integration. Adding a workspace must not make it build bench, and must not - noticeably slow its install. `scripts/vercel-ignore-build.sh` keeps skipping - bench-only commits. -- **Node 24** stays the runtime (CLAUDE.md "Node version"). -- **DCO sign-off** on every commit. Squash merges only. -- **Licensing.** New bench files carry SPDX headers: Apache-2.0 for code, - CC-BY-4.0 for Markdown. - -## Decided by Will - -- Share the toolchain and code, and keep separate deployments. -- Fewer, larger PRs, each smoke-tested in its Vercel preview. -- Keep Vitest for now. -- The Python migration runner moves to Bun eventually, not in this work. -- Close the `bun.lock` gap in dependency review with a submitted dependency - snapshot. -- Force zod 4 across the workspace and let Lighthouse CI prove it still works. - -## The work: two PRs - -### PR 1: upgrade bench and move it onto the site's toolchain - -Mechanical changes only. Commits are ordered so a reviewer can read each one -alone. - -1. **Parity check first.** Add the parity script (see "Proving nothing - changed") and run it with `main` on both sides, to show it reports no - difference. Later commits must keep it clean. -2. **Bun workspace.** The root `package.json` lists `bench/web` as a workspace. - One `bun.lock` replaces `bench/web/pnpm-lock.yaml`. `pnpm-workspace.yaml` - goes, and its settings move as described under Constraints. - `.github/workflows/bench-ci.yml`, `bench-deploy.yml`, the - `.github/actions/bench-checks` composite and Renovate's bench rule switch - from pnpm to Bun. The dependency-snapshot workflow lands in the same - commit, so `dependency-review` never loses sight of bench's packages. -3. **Output file tracing.** `bench/web/next.config.js` sets - `outputFileTracingRoot: __dirname`. Bun hoists workspace packages to the - root `node_modules`, so tracing must start at the repository root, or the - deployed functions can miss dependencies. Check the traced files of a - preview function, not only the build log. -4. **Next.js 16 and React 19.3**, with the other dependency updates - (`pg`, `chart.js`, `jsdom` 30, `@types/*`). Read the Next.js 16 upgrade - guide against bench's use of `unstable_cache`, `revalidateTag`, - `force-dynamic` pages, route handlers and `vercel.json` headers. Turbopack - becomes the default builder. -5. **Biome replaces ESLint and Prettier.** One root `biome.json` covers both - apps: remove the `!bench` exclusion. An `overrides` entry for `bench/**` - keeps bench's current Prettier style (single quotes, trailing commas, - 100 columns), so the switch reformats almost nothing. One style for both - apps can come later; `main` takes squash merges only, so a mass reformat - can't be hidden from `git blame` without also hiding the real changes in - the same squashed commit. ESLint's `next/core-web-vitals` rules have no - Biome equivalent; the site already accepts that. Map any rule bench relies - on, such as the React hooks rules, to its Biome counterpart. -6. **TypeScript 7.** bench's `tsconfig.json` extends a base shared with the - site and keeps its current strictness. Stricter flags wait for PR 2. - -### PR 2: clean up bench's code - -Logic stays the same. The parity check and the tests prove it. - -- **Remove the IAM sign-in path.** Production signs in with the `bench_read` - password; IAM auth is not available for that role as shipped (see - `bench/web/README.md`). Remove `@aws-sdk/rds-signer`, `BENCH_DB_REGION` and - the signer code, and make `BENCH_DB_PASSWORD` required. Update the README - and runbooks. -- **One query per group.** `collectGroupCharts` now runs two queries for each - chart (a seed window and the data), about 200 for a TPC-DS group of 99 - charts. Fetch a group's rows in one or two queries, keeping each chart's own - last-N-commits window (for example, with a window function partitioned by - chart). The JSON must stay byte-identical. This removes the fan-out that - `QUERY_CONCURRENCY` and the pool-wait discussion on #124 exist for. Keep the - concurrency limit unless the new code no longer needs it. -- **Split `components/Chart.tsx` and `lib/chart-store.ts`** into smaller - modules along their existing seams (fetching, formatting, zoom, the loading - queues). Move code; don't rewrite it. -- **Adopt the site's stricter TypeScript flags** - (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), with the code - changes they need. -- Remove dead code that the stricter flags and Biome expose. - -## Proving nothing changed - -Each PR passes these checks, in order: - -1. **CI**: `Bench: format, lint, build, test`, including the Postgres - integration suite. -2. **Parity, run locally, once per PR**, on `main` against the PR's head. If - it reports a difference, bisect the PR's commits to find the cause, then - fix it or explain why it is negligible. Restore a production `pg_dump` - into a local Postgres. Build `main` and the PR branch, serve both with - `next start` on two ports against that one database, and compare: - - JSON from `/api/groups`, `/api/group/{slug}` for every group at `n=100` - and one other window, and `/api/chart/{slug}` for every chart at `n=100` - and a sample at `n=all`. The bodies must match exactly. - - The response headers the app sets: status, `content-type`, - `cache-control`. - - The `/` and `/chart/{slug}` pages: screenshots after the charts load, at - desktop and phone widths, compared pixel by pixel with Playwright (already - a dev dependency of the site). - - Comparing against production or a Vercel preview doesn't work: the Data - Cache (`unstable_cache`, with a 24-hour backstop, flushed only by - production's `/api/revalidate`) makes a preview's cached routes lag - production after any ingest, and a shared preview cache could serve - payloads built by older code. Two local servers start with empty caches - and read the same frozen data, so any difference comes from the code. - This needs no token and no Vercel setting. Will's Mac holds a dump from - 2026-10-28 in `~/Backups/vortex-bench/`. -3. **The deployed preview.** `bench-deploy.yml` already runs - `bench/scripts/verify-web-deploy.mjs` against each preview's public read - paths. It catches what only a Vercel build shows, such as file tracing. - `vercel.json`, which carries the CDN cache headers, must not change. -4. **Will's smoke test** in the preview. - -## Left to the builder - -- The parity script's exact sample, and how it waits for charts before a - screenshot. -- The Biome rule mapping, and how to apply the site's style. -- Whether TypeScript 7 or Next.js 16 needs code changes, and what they are. -- The module boundaries for the `Chart.tsx` and `chart-store.ts` split. -- The SQL for the group query, as long as the output is byte-identical. -- Whether to install the whole workspace or only one app in each workflow. diff --git a/bench/docs/plans/2026-10-28-toolchain-pr1-plan.md b/bench/docs/plans/2026-10-28-toolchain-pr1-plan.md deleted file mode 100644 index dca93c4..0000000 --- a/bench/docs/plans/2026-10-28-toolchain-pr1-plan.md +++ /dev/null @@ -1,240 +0,0 @@ -<!-- -SPDX-License-Identifier: CC-BY-4.0 -SPDX-FileCopyrightText: Copyright the Vortex contributors ---> - -# bench PR 1: upgrade and move onto the site's toolchain — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** bench builds, tests, lints and deploys with the site's Bun, Biome and TypeScript 7 toolchain on Next.js 16, and nothing a visitor or API client sees changes. - -**Architecture:** The repository root becomes a Bun workspace whose members are the site (root) and `bench/web`, with one `bun.lock`. A local parity harness serves `main` and the branch side by side against a restored production dump and diffs every route and screenshot. A submitted dependency snapshot keeps `dependency-review` able to see every package in `bun.lock`. - -**Tech Stack:** Bun 1.4.3, Next.js 16.4.1, React 19.3.1, TypeScript 7, Biome 2.5, Vitest 4 (stays), Playwright 1.64, Postgres 16 in Docker, GitHub Actions. - -**Spec:** `bench/docs/plans/2026-10-28-toolchain-and-cleanup.md` (read it first; this plan covers its "PR 1"). - -## Global Constraints - -- No human-perceptible change in pages, API JSON, response headers, cache rules or URLs; small bug fixes and subtle library-driven changes are acceptable if negligible, and every parity difference is listed and explained. `bench/web/vercel.json` headers and crons do not change. -- Parity runs once, in Task 7, on `main` against the whole branch. The per-task parity steps in Tasks 3–6 are skipped. On a difference, bisect the task commits with `run.sh` as the test. -- Cooldown: `bunfig.toml` `minimumReleaseAge = 1209600` applies to every install. A version still inside the window needs a `minimumReleaseAgeExcludes` entry with an advisory reason and a removal date. -- Root `package.json` `overrides`: keep `"sharp": "^0.35.5"`; add `"zod": "^4.6.7"`; add `"postcss": "^8.5.30"` only if the postcss that Next.js 16.4.1 resolves to is below a patched version for GHSA-6g55-p6wh-862q and GHSA-r28c-9q8g-f849. -- `trustedDependencies` stays `["esbuild", "sharp"]` unless `bun pm untrusted` shows a bench package that must build; justify any addition in the commit message. -- The job name `Bench: format, lint, build, test` does not change (the `main` ruleset requires it). -- Actions are SHA-pinned with a `# vX.Y.Z` comment; runners are `ubuntu-24.04`; Node comes from `.nvmrc` (24). Reuse `oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0` with `bun-version: 1.4.3`. -- No GitHub, Vercel or AWS **settings** change without asking Will first. Files in the repository (workflows, `vercel.json`) are fine. -- Vitest stays the bench test runner; the Python migration runner and its uv-based tests stay as they are. -- bench's code style stays: single quotes, trailing commas everywhere, 100 columns, 2-space indent. -- Every commit: `git commit -s`, ending with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. New code files under `bench/` start with `// SPDX-License-Identifier: Apache-2.0` and `// SPDX-FileCopyrightText: Copyright the Vortex contributors`. -- Root `tsconfig.json` and `biome.json` stop excluding `bench/` only where a task says so. - -## Review Focus - -1. **A deployed function misses a hoisted package.** With Bun hoisting `pg` and `@vercel/functions` to the root `node_modules`, a preview whose file tracing starts at `bench/web` returns 500 on every API route. Task 3 asserts the traced files include them. -2. **Ingest stops flushing the cache.** Next.js 16 changes `revalidateTag`; the emitter's `POST /api/revalidate` must still expire cached data at once. Task 4 pins the second argument in a test. -3. **The site's deploy gets slower or breaks.** The `vortex` Vercel project runs `bun install --frozen-lockfile` at the root, which now installs bench too. Task 3 times it and checks the site still builds. -4. **Dependency review passes because it saw nothing.** If the snapshot isn't submitted before `dependency-review` reads the pull request, the check passes blind. Task 2 makes it wait, and Task 7 reads its log. -5. **A root-level change skips bench CI.** `changed-paths` treats `package.json` and `bun.lock` as site-only today; after the move they change bench too. Task 3 reclassifies them and checks sample paths. - ---- - -### Task 1: Parity harness - -**Files:** -- Create: `bench/scripts/parity/run.sh` -- Create: `bench/scripts/parity/compare-api.ts` -- Create: `bench/scripts/parity/compare-api.test.ts` -- Create: `bench/scripts/parity/screenshots.spec.ts` -- Create: `bench/scripts/parity/playwright.config.ts` -- Create: `bench/scripts/parity/README.md` (CC-BY-4.0 header) - -**Interfaces:** -- Produces: `bash bench/scripts/parity/run.sh <base-ref> <dump-dir>` — exits 0 when the two builds match, non-zero with a printed diff otherwise. Base is a git ref; head is the current working tree. -- Produces: `diffJson(base: string, head: string): string | null` and `compareApi(baseUrl: string, headUrl: string): Promise<string[]>` in `compare-api.ts` (the array holds one line per mismatch). - -- [ ] **Step 1: Write the failing test** in `compare-api.test.ts` (run with `bun test`): - -```ts -import { expect, test } from 'bun:test'; -import { diffJson } from './compare-api'; - -test('identical bodies match', () => { - expect(diffJson('{"a":[1,2]}', '{"a":[1,2]}')).toBeNull(); -}); -test('a changed value is reported with its path', () => { - expect(diffJson('{"a":[1,2]}', '{"a":[1,3]}')).toContain('a[1]'); -}); -test('key order counts, because clients see bytes', () => { - expect(diffJson('{"a":1,"b":2}', '{"b":2,"a":1}')).not.toBeNull(); -}); -``` - -- [ ] **Step 2: Run it:** `bun test bench/scripts/parity/compare-api.test.ts` — expect FAIL (module missing). -- [ ] **Step 3: Implement `diffJson`** (byte comparison first; on mismatch, parse both and return the first differing path) and **`compareApi`**. `compareApi` reads `/api/groups` from the base, then requests from both servers: `/api/groups`; `/api/group/{slug}?n=100` and `?n=50` for every group; `/api/chart/{slug}?n=100` for every chart; `/api/chart/{slug}?n=all` for every 25th chart. It compares status, `content-type`, `cache-control` and the body. -- [ ] **Step 4: Run the test again** — expect PASS. -- [ ] **Step 5: Write `screenshots.spec.ts` and its config.** Pages: `/` and `/chart/{slug}` for five slugs taken from `/api/groups` (the first chart of the first five groups). Projects: desktop 1440×900 and phone 390×844, Chromium only. The config reads `BASE_URL` and writes snapshots under `PARITY_OUT`. The run takes baselines from the base server with `--update-snapshots`, then compares the head server with `maxDiffPixels: 0`. How to wait for charts, and what to mask, are the implementer's call. -- [ ] **Step 6: Write `run.sh`.** - - Start `postgres:16-alpine` (the image the integration suite uses) as container `bench-parity-pg` on port 55432. - - Restore with `pg_restore --no-owner --no-privileges -j 4` from the dump directory, mounted read-only. Skip the restore if the container already holds the `commits` table. - - Export the base with `git archive <base-ref> | tar -x -C "$TMPDIR/bench-parity/base"`; no worktree. - - Build each side with its own toolchain: `pnpm install --frozen-lockfile && pnpm build` when `bench/web/pnpm-lock.yaml` exists, otherwise `bun install --frozen-lockfile` at the root and `bun run --cwd bench/web build`. - - Serve base on 3101 and head on 3102 with `next start`, with `BENCH_DB_HOST=127.0.0.1`, `BENCH_DB_PORT=55432`, `BENCH_DB_NAME=vortex_bench`, `BENCH_DB_USER=postgres`, `BENCH_DB_PASSWORD=parity` and `BENCH_DB_SSL=disable`. - - Run `compareApi`, then the screenshots, and stop the servers on exit (`trap`). -- [ ] **Step 7: Prove it reports no difference on equal code:** `bash bench/scripts/parity/run.sh origin/main ~/Backups/vortex-bench/vortex_bench-20261028T171815Z` from a clean checkout of `main`'s tree — expect exit 0 and `0 mismatches`. -- [ ] **Step 8: Prove it catches a difference:** temporarily change one number in a `/api/groups` response (for example, a summary explanation string in `bench/web/lib/summary.ts`), rerun, expect a non-zero exit naming that path; revert. -- [ ] **Step 9: Commit:** `Add a local parity check for bench builds`. - -### Task 2: Dependency snapshot from `bun.lock` - -**Files:** -- Create: `scripts/dependency-snapshot.ts` -- Create: `scripts/dependency-snapshot.test.ts` -- Create: `.github/workflows/dependency-snapshot.yml` -- Modify: `.github/workflows/ci.yml` (the `dependency-review` job) -- Modify: `CLAUDE.md` (supply-chain layer 5) - -**Interfaces:** -- Produces: `parseBunLock(text: string): BunLock`, `buildSnapshot(lock: BunLock, meta: SnapshotMeta): Snapshot`, with `SnapshotMeta = { sha: string; ref: string; correlator: string; jobId: string; scanned: string }`. `Snapshot` follows GitHub's Dependency Submission API (`version: 0`, `detector`, `manifests[*].resolved[purl] = { package_url, relationship, scope, dependencies }`). - -- [ ] **Step 1: Write the failing tests.** A fixture lock string with trailing commas holds: - - root workspace `vortex`: dependency `a`, devDependency `d`; - - workspace `bench/web`, named `benchmarks-web`: dependency `@s/x`; - - packages: `a@1.0.0` depends on `b` and `c`; nested `a/c@2.0.0`; `d@1.0.0` depends on `b` and `c`; `b@1.0.0`; `c@1.0.0`; `@s/x@1.0.0`. - - Assertions: - -```ts -const snap = buildSnapshot(parseBunLock(FIXTURE), META); -const root = snap.manifests['bun.lock (vortex)'].resolved; -expect(root['pkg:npm/a@1.0.0']).toMatchObject({ relationship: 'direct', scope: 'runtime' }); -expect(root['pkg:npm/b@1.0.0'].scope).toBe('runtime'); // reachable from a -expect(root['pkg:npm/c@2.0.0'].scope).toBe('runtime'); // a resolves the nested a/c -expect(root['pkg:npm/c@1.0.0'].scope).toBe('development'); // only d reaches top-level c -expect(root['pkg:npm/a@1.0.0'].dependencies).toEqual(['pkg:npm/b@1.0.0', 'pkg:npm/c@2.0.0']); -const bench = snap.manifests['bun.lock (bench/web)'].resolved; -expect(bench['pkg:npm/%40s/x@1.0.0']).toMatchObject({ relationship: 'direct', scope: 'runtime' }); -expect(snap.manifests['bun.lock (bench/web)'].file.source_location).toBe('bun.lock'); -``` - -- [ ] **Step 2: Run:** `bun test scripts/dependency-snapshot.test.ts` — expect FAIL. -- [ ] **Step 3: Implement.** - - Parse by removing trailing commas before `}` or `]`, then `JSON.parse`. - - Resolve dependency `N` of package key `K` by trying `K/N`, then `N` under each shorter ancestor of `K`, then `N`. A key is a chain of package names, where a scoped name is two segments. - - Resolve a workspace's own dependency `N` by trying `<workspace name>/N`, then `N`. - - Follow `dependencies`, `optionalDependencies` and `peerDependencies`, and skip names absent from the lock (platform-specific optional packages). - - A package is `runtime` if a workspace's `dependencies` or `optionalDependencies` reach it, and `development` otherwise. It is `direct` if the workspace lists it. - - The CLI (`import.meta.main`) reads `bun.lock` and `POST`s to `/repos/$GITHUB_REPOSITORY/dependency-graph/snapshots`, using `GITHUB_TOKEN`, `SNAPSHOT_SHA` and `SNAPSHOT_REF`. Anything but 201 fails. -- [ ] **Step 4: Run the tests** — expect PASS. Then run against the real lock without submitting (`DRY_RUN=1 bun scripts/dependency-snapshot.ts | head`) and check that `lighthouse`, `puppeteer-core` and `chromium-bidi` appear with scope `development`, and `next` with scope `runtime`. -- [ ] **Step 5: Write the workflow.** - - Triggers: `push` to `main`, and `pull_request` from the same repository only. Fork PRs have no write token. - - `permissions: contents: write`. - - Steps: checkout (`persist-credentials: false`), setup-bun, run the tests, then submit. - - `SNAPSHOT_SHA` and `SNAPSHOT_REF` are `github.sha` and `github.ref` on push, and `github.event.pull_request.head.sha` and `refs/heads/${{ github.head_ref }}` on pull requests. -- [ ] **Step 6: Make `dependency-review` wait for the snapshot:** add `retry-on-snapshot-warnings: true` and `retry-on-snapshot-warnings-timeout: 300` to its `with:`. Leave `fail-on-severity: high` and the default `fail-on-scopes` (runtime) as they are. -- [ ] **Step 7: Update CLAUDE.md** layer 5: dependency review now sees `bun.lock` through the submitted snapshot. It fails on runtime-scope advisories, and dev-only advisories stay in `bun audit` and the "Audit advisories" list. Rewrite the "bench advisories" sentence about pnpm lockfiles to match. -- [ ] **Step 8: Commit:** `Submit bun.lock to the dependency graph`. - -### Task 3: One Bun workspace for the site and bench - -**Files:** -- Modify: `package.json` (`workspaces: ["bench/web"]`, `overrides`), `bun.lock` (regenerated) -- Delete: `bench/web/pnpm-lock.yaml`, `bench/web/pnpm-workspace.yaml` -- Modify: `bench/web/package.json` (drop `packageManager`; scripts call Bun-installed binaries) -- Modify: `bench/web/next.config.js` (`outputFileTracingRoot`) -- Modify: `.github/actions/bench-checks/action.yml`, `.github/workflows/bench-ci.yml`, `.github/workflows/bench-deploy.yml`, `.github/actions/changed-paths/action.yml`, `renovate.json` -- Modify: `CLAUDE.md` ("bench/ (pnpm)" → Bun; "bench advisories"; "Repository layout" toolchain line), `bench/web/README.md`, `bench/AGENTS.md`, `bench/docs/architecture/deploy-and-infra.md` - -**Interfaces:** -- Produces: `bun install --frozen-lockfile` at the root installs both apps; `bun run --cwd bench/web <script>` runs bench scripts. Later tasks use these commands. - -- [ ] **Step 1: Record the baseline.** On `main`, time a clean root install: `rm -rf node_modules && time bun install --frozen-lockfile`. Note the seconds. -- [ ] **Step 2: Create the workspace.** - - Add `"workspaces": ["bench/web"]`, and add `"zod": "^4.6.7"` to `overrides`. postcss follows the Global Constraints rule. - - Delete both pnpm files and run `bun install`. - - If the cooldown refuses a version that pnpm had locked, add a dated `bunfig.toml` exemption. Use the reasons from the deleted `pnpm-workspace.yaml`, and drop each one once it is past the window. - - Check `bun pm untrusted` against `trustedDependencies`. -- [ ] **Step 3: Fix tracing.** Set `outputFileTracingRoot: path.join(__dirname, '../..')`. -- [ ] **Step 4: Check tracing.** Run `bun run --cwd bench/web build`, then assert the trace of a route that queries the database lists the hoisted packages: - -```bash -grep -c 'node_modules/pg/' bench/web/.next/server/app/api/groups/route.js.nft.json # ≥ 1 -grep -c 'node_modules/@vercel/functions/' bench/web/.next/server/app/api/groups/route.js.nft.json # ≥ 1 -``` - -- [ ] **Step 5: Run bench's suite under the new install.** `bun run --cwd bench/web test` should report the same pass count as on `main` (401 passed at #124's merge). Also run `bun run --cwd bench/web lint` and `format:check`, which still use ESLint and Prettier until Task 5. -- [ ] **Step 6: Check the site still installs and builds.** Run `rm -rf node_modules && time bun install --frozen-lockfile` and `bun run build`. If the install is more than 15 s slower than the baseline, set `installCommand` in the root `vercel.json` to `bun install --frozen-lockfile --filter vortex`. Then repeat both commands. -- [ ] **Step 7: Switch CI and deploy to Bun.** - - **`bench-checks`:** replace the pnpm install and `setup-node`'s pnpm cache with setup-bun, `setup-node` (`.nvmrc`, no cache) and `bun install --frozen-lockfile` at the root. Every `pnpm X` step becomes `bun run --cwd bench/web X`. Delete the `pnpm-version` input, and drop `PNPM_VERSION` from both callers. - - **`bench-deploy.yml`:** `vercel build` must find Bun and the root lockfile. Install with setup-bun and `bun install --frozen-lockfile` before `vercel pull`. If `vercel build` still tries pnpm, set `installCommand` in `bench/web/vercel.json` (a repository file; headers and crons untouched). If neither works, stop and ask Will about the project setting. -- [ ] **Step 8: Reclassify shared root files** in `changed-paths`. Add `package.json | bun.lock | bunfig.toml | biome.json | tsconfig*.json) site=true; bench=true ;;` before the catch-all. Check it by running the classify script with each sample path set in `files`: - -``` -bench/web/lib/db.ts → site=false bench=true -bun.lock → site=true bench=true -src/app/page.tsx → site=true bench=false -``` - -- [ ] **Step 9: Update Renovate and the docs.** In `renovate.json`, the bench rule keeps `matchFileNames: ["bench/**", ".github/workflows/bench-*.yml"]`; drop any pnpm-specific option. Rewrite the bench parts of `CLAUDE.md`: the cooldown is now `bunfig.toml`, overrides live in the root `package.json`, `pnpm audit` becomes `bun audit`, and the zod override note goes in "Audit advisories". Rewrite the bench README, `AGENTS.md` and deploy doc commands from `pnpm` to `bun`. -- [ ] **Step 10: Commit:** `Make bench a member of the site's Bun workspace`. Run the parity harness first (`run.sh origin/main <dump>`); it must report 0 mismatches. - -### Task 4: Next.js 16, React 19.3 and the other updates - -**Files:** -- Modify: `bench/web/package.json`, `bun.lock`, `bench/web/app/api/revalidate/route.ts:29`, `bench/web/app/api/revalidate/route.test.ts`, plus whatever the upgrade guide requires -- Reference: `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md` - -**Interfaces:** -- Consumes: Task 3's commands. - -- [ ] **Step 1: Write the failing test.** In `route.test.ts`, mock `revalidateTag` with both arguments (`(tag: string, profile: unknown) => revalidateTag(tag, profile)`) and assert `expect(revalidateTag).toHaveBeenCalledWith(BENCH_DATA_TAG, { expire: 0 })` for an authorized POST. -- [ ] **Step 2: Run:** `bun run --cwd bench/web test app/api/revalidate` — expect FAIL. -- [ ] **Step 3: Upgrade.** Set `next` to `16.4.1` and `react` and `react-dom` to `^19.3.1`, matching the site. Bump `jsdom` to `^30`, the `@types/*` packages to the site's versions (`@types/node` stays `^24`), `pg`, `chart.js`, `@vercel/functions` and `vitest` to their newest versions that the cooldown allows. Then call `revalidateTag(BENCH_DATA_TAG, { expire: 0 })`. That is the old one-argument behavior; `'max'` would serve stale data after an ingest. -- [ ] **Step 4: Work through the v16 guide** for bench's surface: Turbopack builds, async request APIs, `next lint` removal (Task 5 replaces it), scroll behavior (bench sets no smooth scrolling) and `next/image` (unused). bench has no `middleware`, so there's nothing to rename. -- [ ] **Step 5: Run the suite, lint, build and parity.** The tests report the same pass count plus the new test. The build succeeds. Parity reports 0 mismatches. -- [ ] **Step 6: Commit:** `Upgrade bench to Next.js 16 and React 19.3`. - -### Task 5: Biome replaces ESLint and Prettier - -**Files:** -- Modify: `biome.json` (drop `!bench`; add a `bench/**` override; ignore `bench/web/.next`, `bench/web/next-env.d.ts`) -- Modify: `bench/web/package.json` (`lint: biome lint .`, `format: biome format --write .`, `format:check: biome format .`; remove `eslint`, `eslint-config-next`, `@eslint/eslintrc`, `prettier`) -- Delete: `bench/web/eslint.config.mjs`, `bench/web/.prettierrc.json` - -- [ ] **Step 1: Add the override.** It sets `javascript.formatter.quoteStyle: "single"`, `jsxQuoteStyle: "double"`, `trailingCommas: "all"` and `formatter.lineWidth: 100` for `bench/**`. -- [ ] **Step 2: Measure the reformat.** Run `bunx biome format --write bench` and `git diff --stat`. Expect a small diff, not a rewrite. If more than about 50 lines change, adjust the override until Biome's output matches the existing files. -- [ ] **Step 3: Lint.** Run `bunx biome lint bench` and fix what it reports without changing behavior. The site's rule configuration applies. Where a site rule fires on a deliberate bench pattern, disable it in the `bench/**` override with a one-line reason. Keep `correctness/useExhaustiveDependencies` and `correctness/useHookAtTopLevel` on, since they replace ESLint's React hooks rules. -- [ ] **Step 4: Run** `bun run check:ci` at the root, the bench suite and parity. All pass, with 0 mismatches. -- [ ] **Step 5: Commit:** `Lint and format bench with Biome`. Update CLAUDE.md "Toolchain" and the bench README's command list in the same commit. - -### Task 6: TypeScript 7 with a shared base config - -**Files:** -- Create: `tsconfig.base.json` -- Modify: `tsconfig.json`, `bench/web/tsconfig.json`, `bench/web/package.json` (`typescript: ^7.0.2`, as at the root) - -- [ ] **Step 1: Write the base.** It holds the options both configs share today: - - `strict`, `noEmit`, `esModuleInterop`, `module: "esnext"`, `moduleResolution: "bundler"`; - - `resolveJsonModule`, `isolatedModules`, `skipLibCheck`, `allowJs`, `incremental`; - - `noUnusedLocals`, `noUnusedParameters`, `lib: ["dom", "dom.iterable", "esnext"]`, and the `next` plugin. - - Both configs `extend` it and keep their own options: the site its `paths`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and the rest; bench its `paths`, `noFallthroughCasesInSwitch` and `noUncheckedSideEffectImports`. -- [ ] **Step 2: Type-check.** Run `bunx tsc --noEmit -p bench/web` and the root `bun run typecheck`, and fix only what TypeScript 7 reports. The stricter flags are not adopted here; they belong to PR 2. -- [ ] **Step 3: Run** the bench suite, both builds and parity. All pass, with 0 mismatches. -- [ ] **Step 4: Commit:** `Type-check bench with TypeScript 7 and a shared base config`. - -### Task 7: Verify, push and open the draft PR - -- [ ] **Step 1: Run the final parity check.** `bash bench/scripts/parity/run.sh origin/main ~/Backups/vortex-bench/vortex_bench-20261028T171815Z` must report 0 mismatches across the API and screenshots. Keep its summary for the PR. Then run `reuse lint`; every new file must be covered. -- [ ] **Step 2: Push** with `git push -u origin claude/bench-toolchain`. -- [ ] **Step 3: Open a draft PR.** - - The body is about 100 words in Simplified Technical English. It names the parity result, links the design doc, and keeps the details in the linked issue: #113 or a new one. - - Before posting, show Will the body. - - End it with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. -- [ ] **Step 4: Check the CI evidence:** - - `Bench: format, lint, build, test` passes. - - Lighthouse passes, which proves the zod 4 override doesn't break lhci. If it fails on zod, drop the override and record bench's zod as accepted (spec, Constraints). - - The `dependency-review` log shows it read the submitted snapshot, not "no snapshot". - - `Bench Deploy` builds the preview, and `verify-web-deploy.mjs` passes on it. -- [ ] **Step 5: Hand to Will** for the smoke test in the preview. diff --git a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md b/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md deleted file mode 100644 index b565bc9..0000000 --- a/bench/docs/plans/2026-10-29-cleanup-pr2-plan.md +++ /dev/null @@ -1,435 +0,0 @@ -<!-- -SPDX-License-Identifier: CC-BY-4.0 -SPDX-FileCopyrightText: Copyright the Vortex contributors ---> - -# bench PR 2: clean up bench's code — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** bench's code gets smaller and clearer: no IAM sign-in path, two queries per group instead of two per chart, `Chart.tsx` and `chart-store.ts` split into modules, and the site's stricter TypeScript flags. Nothing a visitor or API client sees changes. - -**Architecture:** One branch, `claude/bench-cleanup`, stacked on PR 1's `claude/bench-toolchain`, with one or two commits per task so a parity difference can be bisected. The group route builds every chart of a group from two statements run in parallel: the shared last-`n` commits, and the group's fact rows with each chart's commit total. It slices each chart's own window from the shared one. `chartPayload` keeps its per-chart SQL for `/api/chart/{slug}` and is the oracle the group tests compare against. The split moves code into `components/chart/` and `lib/chart-store/` without rewriting it. - -**Tech Stack:** Bun 1.4.3 workspace, Next.js 16.4.1, React 19.3.1, TypeScript 7, Biome 2.5, Vitest 4, node-postgres 8, Chart.js 4, Postgres 16 (testcontainers in CI; Homebrew `postgresql@16` 16.15 in `/opt/homebrew/opt/postgresql@16/bin` locally). - -**Spec:** `bench/docs/plans/2026-10-28-toolchain-and-cleanup.md` (read it first; this plan covers its "PR 2"). The style reference is `bench/docs/plans/2026-10-28-toolchain-pr1-plan.md`. - -**Starts from:** PR 1 has landed underneath (branch `claude/bench-toolchain`). `bench/web` is a member of the root Bun workspace, with one `bun.lock`. Bench scripts run as `bun run --cwd bench/web <script>`. Biome lints and formats bench, and `bench/web/tsconfig.json` extends `tsconfig.base.json`. `bash bench/scripts/parity/run.sh <base-ref> <dump-dir>` compares two builds (see `bench/scripts/parity/README.md`). This Mac has no Docker, so the integration suite skips locally until Task 1. - -## Global Constraints - -- "No change in behavior that a person could notice, and each PR proves it. Small bug fixes and subtle changes from library upgrades are acceptable if they are negligible; every difference the parity check finds is listed and explained in the PR." For PR 2: "Logic stays the same. The parity check and the tests prove it." -- Fail if "a person can notice a change in a page, API response, response header, cache rule or URL". `bench/web/vercel.json` "must not change". -- Group route: "The JSON must stay byte-identical." So `GROUP_PAYLOAD_CACHE_VERSION` in `lib/data-cache.ts` stays `'v9'`. -- `QUERY_CONCURRENCY`: "Keep the concurrency limit unless the new code no longer needs it." (It stays; see Task 3.) -- Splitting `Chart.tsx` and `chart-store.ts`: "Move code; don't rewrite it." -- Parity runs once, in Task 6, comparing the PR's base with the whole branch. Tasks have no parity step. On a difference, bisect the task commits with `run.sh` as the test. -- Vitest stays the test runner. No database schema, ingest contract (`bench/CONTRACT.md`) or AWS changes. The marketing site's build, behavior and deploy do not change. -- The supply-chain gates must not get weaker: "the 14-day cooldown, explicit overrides, the lifecycle-script allowlist, SHA-pinned actions, `ubuntu-24.04` runners". -- The `main` ruleset requires the job name `Bench: format, lint, build, test`. Keep it. -- "Settings changes need Will's confirmation first. That covers GitHub, Vercel and AWS settings." Deleting `BENCH_DB_REGION` from the Vercel project is such a change: list it in the PR, don't do it. -- Node 24 stays the runtime. -- bench keeps its style: single quotes, trailing commas everywhere, 100 columns, 2-space indent. -- Every commit uses `git commit -s` and ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Squash merges only. -- New code files under `bench/` start with `// SPDX-License-Identifier: Apache-2.0` and `// SPDX-FileCopyrightText: Copyright the Vortex contributors`. Markdown files use CC-BY-4.0. - -## Review Focus - -1. **A chart whose window differs from the rest of its group.** It has fewer commits than `n`, or its first row is on a commit whose timestamp ties with an earlier one, or it has no rows inside the window. `/api/group/{slug}` must still send it with the same bytes as `/api/chart/{slug}`. Task 3 pins this with `group payloads match chartPayload byte for byte at ?n=%s` and `gives each chart its own window`. -2. **A group takes rows from a sibling group.** Groups that share a dataset (another storage, or a NULL against a `'1'` scale factor) must stay apart, so the group statement must pin every column the chart statement pins. Task 3 pins this with `keeps sibling groups apart`. -3. **An environment without `BENCH_DB_PASSWORD`.** A Preview that has only `BENCH_DB_REGION` must fail at the first query, with an error that names `BENCH_DB_PASSWORD`. It must not try IAM. Task 2 pins this with `requires BENCH_DB_PASSWORD, even when BENCH_DB_REGION is set`. -4. **`/api/group/{slug}?n=all` on the 99-chart TPC-DS group.** One result set now holds the group's whole history (about 870,000 rows). The request must still finish within the 30 s `statement_timeout` and the function's memory. Task 3 Step 7 measures old and new code on the restored dump, and sets the fallback. -5. **A remounted chart after the split.** If a moved wheel or range-strip listener loses the controller's abort signal, it keeps panning a destroyed chart. If the moved config loses an option, the chart changes. Task 4 pins both with `pans once per wheel event and stops after unmount`. - ---- - -### Task 1: Run the integration suite against a local Postgres - -**Files:** -- Modify: `bench/web/lib/test-harness.ts` -- Create: `bench/web/lib/test-harness.test.ts` -- Modify: `bench/web/lib/db.test.ts`, `lib/queries.test.ts`, `lib/groups.test.ts`, `lib/health.test.ts`, `lib/server-smoke.test.ts` (imports, skip gate, type) -- Modify: `bench/web/README.md` ("Local development"), `CLAUDE.md` (the bench line that says the suite needs Docker) - -**Interfaces:** -- Produces in `lib/test-harness.ts`: - - `export interface BenchDatabase { stop(): Promise<void> }` - - `export interface TestDatabaseSettings { host: string; port: number; user: string; password: string; adminDatabase: string }` - - `export function testDatabaseSettings(url: string): TestDatabaseSettings` - - `export function integrationDatabaseAvailable(env: NodeJS.ProcessEnv = process.env, docker: () => boolean = dockerAvailable): boolean`. `dockerAvailable` stops being exported. - - `export async function startBenchDatabase(options: { applySchema?: boolean } = {}): Promise<BenchDatabase>` replaces `startBenchContainer`. - - Unchanged: `seedChartFixture`, `COMMITS`, `TREE_SHA`, `commitUrl`. -- Env var `BENCH_TEST_DATABASE_URL`: a `postgres://` URL of an existing server, as a superuser, naming its admin database. - -- [ ] **Step 1: Write the failing tests** in `lib/test-harness.test.ts`: - -```ts -describe('testDatabaseSettings', () => { - it('reads host, port, user and admin database, with a placeholder password', () => { - expect(testDatabaseSettings('postgres://postgres@127.0.0.1:55433/postgres')).toEqual({ - host: '127.0.0.1', port: 55433, user: 'postgres', password: 'trust', adminDatabase: 'postgres', - }); - }); - it('decodes the password and defaults the port and admin database', () => { - expect(testDatabaseSettings('postgresql://will:p%40ss@localhost')).toEqual({ // trufflehog:ignore - host: 'localhost', port: 5432, user: 'will', password: 'p@ss', adminDatabase: 'postgres', - }); - }); - it.each(['mysql://u@h/db', 'postgres://h/db'])('rejects %s', (url) => { - expect(() => testDatabaseSettings(url)).toThrow('BENCH_TEST_DATABASE_URL'); - }); -}); -describe('integrationDatabaseAvailable', () => { - it('prefers BENCH_TEST_DATABASE_URL, then Docker', () => { - expect(integrationDatabaseAvailable({ BENCH_TEST_DATABASE_URL: 'postgres://u@h/db' }, () => false)).toBe(true); - expect(integrationDatabaseAvailable({}, () => true)).toBe(true); - expect(integrationDatabaseAvailable({}, () => false)).toBe(false); - }); - it('fails loudly in CI when no database is available', () => { - expect(() => integrationDatabaseAvailable({ CI: 'true' }, () => false)).toThrow('BENCH_TEST_DATABASE_URL'); - }); -}); -``` - -- [ ] **Step 2: Run** `bun run --cwd bench/web test lib/test-harness.test.ts`. Expect FAIL: the exports are missing. -- [ ] **Step 3: Implement.** - - `testDatabaseSettings` uses `new URL()`. It accepts only the `postgres:` and `postgresql:` schemes and requires a user. The port defaults to 5432 and the admin database to `postgres`. - - With no password in the URL, the password is the literal `'trust'`. `BENCH_DB_PASSWORD` must not be empty (and is required after Task 2), and trust auth ignores the value. - - `integrationDatabaseAvailable` returns true when `BENCH_TEST_DATABASE_URL` is set and not empty, otherwise `docker()`. When `env.CI === 'true'` and both are missing, it throws an error that names Docker and `BENCH_TEST_DATABASE_URL`. That stops GitHub Actions from passing with the database suites skipped. - - When the URL is set, `startBenchDatabase` opens an admin `pg.Client` and takes `pg_advisory_lock(0x62656e63, 0x74657374)`. Under the lock it runs `CREATE DATABASE "bench_test_<pid>_<8 random hex>"` and, unless `applySchema === false`, the same `uv run --no-project migrate-schema.py apply` as today, with `PG*` pointing at the new database. Then it unlocks. The lock is needed because the migrations create cluster-wide roles, and two first applies at once race on `CREATE ROLE`. - - It then sets `BENCH_DB_HOST`, `_PORT`, `_NAME`, `_USER`, `_PASSWORD` and `BENCH_DB_SSL=disable`. `stop()` runs `DROP DATABASE IF EXISTS "<name>" WITH (FORCE)` from a fresh admin client. If a migration fails, drop the database and rethrow. - - Without the URL, it keeps today's testcontainers path and wraps it as `{ stop: async () => { await container.stop(); } }`. -- [ ] **Step 4: Update the five DB suites.** `dockerAvailable()` becomes `integrationDatabaseAvailable()`, `startBenchContainer` becomes `startBenchDatabase`, and the `StartedPostgreSqlContainer` type becomes `BenchDatabase`. `server-smoke.test.ts` keeps its `|| !BUILD_PRESENT`. -- [ ] **Step 5: Document the local database.** In the README's "Local development", add these commands, and change the `test` comment to "needs Docker, or `BENCH_TEST_DATABASE_URL`, and uv": - -```bash -PG=/opt/homebrew/opt/postgresql@16/bin -$PG/initdb -D "$TMPDIR/bench-test-pg" -U postgres -A trust -E UTF8 --locale=C -$PG/pg_ctl -D "$TMPDIR/bench-test-pg" -l "$TMPDIR/bench-test-pg.log" -w start \ - -o "-p 55433 -c listen_addresses=127.0.0.1 -c unix_socket_directories= -c fsync=off" -export BENCH_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres -``` - - Port 55433 stays clear of the parity cluster on 55432. `--locale=C` matches the CI container's collation. Update CLAUDE.md's bench line to match. -- [ ] **Step 6: Verify.** - - `bun run --cwd bench/web test lib/test-harness.test.ts` passes. - - Run `bun run --cwd bench/web build`, then the whole suite with the URL exported. Every file passes, and the summary line shows no `skipped`. - - `psql -h 127.0.0.1 -p 55433 -U postgres -Atc "SELECT count(*) FROM pg_database WHERE datname LIKE 'bench_test_%'"` prints `0`. - - Unset the URL and run again: the database suites skip, as before. - - Record the pass count; later tasks compare against it. -- [ ] **Step 7: Commit:** `Let bench's integration tests use a local Postgres`. - -### Task 2: Remove the IAM sign-in path - -**Files:** -- Modify: `bench/web/lib/db.ts`, `bench/web/lib/db.test.ts` -- Modify: `bench/web/package.json` (remove `@aws-sdk/rds-signer`), `bun.lock` -- Modify: `bench/web/README.md` (env table; "Deploys" step 3 "Auth" bullet), `bench/docs/runbooks/deploy-secrets-setup.md` (env table) - -**Interfaces:** -- Produces: `getPool()` reads `BENCH_DB_PASSWORD` through `requireEnv`. When it is unset or empty, `getPool()` throws ``Missing required environment variable `BENCH_DB_PASSWORD` for the benchmarks DB connection.`` at first use. `next build` still never reads it. -- Removed: `passwordProvider`, `DbConfig.region`, `DbConfig.staticPassword` and the `Signer` import. `DbConfig` gains `password: string` and stops being exported. The pool receives the password as a string. - -- [ ] **Step 1: Write the failing tests** in the `singleton pool configuration` block of `db.test.ts`. Keep `BENCH_DB_REGION` in `ENV_KEYS` so it is restored afterwards. - -```ts -it('requires BENCH_DB_PASSWORD, even when BENCH_DB_REGION is set', () => { - configurePoolEnvironment(); - process.env.BENCH_DB_REGION = 'us-east-1'; - for (const value of [undefined, '']) { - if (value === undefined) delete process.env.BENCH_DB_PASSWORD; - else process.env.BENCH_DB_PASSWORD = value; - expect(() => getPool()).toThrow( - 'Missing required environment variable `BENCH_DB_PASSWORD` for the benchmarks DB connection.', - ); - } -}); -it('hands the password to pg as a string', () => { - configurePoolEnvironment(); - const pool = getPool() as unknown as { options: { password?: unknown } }; - expect(pool.options.password).toBe('fixture-pw'); -}); -``` - -- [ ] **Step 2: Run** `bun run --cwd bench/web test lib/db.test.ts -t 'BENCH_DB_PASSWORD|password to pg'`. Expect both to FAIL: the first gets a pool from the mocked signer, and the second sees a function. -- [ ] **Step 3: Implement.** In `readConfig`, set `password: requireEnv('BENCH_DB_PASSWORD')` right after `user`. In `createPool`, pass `password: config.password`. Rewrite the `DbConfig` doc comment: the app signs in with a password, and production uses the read-only `bench_read` role. In the test file, delete the `db IAM auth path (mocked rds-signer)` block, the `vi.hoisted` mock and the `vi.mock('@aws-sdk/rds-signer')`. -- [ ] **Step 4: Drop the package.** Remove the dependency and run `bun install` at the root. Check that `git diff --stat bun.lock` shows only removed lines (the signer and its `@aws-sdk`, `@aws-crypto` and `@smithy` packages), and that `grep -c rds-signer bun.lock` prints `0`. -- [ ] **Step 5: Update the docs.** - - In the README env table, the `BENCH_DB_PASSWORD` row becomes required: "Password for the role in `BENCH_DB_USER` (`bench_read` in production)." Delete the `BENCH_DB_REGION` row. Fix the stale `SUMMARY_CONCURRENCY` in the `BENCH_DB_POOL_MAX` row to `QUERY_CONCURRENCY`. - - Rewrite the "Auth" bullet: the app signs in only with `BENCH_DB_PASSWORD`. `bench_read` has no `rds_iam` grant (migration `005`). Moving the read path to IAM would need app code, a migration and AWS credentials in the function runtime. - - In the runbook, `BENCH_DB_PASSWORD` becomes "Password for the `bench_read` role (required)". Delete the `BENCH_DB_REGION` row. -- [ ] **Step 6: Verify.** - - The suite passes with your Task 1 baseline minus 3 (the IAM tests) plus 2. - - `bun run --cwd bench/web build` succeeds. - - `grep -rn -e rds-signer -e BENCH_DB_REGION -e passwordProvider bench/web bench/docs/runbooks bench/docs/architecture CLAUDE.md` prints nothing. -- [ ] **Step 7: Commit:** `Remove bench's unused IAM sign-in path`. Say in the message that `BENCH_DB_REGION` on the Vercel project is now unused, and that deleting it is Will's settings change. - -### Task 3: One query per group - -**As built.** The steps below are the plan as written. The code differs from them here: - -- `groupChartPayloads` runs its statements one after another on one `getPool().connect()` client, in one `REPEATABLE READ, READ ONLY` transaction, not in parallel with `Promise.all` on the pool. Every statement sees one snapshot, so an ingest that lands mid-build cannot make the shared window and a chart's commit total disagree. -- The rows statement runs in slices of `GROUP_ROWS_CHUNK = 25` charts, so the 99-chart TPC-DS group runs five. As one result, TPC-DS at `?n=all` (about 2.7M rows) peaked at 1.6× the memory of the old per-chart fan-out. -- The SQL differs from Step 4 in four places. On one connection, every chart's cost is paid in turn: - - A chart's first timestamp comes from an aggregate `LATERAL` subquery (`e`). The planner cannot flatten an aggregate into the outer query and run it once per output row. - - `total_commits` is a binary search, `width_bucket` over the sorted commit timestamps, not a `count(*)` of `commits` per chart (99 scans for TPC-DS). - - For query charts, the first timestamp comes from a skip scan of `idx_query_measurements_summary` with one descent per series (`queryChartEarliest`), not from a `MIN` that reads the chart's whole history (about 27K rows per TPC-DS chart). - - The compression families put NULL-variant and non-NULL-variant charts in separate blocks, each pinned sargably. `IS NOT DISTINCT FROM ch.dataset_variant` planned as a one-row match and rescanned the window for each fact row. -- `groups.test.ts`'s pool-size test is now `keeps a cached group fill within the pool size`. It counts connection requests during a fill under the advisory lock, and the bound is 8: the lock and seven summaries. - -**Files:** -- Modify: `bench/web/lib/queries.ts` -- Create: `bench/web/lib/group-charts.test.ts` -- Modify: `bench/docs/architecture/performance.md` (section "Parallel group fan-out") - -**Interfaces:** -- Consumes from Task 1: `integrationDatabaseAvailable()`, `startBenchDatabase()`, `seedChartFixture()`, `TREE_SHA`, `commitUrl()`. -- Produces in `lib/queries.ts`: - - `export function historyFor(totalCommits: number, window: CommitWindow): ChartHistory` - - `export type FactRow = { commit_sha: string; value: number; engine?: string; format?: string; op?: string; open_mode?: 'cached' | 'reopen'; flavor?: string }` - - `export function seriesFor(kind: ChartKind, row: FactRow): { key: string; engine: string | undefined; format: string | undefined }` - - `export async function groupChartPayloads(group: GroupKey, charts: readonly ChartKey[], window: CommitWindow): Promise<(ChartResponse | null)[]>`. The result follows the order of `charts`. A chart with no commits gives `null`. Every key must belong to `group`. - - New private helpers: `chartDisplayName(key: ChartKey): string`, `chartUnitKind(key: ChartKey): UnitKind`, `windowCommits(window: CommitWindow): Promise<CommitPoint[]>`, `groupRowsQuery(group: GroupKey, charts: readonly ChartKey[], window: CommitWindow): { text: string; values: unknown[] }`. -- Unchanged signatures: `chartPayload`, `collectGroupCharts`, `collectGroups`. `QUERY_CONCURRENCY` and `mapWithConcurrency` stay and now bound only the summary fan-out in `collectGroups`. - -- [ ] **Step 1: Write the unit tests** (no database) in `group-charts.test.ts`: - -```ts -it('bounds a long history to the window', () => { - expect(historyFor(125, { kind: 'last', n: 100 })).toEqual({ total_commits: 125, start_index: 25, loaded_commits: 100, complete: false }); -}); -it('keeps a short history whole', () => { - expect(historyFor(3, { kind: 'last', n: 100 })).toEqual({ total_commits: 3, start_index: 0, loaded_commits: 3, complete: true }); -}); -it('loads everything for ?n=all', () => { - expect(historyFor(125, { kind: 'all' })).toEqual({ total_commits: 125, start_index: 0, loaded_commits: 125, complete: true }); -}); -it('reports an empty history as complete', () => { - expect(historyFor(0, { kind: 'last', n: 100 })).toEqual({ total_commits: 0, start_index: 0, loaded_commits: 0, complete: true }); -}); -it.each([ - ['QueryMeasurement', { engine: 'duckdb', format: 'parquet' }, { key: 'duckdb:parquet', engine: 'duckdb', format: 'parquet' }], - ['CompressionTime', { format: 'parquet', op: 'encode' }, { key: 'parquet:encode', engine: undefined, format: 'parquet' }], - ['CompressionSize', { format: 'lance' }, { key: 'lance', engine: undefined, format: 'lance' }], - ['RandomAccess', { format: 'parquet', open_mode: 'reopen' }, { key: 'parquet:cold', engine: undefined, format: 'parquet' }], - ['VectorSearch', { flavor: 'vortex-turboquant' }, { key: 'vortex-turboquant', engine: undefined, format: undefined }], -] as const)('names the %s series', (kind, cols, expected) => { - expect(seriesFor(kind, { commit_sha: 'a', value: 1, ...cols })).toEqual(expected); -}); -``` - -- [ ] **Step 2: Write the integration tests.** There are two `describe.skipIf(!integrationDatabaseAvailable())` blocks, each with its own `startBenchDatabase()`. `expectedCharts(group, window)` builds the oracle: `JSON.stringify` of `{ name, slug, ...payload }` from `chartPayload(chartKeyFromSlug(link.slug), window)` for each link, nulls dropped. - - **Canonical fixture** (`seedChartFixture`): - - `it.each(['1', '2', '100', 'all'])('every group matches chartPayload byte for byte at ?n=%s')` checks `JSON.stringify((await collectGroupCharts(groupKeyFromSlug(g.slug), w))?.charts ?? []) === await expectedCharts(g, w)` for every group from `collectGroups()`. - - `it('issues two statements for a group of any size')` wraps `vi.spyOn(getPool(), 'query')` around `groupChartPayloads` for the TPC-H group and expects `toHaveBeenCalledTimes(2)`. - - `it('returns null in place of a chart with no rows')` passes `[Q1, { ...Q1, query_idx: 99 }]`. It expects `[1]` to be `null`, and `[0]` to match `chartPayload(Q1)` byte for byte. - - **Window-edge fixture.** Commits in order: `C0` 06-01, `C1` 06-02, `C2` 06-03, `D` and `E` both 06-04T00:00Z (SHAs `'d'.repeat(40)` and `'e'.repeat(40)`), `F` 06-05. Query rows have `commit_timestamp` stamped as the writers do. - - Group G is `('grp', NULL, NULL, 'nvme')`. Q1 has `e1:f1` = 10, 20, …, 60 and `e2:f1` on every commit. Q2 has `e1:f1` on E and F only. Q3 has `e1:f1` on C0 only. - - Group S is `('grp', NULL, '1', 'nvme')`, with Q1 `e1:f1` = 100, …, 105. - - Group T is `('grp', NULL, NULL, 's3')`, with Q1 `e1:f1` = 200, …, 205. - - Tests on the window-edge fixture: - - `it.each(['1', '2', '3', '4', 'all'])('group payloads match chartPayload byte for byte at ?n=%s')` covers G, S and T. - - `it('gives each chart its own window')` checks G at `?n=4`: - - Q1's history is `{6, 2, 4, false}`. - - Q2's history is `{3, 0, 3, true}`, and its commit SHAs are `[D, E, F]`. D is included because it ties with Q2's first row. - - Q3's series is `{}` and its history is `{6, 2, 4, false}`. - - `it('keeps sibling groups apart')`: at `?n=all`, G's Q1 `e1:f1` is `[10, 20, 30, 40, 50, 60]` and S's is `[100, 101, 102, 103, 104, 105]`. -- [ ] **Step 3: Run** `bun run --cwd bench/web test lib/group-charts.test.ts` with `BENCH_TEST_DATABASE_URL` set. Expect FAIL: `groupChartPayloads`, `historyFor` and `seriesFor` are not exported. -- [ ] **Step 4: Implement.** - - Lift `chartDisplayName`, `chartUnitKind` and `seriesFor` out of the five collectors, and make the collectors call them. Their SQL does not change. - - `historyFor`: `loaded = limit === null ? total : Math.min(limit, total)`, `start_index = Math.max(0, total - loaded)`, `complete = loaded === total`. - - `windowCommits` runs the following, with `$1 = commitWindowLimit(window)`. For `?n=all` that is `null`, which Postgres reads as no limit. - -```sql -SELECT commit_sha, to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS"+00"') AS ts_text, - COALESCE(message, '') AS message, url - FROM (SELECT * FROM commits ORDER BY timestamp DESC, commit_sha DESC LIMIT $1) w - ORDER BY timestamp ASC, commit_sha ASC -``` - - - `groupRowsQuery` builds one row per (chart, fact row in its window). A chart with no rows in the window still gets one row with NULL fact columns. Here is the `QueryGroup` shape; build each pin with `sargableDimEq`. - -```sql -SELECT ch.ord::int AS ord, t.total_commits, d.commit_sha, d.engine, d.format, d.value - FROM unnest($1::int[]) WITH ORDINALITY AS ch(query_idx, ord) - CROSS JOIN LATERAL ( - SELECT (SELECT count(*) FROM commits c - WHERE c.timestamp >= (SELECT MIN(q2.commit_timestamp) FROM query_measurements q2 - WHERE q2.dataset = $2 AND <q2 variant pin> AND <q2 scale pin> - AND q2.storage = $n AND q2.query_idx = ch.query_idx))::int AS total_commits - ) t - LEFT JOIN LATERAL ( - SELECT q.commit_sha, q.engine, q.format, q.value_ns::float8 AS value - FROM query_measurements q - WHERE q.dataset = $2 AND <q variant pin> AND <q scale pin> AND q.storage = $n - AND q.query_idx = ch.query_idx<queryMeasurementWindowFilter(params, window)> - ) d ON true -``` - - The other families use the same shape. Copy each chart collector's own `MIN` subquery and row query, and replace its per-chart binds with `ch.<column>`: - - | Group kind | `ch` columns (bound as arrays) | Pins from the group key | Row columns | Window filter | - |---|---|---|---|---| - | `QueryGroup` | `query_idx int[]` | dataset, dataset_variant, scale_factor (sargable), storage | engine, format | `queryMeasurementWindowFilter` | - | `CompressionTimeGroup` | `dataset text[]`, `dataset_variant text[]` | none | format, op | `factWindowFilter`, with `JOIN commits c USING (commit_sha)` | - | `CompressionSizeGroup` | same | none | format | same | - | `RandomAccessGroup` | `dataset text[]` | none (the datasets carry the storage) | format, open_mode | same | - | `VectorSearchGroup` | `threshold float8[]` | dataset, layout | flavor | same | - - - In the compression families, `dataset_variant` varies per chart, so match it with `IS NOT DISTINCT FROM ch.dataset_variant`. `dataset` still leads the index, and those tables are small (380K and 170K rows). - - The rows statement has no `ORDER BY`. The output doesn't depend on row order: each (series, commit) cell is unique by the fact tables' natural keys, and `finish()` sorts series keys. - - `groupChartPayloads` returns `[]` for no charts. Otherwise it runs `Promise.all([windowCommits(window), getPool().query(groupRowsQuery(...))])` and groups the rows by `ord`. For each chart: - - `total === 0` gives `null`. - - Otherwise compute `history = historyFor(total, window)`, then seed `commits.slice(commits.length - history.loaded_commits)` into a fresh `SeriesAccumulator`. - - For each row with a non-null `commit_sha` whose SHA is seeded, call `record` and `tag` with `seriesFor(key.k, row)`. - - Finish with `chartDisplayName(key)`, `chartUnitKind(key)` and `history`. - - Why this is correct: a chart's eligible commits (timestamp at or after its first row) are a suffix of all commits in (timestamp, sha) order. So its last-`n` window is the last `min(n, total)` entries of the shared last-`n` window, ties included, which is exactly what `seededCommitsInWindow` returns. - - `collectGroupCharts` replaces its `mapWithConcurrency` fan-out with one `groupChartPayloads(key, group.charts.map((l) => chartKeyFromSlug(l.slug)), window)`. It zips the result with the links as `{ name, slug, ...payload }` and drops nulls. Update the doc comments of `QUERY_CONCURRENCY` and `collectGroupCharts`. -- [ ] **Step 5: Run** the new file, then the whole suite. Expect PASS at the Task 2 count plus the new tests. `groups.test.ts`'s `keeps a group fill within the pool size` (peak at most 7) still passes. -- [ ] **Step 6: Check bytes and timing on the restored dump.** - - Start the parity cluster: `/opt/homebrew/opt/postgresql@16/bin/pg_ctl -D "$TMPDIR/bench-parity/pgdata" -l "$TMPDIR/bench-parity/postgres.log" -w start -o "-p 55432 -c listen_addresses=127.0.0.1 -c unix_socket_directories="`. - - Save the script below as `$TMPDIR/group-check.ts`. From `bench/web`, run it with `env BENCH_DB_HOST=127.0.0.1 BENCH_DB_PORT=55432 BENCH_DB_NAME=vortex_bench BENCH_DB_USER=postgres BENCH_DB_PASSWORD=parity BENCH_DB_SSL=disable N=100 bun -e "$(cat "$TMPDIR/group-check.ts")"`. If Bun can't resolve `./lib` from `-e`, copy the script into `bench/web` for the run and delete it afterwards. - -```ts -import { createHash } from 'node:crypto'; -import { resetPool } from './lib/db.ts'; -import { chartPayload, collectGroups, groupChartPayloads } from './lib/queries.ts'; -import { chartKeyFromSlug, groupKeyFromSlug } from './lib/slug.ts'; -import { parseCommitWindow } from './lib/window.ts'; -const w = parseCommitWindow(process.env.N ?? '100'); -const mode = process.env.MODE ?? 'both'; -const digest = (v: unknown) => createHash('sha256').update(JSON.stringify(v)).digest('hex').slice(0, 12); -for (const g of await collectGroups()) { - if (process.env.GROUP && g.name !== process.env.GROUP) continue; - const keys = g.charts.map((l) => chartKeyFromSlug(l.slug)); - const line: unknown[] = [g.name, keys.length]; - if (mode !== 'new') { - const t = performance.now(); const old: unknown[] = []; - for (let i = 0; i < keys.length; i += 7) old.push(...(await Promise.all(keys.slice(i, i + 7).map((k) => chartPayload(k, w))))); - line.push(`old ${Math.round(performance.now() - t)} ms ${digest(old)}`); - } - if (mode !== 'old') { - const t = performance.now(); const now = await groupChartPayloads(groupKeyFromSlug(g.slug), keys, w); - line.push(`new ${Math.round(performance.now() - t)} ms ${digest(now)}`); - } - console.log(line.join(' ')); -} -await resetPool(); -``` - - - What passes: run `N=100` and `N=50` twice each and read the second run, which is warm. On every line the old and new digests match. For the five TPC-DS groups and Clickbench, new ms is no more than old ms. - - Then, with `GROUP='TPC-DS (NVMe) (SF=1)' N=all`, run `MODE=old` and `MODE=new` separately under `/usr/bin/time -l`. The digests match, and new's wall time and "maximum resident set size" are each at most 1.5× old's. - - If not, run the rows statement in slices of 25 charts (`GROUP_ROWS_CHUNK = 25`), one after another, and measure again. - - Stop the cluster with `pg_ctl -D "$TMPDIR/bench-parity/pgdata" -m fast stop`, and keep the numbers for the docs and the PR. -- [ ] **Step 7: Update `performance.md`.** Rename "Parallel group fan-out" to "One query per group". In about eight lines, cover the two statements, the window slicing, `QUERY_CONCURRENCY` now bounding only the summaries, and the Step 6 numbers. -- [ ] **Step 8: Verify** `bun run --cwd bench/web lint`, `format:check`, `bunx tsc --noEmit -p bench/web` and `bun run --cwd bench/web build`. **Commit:** `Fetch a bench group's charts with two queries, not two per chart`. - -### Task 4: Split `Chart.tsx` and `chart-store.ts` into modules - -**Files:** -- Create: `bench/web/lib/chart-store/queues.ts`, `bundles.ts`, `global-filter.ts`, `group-store.ts`, `index.ts` -- Delete: `bench/web/lib/chart-store.ts` -- Create: `bench/web/components/chart/types.ts`, `datasets.ts`, `tooltip.ts`, `config.ts`, `card-dom.ts`, `pan-zoom.ts`, `controller.ts` -- Modify: `bench/web/components/Chart.tsx` -- Test: `bench/web/components/Chart.loading.test.tsx` (one new test) - -**Interfaces:** -- `'@/lib/chart-store'` keeps its exact export surface through `lib/chart-store/index.ts`, which holds `export * from` each module. No importer or test changes; `vi.spyOn(hydrationQueue, 'schedule')` still sees the same object. -- Produces in `components/chart/` (no `'use client'`; `Chart.tsx` stays the client boundary): - - `types.ts`: `export interface BenchDataset`, `CardState`, `CardElements`, `CardCallbacks` (unchanged bodies) - - `datasets.ts`: `export function benchDatasets(chart: ChartJs): BenchDataset[]`; `export function buildDatasets(payload: NormalizedChartPayload): BenchDataset[]` - - `tooltip.ts`: `export function externalTooltipHandler(state: CardState, host: HTMLDivElement | null): (context: { chart: ChartJs; tooltip: TooltipModel<'line'> }) => void` - - `config.ts`: `GRID_COLOR`, `crosshairPlugin`, and `export function lineChartConfig(input: { Chart: typeof ChartJs; state: CardState; tooltipHost: HTMLDivElement | null; labels: string[]; datasets: BenchDataset[]; range: VisibleRange; legendPosition: 'top' | 'bottom'; onZoom: (chart: ChartJs) => void }): ChartConfiguration<'line', (number | null)[], string>` - - `card-dom.ts`: `renderWindowChip(chip: HTMLButtonElement | null, state: CardState): void`; `renderDownsampleBadge(badge: HTMLSpanElement | null, keptCommits: number, visibleCommits: number, anyDownsampled: boolean): void`; `setSliderFromRange(slider: HTMLInputElement | null, visibleCommits: number): void`; `setSliderBounds(slider: HTMLInputElement | null, commitCount: number): void` - - `pan-zoom.ts`: `attachWheelPan(canvas: HTMLCanvasElement, chart: ChartJs, state: CardState, rebuild: (chart: ChartJs) => void, signal: AbortSignal): void`; `bindRangeStrip(chart: ChartJs, els: Pick<CardElements, 'strip' | 'stripWindow'>, state: CardState, rebuildRange: (chart: ChartJs, min: number, max: number) => void, signal: AbortSignal): void` - - `controller.ts`: `export class ChartController`, with the same constructor and public methods - -Where code goes. Line numbers are as of `702aeeb`; PR 1's reformat may shift them, so the symbol names decide. - -| Destination | Moves | -|---|---| -| `lib/chart-store/queues.ts` | `QueueEntry`, `InternalEntry`, `TaskQueue`, `makeQueue`, `hydrationQueue`, `fullHistoryQueue`, `bundleQueue` (37–118) | -| `lib/chart-store/bundles.ts` | payload cache, `BundleResult`, settled and in-flight maps, `subscribeGroupBundleRetry`, `retryGroupBundle`, `resetPayloadCache`, `ensureGroupBundle`, `abortGroupBundle` (120–306) | -| `lib/chart-store/global-filter.ts` | `GlobalFilterSnapshot` through `toggleGlobalFilterValue` (308–388) | -| `lib/chart-store/group-store.ts` | `GroupSnapshot` through `noteGroupSeries` (390–632) | -| `components/chart/types.ts` | 108–178 | -| `components/chart/config.ts` | `GRID_COLOR`, `crosshairPlugin` (180–217), and the config literal of `new Chart(canvas, {...})` in `maybeConstruct` (915–1044) | -| `components/chart/datasets.ts` | `benchDatasets`, `buildDatasets` (219–270) | -| `components/chart/tooltip.ts` | `externalTooltipHandler` (272–395) | -| `components/chart/card-dom.ts` | bodies of `syncSliderFromRange`, `syncDownsampleBadge`, `syncWindowChip`, `syncSliderBounds` (1223–1326, 1379–1397) | -| `components/chart/pan-zoom.ts` | bodies of `attachWheelPan`, `bindRangeStrip` (1399–1607) | -| `components/chart/controller.ts` | the rest of `ChartController` (397–1753) | -| `components/Chart.tsx` keeps | `'use client'`, the architecture comment, `ChartIslandProps`, `Chart` (1755–2200) | - -Moves are verbatim, with their comments. The only other edits allowed: -- `export` keywords and import lists. -- In moved methods, `this.state`, `this.els()` and `this.aborter.signal` become parameters. -- Each moved private method leaves a one-line delegator with the same name, so no call site inside the controller changes. -- `maybeConstruct` calls `lineChartConfig({...})`, and the `state_` alias goes. -- In `group-store.ts`, the two reads of the module variable `globalSnapshot` become `getGlobalFilterSnapshot()`. - -- [ ] **Step 1: Write the pin test** in `Chart.loading.test.tsx`: `it('pans once per wheel event and stops after unmount')`. - - Render one card in an open group under `<StrictMode>`. The bundle returns 404, and the chart fetch returns `completePayload(20)`. - - Resolve `loadChartJs` to a `StubChart` that keeps `config.options` and has `pan = vi.fn()`, `update()` and `destroy()`. - - After construction, assert `stub.options.plugins.tooltip.enabled === false` and that `stub.options.plugins.zoom.limits.x` equals `{ min: 0, max: 19, minRange: 4 }`. - - Dispatch `new WheelEvent('wheel', { deltaY: 40, cancelable: true })` on the canvas. Expect `pan` to be called once, with `({ x: 20 }, undefined, 'none')`. - - Unmount, dispatch again on the same canvas node, and expect still one call. -- [ ] **Step 2: Run it on the unsplit code.** Expect PASS: it pins today's behavior. -- [ ] **Step 3: Split `chart-store.ts`** following the table, and run the whole suite. Expect the Task 3 count. Lint and type-check. **Commit:** `Split bench's chart store into modules`. -- [ ] **Step 4: Split `Chart.tsx`** following the table. -- [ ] **Step 5: Verify.** - - The suite passes at the Task 3 count plus 1, and lint, type-check and build pass. - - `wc -l` shows `Chart.tsx` under 600 lines, `controller.ts` under 950, and every other new module under 300. - - `git diff --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change HEAD -- bench/web/components` dims everything except imports, exports, delegators and the parameter substitutions. -- [ ] **Step 6: Commit:** `Split bench's chart island into modules`. - -### Task 5: Remove test-only helpers and adopt the site's stricter flags - -**Files:** -- Modify: `bench/web/lib/chart-format.ts` and `lib/chart-format.test.ts` (delete `PALETTE`, `colorFor`, `assignStableColors`, `labelForCommit` and their tests); `lib/window.ts` and `lib/window.test.ts` (delete `commitWindowUrlValue` and its test) -- Modify: `tsconfig.base.json` (add `"noUncheckedIndexedAccess": true`, `"exactOptionalPropertyTypes": true`), `tsconfig.json` (remove both; it inherits them) -- Modify: the files tsc reports. Measured on 2026-10-28 with TS 5.9 on the unsplit tree: 138 + 7 = 145 errors. - - About 33 are in app code: `lib/chart-format.ts` 20, `lib/queries.ts` 2, the four from `Chart.tsx` (now in `components/chart/*`), `app/page.tsx` 2, `app/chart/[slug]/page.tsx` 1, `components/GroupSection.tsx` 1, `lib/random-access.ts` 1, `lib/db.ts` 1, `lib/test-harness.ts` 1. - - About 112 are in tests: `groups.test.ts` 36, `Chart.lazy-hydration.test.tsx` 29, `summary.test.ts` 23, `Chart.loading.test.tsx` 9, `cache-fill.test.ts` 5, and a few in others. - -**Interfaces:** -- Consumes: Task 3's `queries.ts` and Task 4's modules. This task goes last so its edits land on their final locations. - -- [ ] **Step 1: Delete the helpers only tests use.** Check with `grep -rlw -e assignStableColors -e colorFor -e PALETTE -e labelForCommit -e commitWindowUrlValue bench/web/app bench/web/components bench/web/lib`: only the defining files and their tests may appear. Delete the helpers and their tests. Run the suite. **Commit:** `Remove bench helpers that only tests used`. -- [ ] **Step 2: Move the flags into the base.** `bunx tsc --showConfig -p .` must list both flags as `true` before and after. Then run `bunx tsc --noEmit -p bench/web` and record the error count, about 145. -- [ ] **Step 3: Fix without changing behavior.** - - For `exactOptionalPropertyTypes`, add `| undefined` to the optional property's declared type (`summary?: Summary | undefined`, `benchMeta`, `__benchDbPool?: Pool | undefined`). Never change which keys an object carries. - - For `noUncheckedIndexedAccess` in app code, use `!` only where the index is in range by construction: a loop bound, a prior length check, `% length`, or `split()[0]`. Otherwise add an explicit `=== undefined` check whose branch does what today's code does with `undefined`. Never add a new `?? 0`, `?? ''` or `?? []`. - - In tests, use `!` or the existing `expectDefined`. -- [ ] **Step 4: Verify.** - - `bunx tsc --noEmit -p bench/web` and the root `bun run typecheck` both report 0 errors. - - The suite passes at the count after Step 1. - - `bun run check:ci`, `bun run --cwd bench/web build` and `bun run build` pass. -- [ ] **Step 5: Commit:** `Type-check bench with the site's stricter flags`. - -### Task 6: Verify, push and open the draft PR - -- [ ] **Step 1: Run the local gates.** - - Build, then run the whole suite with `BENCH_TEST_DATABASE_URL` set. Everything passes, with nothing skipped. - - `bun run check:ci`, `bunx tsc --noEmit -p bench/web`, `bun run typecheck`, `bun run --cwd bench/web build` and `bun run build` pass. - - `uvx reuse lint` passes. - - `git diff --exit-code origin/claude/bench-toolchain -- bench/web/vercel.json` exits 0. -- [ ] **Step 2: Run parity once.** `bash bench/scripts/parity/run.sh origin/claude/bench-toolchain ~/Backups/vortex-bench/vortex_bench-20261028T171815Z` must print `parity: 0 mismatches`. - - If PR 1 has merged, merge `origin/main` into this branch (never rebase: Auto-fix watches it) and use `origin/main` as the base. - - On a difference, run `git bisect start HEAD <base>` and then `git bisect run bash bench/scripts/parity/run.sh <base> <dump>`. `run.sh` exits 2 on setup errors, which bisect counts as bad, so read the log of the commit it names. - - Fix the cause, or explain in the PR why it is negligible. -- [ ] **Step 3: Push** with `git push -u origin claude/bench-cleanup`. -- [ ] **Step 4: Open a draft PR.** Run `gh pr create --draft --base claude/bench-toolchain` (or `main`). - - Write the body, about 100 words in Simplified Technical English, with the spiral:pr-and-issue-voice skill. - - It names the parity result and the Task 3 timings, and links the design doc. - - It says that `BENCH_DB_REGION` on the Vercel project is now unused, and that deleting it is Will's call. - - It ends with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. - - Show the body in the session before creating the PR (Will pre-authorized opening it as a draft). -- [ ] **Step 5: Check the CI evidence.** - - `Bench: format, lint, build, test` passes, and its log shows the database suites ran. - - `Bench Deploy` builds the preview, and `verify-web-deploy.mjs` passes. -- [ ] **Step 6: Hand to Will** for the smoke test in the preview: - - Open TPC-DS (NVMe) (SF=1) and use Expand All. - - Click a chart's "load all" chip. - - Open one permalink page.