@bitclaw/sqlite 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
6
6
  * measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
7
- * ORM (Prisma), SSR rendering, etc.
7
+ * the ORM/query layer, SSR rendering, etc.
8
8
  *
9
9
  * Usage:
10
10
  * Import into app-specific load tests:
@@ -24,6 +24,14 @@ export type LoadTestConfig = {
24
24
  durationSec: number;
25
25
  /** Optional: warm-up requests before timing */
26
26
  warmupRequests?: number;
27
+ /**
28
+ * Optional: number of times to repeat each scenario (default 1).
29
+ * When > 1, each (endpoint, concurrency) runs N times and results are
30
+ * aggregated — medians for throughput/latency, plus a coefficient of
31
+ * variation so run-to-run dispersion is visible. Defends against the
32
+ * ~±30% single-run variance seen on virtualized hosts (e.g. WSL2).
33
+ */
34
+ repeat?: number;
27
35
  };
28
36
  export type EndpointConfig = {
29
37
  /** Path relative to baseUrl (e.g., '/healthcheck') */
@@ -63,6 +71,20 @@ export type ScenarioResult = {
63
71
  statusCodes: Record<number, number>;
64
72
  avgBodySize: number;
65
73
  via?: 'cdn' | 'direct';
74
+ /**
75
+ * Variance fields — only populated when the scenario was run more than
76
+ * once (LoadTestConfig.repeat > 1). Absent for single-run scenarios, so
77
+ * the single-run output shape is unchanged.
78
+ */
79
+ runs?: number;
80
+ /** Lowest per-run throughput (req/s) across the N runs. */
81
+ throughputMin?: number;
82
+ /** Highest per-run throughput (req/s) across the N runs. */
83
+ throughputMax?: number;
84
+ /** Coefficient of variation of throughput across runs, as a percent. */
85
+ throughputCoV?: number;
86
+ /** Median of the per-run p95 values (NOT p95 of pooled latencies). */
87
+ p95Median?: number;
66
88
  };
67
89
  export type LoadTestResults = {
68
90
  baseUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"load-test-utils.d.ts","sourceRoot":"","sources":["../../scripts/load-test-utils.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;GAaG;AAKH,MAAM,MAAM,cAAc,GAAG;IAC3B,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,iCAAiC;IACjC,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gCAAgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IAEpB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IAEpB,UAAU,EAAE,MAAM,CAAC;IAEnB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IAEZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC;IAEpB,GAAG,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,cAAc,EAAE,CAAC;CAC7B,CAAC;AAKF,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAS9D;AAKD,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,MAAM,SAAQ,EACd,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,OAAO,CAAC,aAAa,CAAC,CA6BxB;AA8FD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,eAAe,CAAC,CA0B1B;AAKD,wBAAgB,aAAa,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAoE9D"}
1
+ {"version":3,"file":"load-test-utils.d.ts","sourceRoot":"","sources":["../../scripts/load-test-utils.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;GAaG;AAKH,MAAM,MAAM,cAAc,GAAG;IAC3B,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,iCAAiC;IACjC,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gCAAgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IAEpB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IAEpB,UAAU,EAAE,MAAM,CAAC;IAEnB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IAEZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC;IAEpB,GAAG,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IAEvB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,cAAc,EAAE,CAAC;CAC7B,CAAC;AAKF,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAS9D;AAKD,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,MAAM,SAAQ,EACd,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,OAAO,CAAC,aAAa,CAAC,CA6BxB;AA0KD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,eAAe,CAAC,CA8B1B;AAKD,wBAAgB,aAAa,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CA2F9D"}
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
6
6
  * measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
7
- * ORM (Prisma), SSR rendering, etc.
7
+ * the ORM/query layer, SSR rendering, etc.
8
8
  *
9
9
  * Usage:
10
10
  * Import into app-specific load tests:
@@ -119,6 +119,72 @@ async function runScenario(baseUrl, endpoint, concurrency, durationSec, warmupRe
119
119
  avgBodySize: results.length > 0 ? totalBodySize / results.length : 0
120
120
  };
121
121
  }
122
+ /* ------------------------------------------------------------------
123
+ * Multi-run aggregation
124
+ * ------------------------------------------------------------------ */
125
+ function median(values) {
126
+ if (values.length === 0)
127
+ return 0;
128
+ const sorted = [...values].sort((a, b) => a - b);
129
+ const mid = Math.floor(sorted.length / 2);
130
+ return sorted.length % 2 !== 0
131
+ ? sorted[mid]
132
+ : (sorted[mid - 1] + sorted[mid]) / 2;
133
+ }
134
+ function coefficientOfVariation(values) {
135
+ if (values.length < 2)
136
+ return 0;
137
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
138
+ if (mean === 0)
139
+ return 0;
140
+ const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
141
+ return (Math.sqrt(variance) / mean) * 100;
142
+ }
143
+ /**
144
+ * Collapse N per-run ScenarioResults into one. Threshold-checked fields
145
+ * (throughput, successRate) use the median so a single outlier run does not
146
+ * flip pass/fail; counts are summed; variance fields expose dispersion.
147
+ * A single run is returned unchanged (no variance fields → identical shape).
148
+ */
149
+ function aggregateRuns(runs) {
150
+ if (runs.length === 1)
151
+ return runs[0];
152
+ const first = runs[0];
153
+ const throughputs = runs.map(r => r.throughput);
154
+ const statusCodes = {};
155
+ for (const r of runs) {
156
+ for (const [code, count] of Object.entries(r.statusCodes)) {
157
+ statusCodes[Number(code)] = (statusCodes[Number(code)] ?? 0) + count;
158
+ }
159
+ }
160
+ const p95Median = median(runs.map(r => r.p95));
161
+ return {
162
+ endpoint: first.endpoint,
163
+ label: first.label,
164
+ method: first.method,
165
+ concurrency: first.concurrency,
166
+ durationSec: first.durationSec,
167
+ totalRequests: runs.reduce((s, r) => s + r.totalRequests, 0),
168
+ successCount: runs.reduce((s, r) => s + r.successCount, 0),
169
+ failCount: runs.reduce((s, r) => s + r.failCount, 0),
170
+ successRate: median(runs.map(r => r.successRate)),
171
+ throughput: median(throughputs),
172
+ p50: median(runs.map(r => r.p50)),
173
+ p95: p95Median,
174
+ p99: median(runs.map(r => r.p99)),
175
+ min: Math.min(...runs.map(r => r.min)),
176
+ max: Math.max(...runs.map(r => r.max)),
177
+ avg: runs.reduce((s, r) => s + r.avg, 0) / runs.length,
178
+ statusCodes,
179
+ avgBodySize: runs.reduce((s, r) => s + r.avgBodySize, 0) / runs.length,
180
+ via: first.via,
181
+ runs: runs.length,
182
+ throughputMin: Math.min(...throughputs),
183
+ throughputMax: Math.max(...throughputs),
184
+ throughputCoV: coefficientOfVariation(throughputs),
185
+ p95Median
186
+ };
187
+ }
122
188
  /* ------------------------------------------------------------------
123
189
  * Main load test runner
124
190
  * ------------------------------------------------------------------ */
@@ -126,11 +192,14 @@ export async function runLoadTest(config) {
126
192
  const startedAt = new Date().toISOString();
127
193
  const scenarios = [];
128
194
  const warmup = config.warmupRequests ?? 5;
195
+ const repeat = Math.max(1, config.repeat ?? 1);
129
196
  for (const endpoint of config.endpoints) {
130
197
  for (const concurrency of config.concurrencyLevels) {
131
- const _label = endpoint.label ?? endpoint.path;
132
- const result = await runScenario(config.baseUrl, endpoint, concurrency, config.durationSec, warmup);
133
- scenarios.push(result);
198
+ const runs = [];
199
+ for (let i = 0; i < repeat; i++) {
200
+ runs.push(await runScenario(config.baseUrl, endpoint, concurrency, config.durationSec, warmup));
201
+ }
202
+ scenarios.push(aggregateRuns(runs));
134
203
  }
135
204
  }
136
205
  return {
@@ -153,8 +222,11 @@ export function formatResults(results) {
153
222
  lines.push(` Completed: ${results.completedAt}`);
154
223
  lines.push('='.repeat(100));
155
224
  lines.push('');
225
+ // Show the variance column only when at least one scenario was repeated.
226
+ // Single-run output keeps its original columns unchanged.
227
+ const showCoV = results.scenarios.some(s => (s.runs ?? 1) > 1);
156
228
  // Summary table header
157
- const header = [
229
+ const headerCols = [
158
230
  'Endpoint'.padEnd(25),
159
231
  'Conc'.padStart(5),
160
232
  'Req/s'.padStart(8),
@@ -164,11 +236,15 @@ export function formatResults(results) {
164
236
  'P99ms'.padStart(8),
165
237
  'Success'.padStart(8),
166
238
  'AvgBody'.padStart(8)
167
- ].join(' | ');
239
+ ];
240
+ if (showCoV) {
241
+ headerCols.push('Runs'.padStart(5), 'CoV%'.padStart(7));
242
+ }
243
+ const header = headerCols.join(' | ');
168
244
  lines.push(header);
169
245
  lines.push('-'.repeat(header.length));
170
246
  for (const s of results.scenarios) {
171
- const row = [
247
+ const cols = [
172
248
  s.label.padEnd(25).slice(0, 25),
173
249
  String(s.concurrency).padStart(5),
174
250
  s.throughput.toFixed(0).padStart(8),
@@ -178,15 +254,24 @@ export function formatResults(results) {
178
254
  s.p99.toFixed(1).padStart(8),
179
255
  `${s.successRate.toFixed(1)}%`.padStart(8),
180
256
  formatBytes(s.avgBodySize).padStart(8)
181
- ].join(' | ');
182
- lines.push(row);
257
+ ];
258
+ if (showCoV) {
259
+ cols.push(String(s.runs ?? 1).padStart(5), (s.throughputCoV !== undefined
260
+ ? `±${s.throughputCoV.toFixed(0)}%`
261
+ : '-').padStart(7));
262
+ }
263
+ lines.push(cols.join(' | '));
183
264
  }
184
265
  lines.push('');
185
- // Pool-level comparison note
266
+ // Stack-overhead note
186
267
  lines.push('-'.repeat(100));
187
- lines.push(' NOTE: Pool-level benchmarks (raw pool.exec) show 6,102-13,781 req/s.');
188
- lines.push(' Application-level throughput is lower due to HTTP overhead, middleware,');
189
- lines.push(' Prisma ORM, SSR rendering, and serialization.');
268
+ lines.push(' NOTE: Application-level throughput (full HTTP stack) is lower than raw');
269
+ lines.push(' pool.exec() benchmarks due to HTTP overhead, middleware, the ORM/query');
270
+ lines.push(' layer, SSR rendering, and serialization.');
271
+ if (showCoV) {
272
+ lines.push(' CoV% = coefficient of variation of throughput across repeated runs');
273
+ lines.push(' (higher = noisier host; treat deltas below CoV% as noise).');
274
+ }
190
275
  lines.push('-'.repeat(100));
191
276
  lines.push('');
192
277
  // Status code breakdown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitclaw/sqlite",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "High-performance SQLite worker pool and utilities using bun:sqlite",
5
5
  "files": [
6
6
  "dist",
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ formatResults,
4
+ type LoadTestResults,
5
+ type ScenarioResult
6
+ } from './load-test-utils';
7
+
8
+ function makeScenario(overrides: Partial<ScenarioResult> = {}): ScenarioResult {
9
+ return {
10
+ endpoint: '/dashboard',
11
+ label: 'Dashboard',
12
+ method: 'GET',
13
+ concurrency: 100,
14
+ durationSec: 10,
15
+ totalRequests: 12_000,
16
+ successCount: 12_000,
17
+ failCount: 0,
18
+ successRate: 100,
19
+ throughput: 1200,
20
+ p50: 60,
21
+ p95: 150,
22
+ p99: 240,
23
+ min: 5,
24
+ max: 400,
25
+ avg: 70,
26
+ statusCodes: { 200: 12_000 },
27
+ avgBodySize: 6400,
28
+ ...overrides
29
+ };
30
+ }
31
+
32
+ function makeResults(scenarios: ScenarioResult[]): LoadTestResults {
33
+ return {
34
+ baseUrl: 'http://localhost:3000',
35
+ startedAt: new Date().toISOString(),
36
+ completedAt: new Date().toISOString(),
37
+ scenarios
38
+ };
39
+ }
40
+
41
+ describe('formatResults — stack-overhead note', () => {
42
+ test('given any results, when formatted, then note does not mention Prisma', () => {
43
+ const report = formatResults(makeResults([makeScenario()]));
44
+ expect(report).not.toContain('Prisma');
45
+ });
46
+
47
+ test('given any results, when formatted, then note omits stale hardcoded pool numbers', () => {
48
+ const report = formatResults(makeResults([makeScenario()]));
49
+ expect(report).not.toContain('6,102-13,781');
50
+ });
51
+
52
+ test('given any results, when formatted, then note credits the ORM/query layer generically', () => {
53
+ const report = formatResults(makeResults([makeScenario()]));
54
+ expect(report).toContain('ORM/query');
55
+ });
56
+ });
57
+
58
+ describe('formatResults — variance (CoV) column', () => {
59
+ test('given single-run scenarios, when formatted, then no CoV column is shown', () => {
60
+ const report = formatResults(makeResults([makeScenario()]));
61
+ expect(report).not.toContain('CoV%');
62
+ expect(report).not.toContain('Runs');
63
+ });
64
+
65
+ test('given a repeated scenario, when formatted, then a CoV column appears', () => {
66
+ const report = formatResults(
67
+ makeResults([
68
+ makeScenario({ runs: 3, throughputCoV: 18, p95Median: 150 })
69
+ ])
70
+ );
71
+ expect(report).toContain('CoV%');
72
+ expect(report).toContain('±18%');
73
+ expect(report).toContain('coefficient of variation');
74
+ });
75
+ });
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
6
6
  * measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
7
- * ORM (Prisma), SSR rendering, etc.
7
+ * the ORM/query layer, SSR rendering, etc.
8
8
  *
9
9
  * Usage:
10
10
  * Import into app-specific load tests:
@@ -28,6 +28,14 @@ export type LoadTestConfig = {
28
28
  durationSec: number;
29
29
  /** Optional: warm-up requests before timing */
30
30
  warmupRequests?: number;
31
+ /**
32
+ * Optional: number of times to repeat each scenario (default 1).
33
+ * When > 1, each (endpoint, concurrency) runs N times and results are
34
+ * aggregated — medians for throughput/latency, plus a coefficient of
35
+ * variation so run-to-run dispersion is visible. Defends against the
36
+ * ~±30% single-run variance seen on virtualized hosts (e.g. WSL2).
37
+ */
38
+ repeat?: number;
31
39
  };
32
40
 
33
41
  export type EndpointConfig = {
@@ -75,6 +83,21 @@ export type ScenarioResult = {
75
83
  avgBodySize: number;
76
84
 
77
85
  via?: 'cdn' | 'direct';
86
+
87
+ /**
88
+ * Variance fields — only populated when the scenario was run more than
89
+ * once (LoadTestConfig.repeat > 1). Absent for single-run scenarios, so
90
+ * the single-run output shape is unchanged.
91
+ */
92
+ runs?: number;
93
+ /** Lowest per-run throughput (req/s) across the N runs. */
94
+ throughputMin?: number;
95
+ /** Highest per-run throughput (req/s) across the N runs. */
96
+ throughputMax?: number;
97
+ /** Coefficient of variation of throughput across runs, as a percent. */
98
+ throughputCoV?: number;
99
+ /** Median of the per-run p95 values (NOT p95 of pooled latencies). */
100
+ p95Median?: number;
78
101
  };
79
102
 
80
103
  export type LoadTestResults = {
@@ -226,6 +249,82 @@ async function runScenario(
226
249
  };
227
250
  }
228
251
 
252
+ /* ------------------------------------------------------------------
253
+ * Multi-run aggregation
254
+ * ------------------------------------------------------------------ */
255
+ function median(values: number[]): number {
256
+ if (values.length === 0) return 0;
257
+ const sorted = [...values].sort((a, b) => a - b);
258
+ const mid = Math.floor(sorted.length / 2);
259
+ return sorted.length % 2 !== 0
260
+ ? sorted[mid]!
261
+ : (sorted[mid - 1]! + sorted[mid]!) / 2;
262
+ }
263
+
264
+ function coefficientOfVariation(values: number[]): number {
265
+ if (values.length < 2) return 0;
266
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
267
+ if (mean === 0) return 0;
268
+ const variance =
269
+ values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
270
+ return (Math.sqrt(variance) / mean) * 100;
271
+ }
272
+
273
+ /**
274
+ * Collapse N per-run ScenarioResults into one. Threshold-checked fields
275
+ * (throughput, successRate) use the median so a single outlier run does not
276
+ * flip pass/fail; counts are summed; variance fields expose dispersion.
277
+ * A single run is returned unchanged (no variance fields → identical shape).
278
+ */
279
+ function aggregateRuns(runs: ScenarioResult[]): ScenarioResult {
280
+ if (runs.length === 1) return runs[0]!;
281
+
282
+ const first = runs[0]!;
283
+ const throughputs = runs.map(r => r.throughput);
284
+
285
+ const statusCodes: Record<number, number> = {};
286
+ for (const r of runs) {
287
+ for (const [code, count] of Object.entries(r.statusCodes)) {
288
+ statusCodes[Number(code)] = (statusCodes[Number(code)] ?? 0) + count;
289
+ }
290
+ }
291
+
292
+ const p95Median = median(runs.map(r => r.p95));
293
+
294
+ return {
295
+ endpoint: first.endpoint,
296
+ label: first.label,
297
+ method: first.method,
298
+ concurrency: first.concurrency,
299
+ durationSec: first.durationSec,
300
+
301
+ totalRequests: runs.reduce((s, r) => s + r.totalRequests, 0),
302
+ successCount: runs.reduce((s, r) => s + r.successCount, 0),
303
+ failCount: runs.reduce((s, r) => s + r.failCount, 0),
304
+ successRate: median(runs.map(r => r.successRate)),
305
+
306
+ throughput: median(throughputs),
307
+
308
+ p50: median(runs.map(r => r.p50)),
309
+ p95: p95Median,
310
+ p99: median(runs.map(r => r.p99)),
311
+ min: Math.min(...runs.map(r => r.min)),
312
+ max: Math.max(...runs.map(r => r.max)),
313
+ avg: runs.reduce((s, r) => s + r.avg, 0) / runs.length,
314
+
315
+ statusCodes,
316
+ avgBodySize: runs.reduce((s, r) => s + r.avgBodySize, 0) / runs.length,
317
+
318
+ via: first.via,
319
+
320
+ runs: runs.length,
321
+ throughputMin: Math.min(...throughputs),
322
+ throughputMax: Math.max(...throughputs),
323
+ throughputCoV: coefficientOfVariation(throughputs),
324
+ p95Median
325
+ };
326
+ }
327
+
229
328
  /* ------------------------------------------------------------------
230
329
  * Main load test runner
231
330
  * ------------------------------------------------------------------ */
@@ -235,19 +334,23 @@ export async function runLoadTest(
235
334
  const startedAt = new Date().toISOString();
236
335
  const scenarios: ScenarioResult[] = [];
237
336
  const warmup = config.warmupRequests ?? 5;
337
+ const repeat = Math.max(1, config.repeat ?? 1);
238
338
 
239
339
  for (const endpoint of config.endpoints) {
240
340
  for (const concurrency of config.concurrencyLevels) {
241
- const _label = endpoint.label ?? endpoint.path;
242
-
243
- const result = await runScenario(
244
- config.baseUrl,
245
- endpoint,
246
- concurrency,
247
- config.durationSec,
248
- warmup
249
- );
250
- scenarios.push(result);
341
+ const runs: ScenarioResult[] = [];
342
+ for (let i = 0; i < repeat; i++) {
343
+ runs.push(
344
+ await runScenario(
345
+ config.baseUrl,
346
+ endpoint,
347
+ concurrency,
348
+ config.durationSec,
349
+ warmup
350
+ )
351
+ );
352
+ }
353
+ scenarios.push(aggregateRuns(runs));
251
354
  }
252
355
  }
253
356
 
@@ -274,8 +377,12 @@ export function formatResults(results: LoadTestResults): string {
274
377
  lines.push('='.repeat(100));
275
378
  lines.push('');
276
379
 
380
+ // Show the variance column only when at least one scenario was repeated.
381
+ // Single-run output keeps its original columns unchanged.
382
+ const showCoV = results.scenarios.some(s => (s.runs ?? 1) > 1);
383
+
277
384
  // Summary table header
278
- const header = [
385
+ const headerCols = [
279
386
  'Endpoint'.padEnd(25),
280
387
  'Conc'.padStart(5),
281
388
  'Req/s'.padStart(8),
@@ -285,13 +392,17 @@ export function formatResults(results: LoadTestResults): string {
285
392
  'P99ms'.padStart(8),
286
393
  'Success'.padStart(8),
287
394
  'AvgBody'.padStart(8)
288
- ].join(' | ');
395
+ ];
396
+ if (showCoV) {
397
+ headerCols.push('Runs'.padStart(5), 'CoV%'.padStart(7));
398
+ }
399
+ const header = headerCols.join(' | ');
289
400
 
290
401
  lines.push(header);
291
402
  lines.push('-'.repeat(header.length));
292
403
 
293
404
  for (const s of results.scenarios) {
294
- const row = [
405
+ const cols = [
295
406
  s.label.padEnd(25).slice(0, 25),
296
407
  String(s.concurrency).padStart(5),
297
408
  s.throughput.toFixed(0).padStart(8),
@@ -301,21 +412,36 @@ export function formatResults(results: LoadTestResults): string {
301
412
  s.p99.toFixed(1).padStart(8),
302
413
  `${s.successRate.toFixed(1)}%`.padStart(8),
303
414
  formatBytes(s.avgBodySize).padStart(8)
304
- ].join(' | ');
305
- lines.push(row);
415
+ ];
416
+ if (showCoV) {
417
+ cols.push(
418
+ String(s.runs ?? 1).padStart(5),
419
+ (s.throughputCoV !== undefined
420
+ ? `±${s.throughputCoV.toFixed(0)}%`
421
+ : '-'
422
+ ).padStart(7)
423
+ );
424
+ }
425
+ lines.push(cols.join(' | '));
306
426
  }
307
427
 
308
428
  lines.push('');
309
429
 
310
- // Pool-level comparison note
430
+ // Stack-overhead note
311
431
  lines.push('-'.repeat(100));
312
432
  lines.push(
313
- ' NOTE: Pool-level benchmarks (raw pool.exec) show 6,102-13,781 req/s.'
433
+ ' NOTE: Application-level throughput (full HTTP stack) is lower than raw'
314
434
  );
315
435
  lines.push(
316
- ' Application-level throughput is lower due to HTTP overhead, middleware,'
436
+ ' pool.exec() benchmarks due to HTTP overhead, middleware, the ORM/query'
317
437
  );
318
- lines.push(' Prisma ORM, SSR rendering, and serialization.');
438
+ lines.push(' layer, SSR rendering, and serialization.');
439
+ if (showCoV) {
440
+ lines.push(
441
+ ' CoV% = coefficient of variation of throughput across repeated runs'
442
+ );
443
+ lines.push(' (higher = noisier host; treat deltas below CoV% as noise).');
444
+ }
319
445
  lines.push('-'.repeat(100));
320
446
  lines.push('');
321
447