Files
strapi/examples/complex/scripts/bench-compare.js
T
DMehaffy 19fef31e08 chore(examples): migration performance benchmark harness + mariadb/sqlite + anti-pattern schemas (#26036)
* chore(examples): add mariadb + sqlite + podman support to complex

Extends the complex example's DB tooling to cover all Strapi-supported
database dialects and both container runtimes, as groundwork for a
migration performance benchmark harness:

- New compose.js runtime shim auto-detects podman compose / podman-compose
  / docker compose / docker-compose and the matching container CLI; all
  existing db-* scripts now go through it so podman-only environments
  work without installing docker
- New db-mariadb.js mirrors db-mysql.js using mariadb-dump / mariadb CLIs
  and adds a mariadb:11 service on port 3307 to docker-compose.dev.yml
- New db-sqlite.js handles file-based snapshot/restore/wipe/check via
  fs.copy / better-sqlite3
- db-utils.js falls back to `<runtime> ps --filter name=` for container
  lookup since podman-compose doesn't support `ps -q`
- develop-with-db.js and the v4 templates (develop-with-db.js,
  seed-with-db.js) handle mariadb + sqlite (sqlite skips compose)
- setup-v4-project.js includes better-sqlite3 in v4 deps, database.js
  template covers all 4 clients, and compose.js is copied into the
  v4 scaffold scripts dir (dep of db-utils.js)

All four DBs smoke-tested locally against podman: start/check/snapshot/
restore/wipe cycle works for mariadb; cp-based snapshot cycle works
for sqlite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(examples): add migration perf benchmark harness

Three new scripts enable per-migration timing and baseline-vs-candidate
comparison reports for v4→v5 migrations in the complex example:

- bench-hook.js: Node --require preload that intercepts require('umzug')
  and subscribes to Umzug's native `migrating`/`migrated` events for
  sub-ms timing. Captures every migration that runs (including dynamically
  registered ones like discard-drafts and EE-only release migrations)
  without hardcoding names. Dumps to a JSON file on process exit; self-
  disables when STRAPI_BENCH_HOOK_OUTPUT is unset.
- bench.js: orchestrator with `run`, `seed`, and `suite` subcommands.
  `run` restores a snapshot, spawns Strapi in migrate-then-exit mode
  with the hook preload, collects row counts, and writes a result JSON
  with baseline/candidate attribution, env capture (node, CPU, memory,
  DB version, host type), and config (multiplier, seed/hook modes).
  `seed` wipes the DB, runs the v4 seed via seed-with-db.js, then
  snapshots. First iteration supports --strapi-source=local only;
  experimental/pinned are stubbed with a clear error.
- bench-compare.js: takes N labels and emits both a clipboard-friendly
  markdown report (stdout + results/compare-*.md) and a self-contained
  HTML report (results/compare-*.html) with inline SVG bar charts,
  per-DB grid, sortable tables, collapsible raw JSON, and a light/dark
  adaptive theme via prefers-color-scheme. No CDN deps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(examples): bench harness smoke-test fixes

Fixes discovered during the end-to-end smoke test on the existing 6
content types at multiplier=1:

- bench-hook.js: switch from subclass-based wrapping to in-place
  Umzug.prototype.up patching. The subclass approach replaced the module
  export at require time, but Node's module cache hands out the original
  class on subsequent requires, so listeners weren't attached on all
  instances. In-place prototype patching works for every instance
  regardless of how Umzug was imported.
- bench-hook.js: flush incrementally after each recorded migration.
  Strapi's shutdown path can bypass process.on('exit') handlers under
  some conditions (signal or explicit exit from deep inside), causing
  fully-collected timing data to be lost. Writing after each recording
  makes the benchmark resilient to any exit path.
- bench.js: compile TypeScript configs via @strapi/typescript-utils
  before createStrapi().load(). The examples/complex project has .ts
  config files; the Strapi CLI compiles them to dist/ before boot but
  our direct node -e loader skipped this, producing
  "db.config.connection undefined" failures.
- bench.js: propagate STRAPI_BENCH_HOOK_DEBUG to the Strapi child so
  debug output is visible when tracing hook behavior.
- bench-compare.js: rework the SVG chart. Dynamic label column sized
  to the longest migration name (up to 420px), 80px reserved on the
  right for value labels so they never clip, inlined monospace font
  (SVG text doesn't reliably inherit CSS variables from the surrounding
  stylesheet), and `dominant-baseline="middle"` for proper vertical
  centering.

Verified: full pipeline (setup:v4 → seed → snapshot → bench:run →
bench:compare) works against postgres at multiplier=1. Ran a baseline
vs cherry-picked PR #25988 comparison — captured all 7 v4→v5 migrations,
produced both markdown and HTML reports with correct test-setup
attribution and delta coloring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(examples): run ANALYZE before db:check to get fresh row counts

pg_stat_user_tables.n_live_tup and information_schema.tables.table_rows
are approximate and can lag behind reality by minutes or hours depending
on autovacuum / ANALYZE cadence. For a benchmark harness that publishes
row-count numbers in its reports, stale counts are misleading.

Trigger a refresh via ANALYZE (postgres) / ANALYZE TABLE per-table
(mysql/mariadb) before each db:check invocation. Best-effort on the
mysql/mariadb side — fall through to stale stats if ANALYZE fails rather
than error the whole command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(examples): add hc-m2m-source/target anti-pattern schemas

First anti-pattern schema pair for migration benchmark stress-testing.
A high-cardinality many-to-many relation that forces the v4→v5
discard-drafts migration's copyRelationTableRows code path to span
multiple chunks (>1000 rows) — the same scenario PR #25988's caching
fixes target.

- src/api/hc-m2m-source: collection type with DP and a manyToMany
  relation to hc-m2m-target (owning side)
- src/api/hc-m2m-target: collection type with DP and the inverse
  manyToMany back to source
- setup-v4-project.js: include both in the v4 scaffold CONTENT_TYPES
- seed-v4.js: seedHcM2m() method that creates sources + targets and
  fans out 10 targets-per-source via the M2M relation. BASE counts at
  m=1 are tiny (15 pub + 5 draft per side) but at m=100 produce ~2K
  sources × ~2K targets × 10 = 20K join rows, crossing the 1000-row
  chunk boundary multiple times

Intentionally NOT a realistic content-type design — this is a
stress-test fixture. See the description in schema.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(examples): render multiplier x db matrix in bench-compare

Rework bench-compare to index results by (label, multiplier, dbEngine)
triples pulled from each result JSON's own fields, rather than parsing
labels out of filenames. Lets the same canonical baseline/candidate
label span any number of (multiplier, db) combinations and produces:

- A speedup matrix at the top: rows = multipliers, cols = databases,
  cells = "baseline -> candidate (delta%)". Missing cells render as
  "-" so partial data still produces a useful report.
- A data-availability matrix listing what ran vs what's still missing.
- Per-(db, multiplier) detail sections as collapsible details in
  HTML, all expanded in markdown.

Also:
- New flag syntax: --baseline <label> / --candidate <label>, with
  positional args kept for backward compat.
- Legacy labels that embedded the multiplier (e.g. "baseline-m100")
  are normalized to their base form ("baseline"), letting older
  result files keep working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(examples): force TCP for mysql/mariadb CLI in containers

The mysql/mariadb CLI tools default to connecting via unix socket at
/var/run/mysqld/mysqld.sock, which isn't populated in the official
mysql:8 / mariadb:11 container images. Every invocation (check,
snapshot, restore, wipe, readiness probe, version probe) needs an
explicit -h 127.0.0.1 to force TCP via the container's loopback.

Without this fix, bench:seed and bench:run error out with
"Can't connect to local MySQL server through socket" on anything
requiring the CLI inside the container (pg_stat-style row-count
queries, snapshot restore, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* enhancement(examples): parallelize entity creation in seed-v4

Replace sequential for-loops of `await entityService.create(...)` with
a `concurrentMap(count, concurrency, taskFn)` helper that runs N tasks
in flight at once. At SEED_CONCURRENCY=5 (default), a seed that was
strictly serial now fans out into 5 parallel creates.

Concurrency chosen conservatively: Strapi v4's default knex pool is
`{min: 2, max: 10}`, and entity-heavy creates (components + DZs +
localizations) can use multiple connections per call. 5 keeps us well
under the pool ceiling. Tune via `SEED_CONCURRENCY=<n>` env var if
you've also raised the pool max.

Applied to: seedBasic, seedBasicDp, updateComponentRelations,
seedBasicDpI18n, seedRelation, seedRelationDp, seedRelationDpI18n,
seedHcM2m (all entity-creation loops plus their follow-up
self-reference update loops).

Not yet done: incremental seeding (restore previous snapshot + seed
delta) — a separate optimization tracked as a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(examples): update complex README for new bench tooling + DBs

README was documenting just the original 6-type, postgres+mysql
workflow. Updated to cover everything this branch adds:

- 8 content types (added hc-m2m-source/target anti-patterns)
- 4 supported databases (added mariadb + sqlite)
- Container runtime auto-detection (podman compose / podman-compose /
  docker compose / docker-compose) with STRAPI_BENCH_RUNTIME override
- Benchmark harness workflow (bench:seed / bench:run / bench:compare /
  bench:suite) for reviewing migration-performance PRs
- SEED_CONCURRENCY, STRAPI_BENCH_HOOK_OUTPUT, STRAPI_BENCH_HOOK_DEBUG,
  and the existing port-override env vars
- MariaDB port default 3307 to avoid colliding with MySQL on 3306

Also collapsed the redundant per-DB command sections (postgres and
mysql both had identical copy-pasted blocks) into a single
'yarn db:<op>:<db>' table since the commands are symmetric across
all four dialects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(examples): align better-sqlite3 version with monorepo convention

I picked `11.3.0` arbitrarily. Every other example and tests/app-template
use `12.8.0`, and the root yarn.lock already resolves that version.
Without alignment CI's `yarn install --immutable` fails with 'lockfile
would have been modified', cascading every subsequent job (build, pretty,
commitlint, aggregate_test_result) to red.

Bumping to `12.8.0` to match, regenerating yarn.lock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: throw instead of return to fail fast

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Irvin <ben@innerdvations.com>
2026-04-27 18:28:08 +02:00

670 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Compare benchmark result sets across multipliers and databases.
*
* Usage:
* node bench-compare.js --baseline <label> --candidate <label>
* node bench-compare.js <baseline-label> <candidate-label> # legacy positional form
*
* Result files are loaded from `results/*.json` and indexed by:
* { label, multiplier, dbEngine }
* taken from fields inside each JSON (not the filename), so the same
* canonical baseline/candidate label can span any number of (multiplier, db)
* combinations. Missing cells are rendered as "—" rather than errors.
*
* Produces:
* - Markdown report: stdout + results/compare-<timestamp>.md
* - Self-contained HTML: results/compare-<timestamp>.html
*/
const fs = require('fs');
const path = require('path');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const RESULTS_DIR = path.join(COMPLEX_DIR, 'results');
// ─── argument parsing ────────────────────────────────────────────────────────
function parseArgs(argv) {
const flags = {};
const positional = [];
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) {
flags[a.slice(2, eq)] = a.slice(eq + 1);
} else {
const next = argv[i + 1];
if (next != null && !next.startsWith('--')) {
flags[a.slice(2)] = next;
i += 1;
} else {
flags[a.slice(2)] = true;
}
}
} else {
positional.push(a);
}
}
return { flags, positional };
}
const { flags, positional } = parseArgs(process.argv.slice(2));
const baselineLabel = flags.baseline ?? positional[0];
const candidateLabel = flags.candidate ?? positional[1];
if (!baselineLabel || !candidateLabel) {
console.error('Usage: node bench-compare.js --baseline <label> --candidate <label>');
console.error(' node bench-compare.js <baseline-label> <candidate-label>');
process.exit(1);
}
if (!fs.existsSync(RESULTS_DIR)) {
console.error(`No results/ directory found at ${RESULTS_DIR}`);
process.exit(1);
}
// ─── result loading ──────────────────────────────────────────────────────────
/**
* Strip a trailing `-m<N>` suffix from labels so older results (e.g.,
* "baseline-develop-m100") can be matched against canonical labels
* ("baseline-develop"). Leaves labels without the suffix untouched.
*/
function normalizeLabel(raw) {
if (typeof raw !== 'string') return raw;
return raw.replace(/-m\d+$/, '');
}
/**
* Load every result into a flat list, keeping only the most recent per
* (normalized-label, multiplier, dbEngine) triple.
*/
function loadResults() {
const files = fs
.readdirSync(RESULTS_DIR)
.filter((f) => f.endsWith('.json'))
.sort(); // lexicographic on ISO timestamps = chronological
const byKey = new Map();
for (const file of files) {
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(RESULTS_DIR, file), 'utf8'));
} catch (err) {
console.error(`[bench-compare] skipping unparseable ${file}: ${err.message}`);
continue;
}
const db = data?.env?.dbEngine;
const multiplier = data?.config?.multiplier;
const label = normalizeLabel(data?.label);
if (!db || multiplier == null || !label) continue;
byKey.set(`${label}::${multiplier}::${db}`, { ...data, __file: file });
}
return byKey;
}
const results = loadResults();
/**
* Return the result for (label, multiplier, db) or undefined.
*/
function getResult(label, multiplier, db) {
return results.get(`${label}::${multiplier}::${db}`);
}
/**
* Collect every multiplier and dbEngine seen across baseline + candidate.
*/
function collectAxes() {
const multipliers = new Set();
const dbs = new Set();
for (const key of results.keys()) {
const [label, mult, db] = key.split('::');
if (label === baselineLabel || label === candidateLabel) {
multipliers.add(Number(mult));
dbs.add(db);
}
}
return {
multipliers: [...multipliers].sort((a, b) => a - b),
dbs: [...dbs].sort(),
};
}
const { multipliers, dbs } = collectAxes();
if (multipliers.length === 0) {
console.error(
`[bench-compare] no results found for labels "${baselineLabel}" or "${candidateLabel}". Have you run bench:run yet?`
);
console.error('Available label/multiplier/db combos:');
for (const key of [...results.keys()].sort()) console.error(` ${key}`);
process.exit(1);
}
// ─── formatting helpers ──────────────────────────────────────────────────────
const REGRESSION_THRESHOLD_PCT = 5;
function pctChange(baseline, candidate) {
if (baseline === 0 || baseline == null || candidate == null) return null;
return ((candidate - baseline) / baseline) * 100;
}
function fmtMs(ms) {
if (ms == null || Number.isNaN(ms)) return '—';
if (ms < 1000) return `${ms.toFixed(0)} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
return `${(ms / 60000).toFixed(2)} min`;
}
function fmtDelta(ms) {
if (ms == null || Number.isNaN(ms)) return '—';
const sign = ms > 0 ? '+' : ms < 0 ? '' : '';
return `${sign}${fmtMs(Math.abs(ms))}`;
}
function fmtPct(pct) {
if (pct == null || Number.isNaN(pct)) return '—';
const sign = pct > 0 ? '+' : pct < 0 ? '' : '';
return `${sign}${Math.abs(pct).toFixed(1)}%`;
}
function fmtSpeedup(baseline, candidate) {
if (!baseline || !candidate) return '—';
const ratio = baseline / candidate;
if (ratio >= 1) return `${ratio.toFixed(2)}×`;
return `${(1 / ratio).toFixed(2)}× slower`;
}
function pctClass(pct) {
if (pct == null) return '';
if (pct > REGRESSION_THRESHOLD_PCT) return 'regression';
if (pct < -REGRESSION_THRESHOLD_PCT) return 'improvement';
return '';
}
// ─── per-cell detail ─────────────────────────────────────────────────────────
function pairedMigrationRows(baseline, candidate) {
const names = new Set();
(baseline?.migrations ?? []).forEach((m) => names.add(m.name));
(candidate?.migrations ?? []).forEach((m) => names.add(m.name));
return [...names].map((name) => ({
name,
baseline: baseline?.migrations?.find((m) => m.name === name)?.durationMs ?? null,
candidate: candidate?.migrations?.find((m) => m.name === name)?.durationMs ?? null,
}));
}
// ─── representative pick (for setup block) ───────────────────────────────────
function pickRepresentative(label) {
for (const mult of multipliers) {
for (const db of dbs) {
const r = getResult(label, mult, db);
if (r) return r;
}
}
return null;
}
const baselineRep = pickRepresentative(baselineLabel);
const candidateRep = pickRepresentative(candidateLabel);
function renderSetupInline(result) {
if (!result) return '_(no data)_';
const src = result.strapiSource || 'unknown';
const ver = result.strapiVersion || '?';
const sha = result.strapiGitSha ? ` @ ${result.strapiGitSha.slice(0, 10)}` : '';
const branch = result.strapiGitBranch ? ` (branch \`${result.strapiGitBranch}\`)` : '';
return `Strapi ${ver}${sha}${branch} [source: ${src}]`;
}
function renderEnvInline(result) {
const e = result?.env;
if (!e) return '_(no env info)_';
return `Node ${e.nodeVersion} · ${e.platform}/${e.arch} · ${e.cpuModel} (${e.cpuCount} cores) · ${Math.round(
e.totalMemMB / 1024
)} GB`;
}
// ─── markdown rendering ──────────────────────────────────────────────────────
function renderMarkdown() {
let out = '';
out += `## Migration benchmark: ${baselineLabel} vs ${candidateLabel}\n\n`;
// Test setup
out += `### Test setup\n\n`;
out += `- **Baseline (${baselineLabel}):** ${renderSetupInline(baselineRep)}\n`;
out += `- **Candidate (${candidateLabel}):** ${renderSetupInline(candidateRep)}\n`;
if (baselineRep) {
out += `- **Host env:** ${renderEnvInline(baselineRep)}\n`;
}
const cfg = baselineRep?.config;
if (cfg) {
out += `- **Config (representative):** seed=${cfg.seedMode}, hook=${cfg.hookMode}\n`;
}
out += `\n`;
// Cross-multiplier × cross-DB summary table
out += `### Speedup matrix (total migration time)\n\n`;
out += `Rows: multipliers · Columns: databases. Each cell shows ${'`baseline → candidate (% change)`'}.\n\n`;
const header = ['multiplier', ...dbs];
out += `| ${header.join(' | ')} |\n`;
out += `| ${header.map((_, i) => (i === 0 ? ':---' : '---:')).join(' | ')} |\n`;
for (const mult of multipliers) {
const cells = [`**m=${mult}**`];
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (!b || !c) {
cells.push('—');
continue;
}
const bt = b.totalDurationMs;
const ct = c.totalDurationMs;
const pct = pctChange(bt, ct);
cells.push(`${fmtMs(bt)}${fmtMs(ct)} (${fmtPct(pct)})`);
}
out += `| ${cells.join(' | ')} |\n`;
}
out += `\n`;
// Data-availability matrix (baseline/candidate presence)
out += `### Data points captured\n\n`;
const availHeader = ['multiplier', ...dbs];
out += `| ${availHeader.join(' | ')} |\n`;
out += `| ${availHeader.map((_, i) => (i === 0 ? ':---' : ':---:')).join(' | ')} |\n`;
for (const mult of multipliers) {
const cells = [`m=${mult}`];
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
const hasB = b ? '✓' : ' ';
const hasC = c ? '✓' : ' ';
cells.push(`base:${hasB} pr:${hasC}`);
}
out += `| ${cells.join(' | ')} |\n`;
}
out += `\n`;
// Per-cell detailed breakdowns
out += `### Per-migration detail\n\n`;
for (const mult of multipliers) {
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (!b || !c) continue;
out += `#### ${db} @ m=${mult}\n\n`;
const rowCountTotal = Object.values(b.rowCount || {}).reduce(
(a, n) => (typeof n === 'number' ? a + n : a),
0
);
out += `Row count (baseline, after migration): ~${rowCountTotal.toLocaleString()}\n\n`;
const rows = pairedMigrationRows(b, c);
out += `| Migration | Baseline | Candidate | Δ | % change |\n`;
out += `| :--- | ---: | ---: | ---: | ---: |\n`;
const regressionLines = [];
for (const row of rows) {
const delta =
row.candidate != null && row.baseline != null ? row.candidate - row.baseline : null;
const pct = pctChange(row.baseline, row.candidate);
out += `| ${row.name} | ${fmtMs(row.baseline)} | ${fmtMs(row.candidate)} | ${fmtDelta(delta)} | ${fmtPct(pct)} |\n`;
if (pct != null && pct > REGRESSION_THRESHOLD_PCT) {
regressionLines.push(` - \`${row.name}\` regressed by ${fmtPct(pct)}`);
}
}
const totalDelta = c.totalDurationMs - b.totalDurationMs;
const totalPct = pctChange(b.totalDurationMs, c.totalDurationMs);
out += `| **Total** | **${fmtMs(b.totalDurationMs)}** | **${fmtMs(c.totalDurationMs)}** | **${fmtDelta(totalDelta)}** | **${fmtPct(totalPct)}** |\n`;
out += `\n**Speedup:** ${fmtSpeedup(b.totalDurationMs, c.totalDurationMs)}\n`;
if (regressionLines.length) {
out += `\n⚠️ Per-migration regressions (>${REGRESSION_THRESHOLD_PCT}%):\n${regressionLines.join('\n')}\n`;
}
out += `\n`;
}
}
return out;
}
// ─── HTML rendering ──────────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderSvgBarChart(rows) {
if (rows.length === 0) return '';
const barHeight = 16;
const barGap = 2;
const rowGap = 10;
const leftPad = 8;
const rightPad = 80;
const charWidth = 7.2;
const longestName = rows.reduce((a, r) => Math.max(a, r.name.length), 0);
const labelColWidth = Math.round(Math.min(Math.max(longestName * charWidth + 16, 180), 420));
const seriesCount = 2; // baseline + candidate
const rowHeight = (barHeight + barGap) * seriesCount + rowGap;
const chartHeight = rows.length * rowHeight + rowGap;
const barAreaWidth = 320;
const width = leftPad + labelColWidth + barAreaWidth + rightPad;
const maxDuration = Math.max(...rows.flatMap((r) => [r.baseline ?? 0, r.candidate ?? 0]), 1);
const scale = (v) => (v == null ? 0 : (v / maxDuration) * barAreaWidth);
const textStyle = 'font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
const textStyleMuted = `${textStyle}; fill: var(--muted)`;
const textStyleFg = `${textStyle}; fill: var(--text)`;
let svg = `<svg viewBox="0 0 ${width} ${chartHeight}" width="100%" role="img" aria-label="Per-migration duration bars" style="max-width: ${width}px">`;
rows.forEach((row, rowIdx) => {
const yBase = rowIdx * rowHeight + rowGap / 2;
const series = [
{ value: row.baseline, color: 'var(--baseline-color)', title: 'baseline' },
{ value: row.candidate, color: 'var(--candidate-1-color)', title: 'candidate' },
];
const nameYCenter = yBase + (seriesCount * (barHeight + barGap)) / 2 - barGap / 2;
svg += `<text x="${leftPad}" y="${nameYCenter}" dominant-baseline="middle" style="${textStyleFg}">${escapeHtml(row.name)}</text>`;
series.forEach((s, i) => {
const y = yBase + i * (barHeight + barGap);
const textY = y + barHeight / 2;
const barX = leftPad + labelColWidth;
if (s.value != null) {
const w = scale(s.value);
const title = `${s.title}: ${fmtMs(s.value)}`;
svg += `<rect x="${barX}" y="${y}" width="${w.toFixed(1)}" height="${barHeight}" fill="${s.color}" rx="2"><title>${escapeHtml(title)}</title></rect>`;
svg += `<text x="${(barX + w + 4).toFixed(1)}" y="${textY}" dominant-baseline="middle" style="${textStyleMuted}">${fmtMs(s.value)}</text>`;
} else {
svg += `<text x="${barX}" y="${textY}" dominant-baseline="middle" style="${textStyleMuted}">—</text>`;
}
});
});
svg += `</svg>`;
return svg;
}
function renderCellDetailHtml(mult, db, baseline, candidate) {
const rows = pairedMigrationRows(baseline, candidate);
const rowCountTotal = Object.values(baseline.rowCount || {}).reduce(
(a, n) => (typeof n === 'number' ? a + n : a),
0
);
const tableRows = rows
.map((row) => {
const delta =
row.candidate != null && row.baseline != null ? row.candidate - row.baseline : null;
const pct = pctChange(row.baseline, row.candidate);
return `<tr>
<td>${escapeHtml(row.name)}</td>
<td class="num">${fmtMs(row.baseline)}</td>
<td class="num">${fmtMs(row.candidate)}</td>
<td class="num">${fmtDelta(delta)}</td>
<td class="num ${pctClass(pct)}">${fmtPct(pct)}</td>
</tr>`;
})
.join('');
const totalDelta = candidate.totalDurationMs - baseline.totalDurationMs;
const totalPct = pctChange(baseline.totalDurationMs, candidate.totalDurationMs);
const svg = renderSvgBarChart(rows);
return `
<details class="cell-detail">
<summary><strong>${escapeHtml(db)} @ m=${mult}</strong> — ${fmtMs(baseline.totalDurationMs)}${fmtMs(candidate.totalDurationMs)} (<span class="${pctClass(totalPct)}">${fmtPct(totalPct)}</span>), speedup ${fmtSpeedup(baseline.totalDurationMs, candidate.totalDurationMs)} · ~${rowCountTotal.toLocaleString()} rows</summary>
<div class="cell-body">
<div class="chart-wrap">${svg}</div>
<table class="sortable">
<thead>
<tr>
<th>Migration</th>
<th class="num">Baseline</th>
<th class="num">Candidate</th>
<th class="num">Δ</th>
<th class="num">% change</th>
</tr>
</thead>
<tbody>
${tableRows}
<tr class="total-row">
<th>Total</th>
<th class="num">${fmtMs(baseline.totalDurationMs)}</th>
<th class="num">${fmtMs(candidate.totalDurationMs)}</th>
<th class="num">${fmtDelta(totalDelta)}</th>
<th class="num ${pctClass(totalPct)}">${fmtPct(totalPct)}</th>
</tr>
</tbody>
</table>
</div>
</details>
`;
}
function renderHtml() {
// Summary matrix cells
const matrixRows = multipliers
.map((mult) => {
const cells = [`<th>m=${mult}</th>`];
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (!b || !c) {
cells.push('<td class="num muted">—</td>');
continue;
}
const pct = pctChange(b.totalDurationMs, c.totalDurationMs);
cells.push(
`<td class="num ${pctClass(pct)}" title="${escapeHtml(`${fmtMs(b.totalDurationMs)}${fmtMs(c.totalDurationMs)}`)}">${fmtMs(b.totalDurationMs)}${fmtMs(c.totalDurationMs)}<br><span class="delta">${fmtPct(pct)} · ${fmtSpeedup(b.totalDurationMs, c.totalDurationMs)}</span></td>`
);
}
return `<tr>${cells.join('')}</tr>`;
})
.join('');
const headerCells = ['<th>multiplier</th>', ...dbs.map((db) => `<th>${escapeHtml(db)}</th>`)];
// Per-cell details
const details = [];
for (const mult of multipliers) {
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (b && c) details.push(renderCellDetailHtml(mult, db, b, c));
}
}
// Verdict (use postgres at the largest multiplier if available)
const verdictDb = dbs.includes('postgres') ? 'postgres' : dbs[0];
const verdictMult = multipliers[multipliers.length - 1];
const vB = getResult(baselineLabel, verdictMult, verdictDb);
const vC = getResult(candidateLabel, verdictMult, verdictDb);
let verdict = 'No data to summarize';
if (vB && vC) {
const ratio = vB.totalDurationMs / vC.totalDurationMs;
if (ratio > 1.05) {
verdict = `${ratio.toFixed(2)}× faster on ${verdictDb} @ m=${verdictMult}`;
} else if (ratio < 0.95) {
verdict = `${(1 / ratio).toFixed(2)}× slower on ${verdictDb} @ m=${verdictMult}`;
} else {
verdict = `≈ no change on ${verdictDb} @ m=${verdictMult}`;
}
}
const cssVars = `
--baseline-color: #888;
--candidate-1-color: #2563eb;
--regression-bg: #fee2e2;
--regression-fg: #991b1b;
--improvement-bg: #dcfce7;
--improvement-fg: #166534;
`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Migration benchmark: ${escapeHtml(baselineLabel)} vs ${escapeHtml(candidateLabel)}</title>
<style>
:root {
--bg: #ffffff;
--text: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--card-bg: #f9fafb;
--mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
${cssVars}
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--text: #e5e7eb;
--muted: #9ca3af;
--border: #374151;
--card-bg: #111827;
--baseline-color: #6b7280;
--candidate-1-color: #60a5fa;
--regression-bg: #450a0a;
--regression-fg: #fca5a5;
--improvement-bg: #052e16;
--improvement-fg: #86efac;
}
}
html, body { background: var(--bg); color: var(--text); margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 { margin-top: 0; }
h2 { border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; margin-top: 2rem; }
.verdict { font-size: 1.2rem; padding: 0.5rem 0.75rem; background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border); }
.setup dt { font-weight: 600; color: var(--muted); margin-top: 0.5rem; }
.setup dd { margin-left: 0; }
table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
th, td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
th { user-select: none; }
table.sortable th { cursor: pointer; }
table.sortable th:hover { background: rgba(127,127,127,0.1); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono-font); white-space: nowrap; }
td.num .delta { display: block; font-size: 0.85em; color: var(--muted); }
tr.total-row th, tr.total-row td { border-top: 2px solid var(--border); font-weight: 600; }
td.regression, th.regression { background: var(--regression-bg); color: var(--regression-fg); }
td.improvement, th.improvement { background: var(--improvement-bg); color: var(--improvement-fg); }
td.muted { color: var(--muted); }
.cell-detail { margin: 0.5rem 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.75rem; }
.cell-detail summary { cursor: pointer; padding: 0.25rem 0; }
.cell-detail .cell-body { padding-top: 0.5rem; }
.chart-wrap { overflow-x: auto; padding: 0.5rem 0; }
footer { margin-top: 3rem; color: var(--muted); font-size: 0.85rem; border-top: 1px solid var(--border); padding-top: 1rem; }
</style>
</head>
<body>
<h1>Migration benchmark: ${escapeHtml(baselineLabel)} vs ${escapeHtml(candidateLabel)}</h1>
<p class="verdict">${escapeHtml(verdict)}</p>
<section class="setup">
<h2>Test setup</h2>
<dl>
<dt>Baseline (${escapeHtml(baselineLabel)})</dt>
<dd>${escapeHtml(renderSetupInline(baselineRep))}</dd>
<dt>Candidate (${escapeHtml(candidateLabel)})</dt>
<dd>${escapeHtml(renderSetupInline(candidateRep))}</dd>
${baselineRep?.env ? `<dt>Host env</dt><dd>${escapeHtml(renderEnvInline(baselineRep))}</dd>` : ''}
${baselineRep?.config ? `<dt>Config (representative)</dt><dd>seed=${escapeHtml(baselineRep.config.seedMode || '?')}, hook=${escapeHtml(baselineRep.config.hookMode || '?')}</dd>` : ''}
</dl>
</section>
<section>
<h2>Speedup matrix</h2>
<p>Rows: multipliers · Columns: databases. Each cell shows <code>baseline → candidate</code> with Δ% and speedup. Empty cells mean no data for that combination yet.</p>
<table>
<thead><tr>${headerCells.join('')}</tr></thead>
<tbody>${matrixRows}</tbody>
</table>
</section>
<section>
<h2>Per-migration detail</h2>
<p>Click a row to expand per-migration breakdown for that (database, multiplier) pair.</p>
${details.join('')}
</section>
<footer>
Generated ${new Date().toISOString()} · bench-compare.js · Strapi migration benchmark harness
</footer>
<script>
document.querySelectorAll('table.sortable').forEach((table) => {
const headers = table.querySelectorAll('th');
headers.forEach((th, colIdx) => {
th.addEventListener('click', () => {
const tbody = table.tBodies[0];
const rows = Array.from(tbody.querySelectorAll('tr:not(.total-row)'));
const dir = th.dataset.sortDir === 'asc' ? 'desc' : 'asc';
headers.forEach((h) => delete h.dataset.sortDir);
th.dataset.sortDir = dir;
rows.sort((a, b) => {
const av = a.children[colIdx]?.innerText.trim() || '';
const bv = b.children[colIdx]?.innerText.trim() || '';
const an = parseFloat(av.replace(/[^0-9.\\-]/g, ''));
const bn = parseFloat(bv.replace(/[^0-9.\\-]/g, ''));
let cmp;
if (!isNaN(an) && !isNaN(bn)) cmp = an - bn;
else cmp = av.localeCompare(bv);
return dir === 'asc' ? cmp : -cmp;
});
const totalRow = tbody.querySelector('tr.total-row');
rows.forEach((r) => tbody.appendChild(r));
if (totalRow) tbody.appendChild(totalRow);
});
});
});
</script>
</body>
</html>`;
}
// ─── emit ────────────────────────────────────────────────────────────────────
const markdown = renderMarkdown();
const html = renderHtml();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const mdPath = path.join(RESULTS_DIR, `compare-${stamp}.md`);
const htmlPath = path.join(RESULTS_DIR, `compare-${stamp}.html`);
fs.writeFileSync(mdPath, markdown, 'utf8');
fs.writeFileSync(htmlPath, html, 'utf8');
process.stdout.write(markdown);
console.error(`\n[bench-compare] markdown: ${path.relative(COMPLEX_DIR, mdPath)}`);
console.error(`[bench-compare] html: ${path.relative(COMPLEX_DIR, htmlPath)}`);