@bitclaw/sqlite 1.1.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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/scripts/benchmark.d.ts +3 -0
- package/dist/scripts/benchmark.d.ts.map +1 -0
- package/dist/scripts/benchmark.js +286 -0
- package/dist/scripts/load-test-utils.d.ts +77 -0
- package/dist/scripts/load-test-utils.d.ts.map +1 -0
- package/dist/scripts/load-test-utils.js +235 -0
- package/dist/src/cache-lock.d.ts +25 -0
- package/dist/src/cache-lock.d.ts.map +1 -0
- package/dist/src/cache-lock.js +95 -0
- package/dist/src/connection.d.ts +26 -0
- package/dist/src/connection.d.ts.map +1 -0
- package/dist/src/connection.js +132 -0
- package/dist/src/json-cache.d.ts +89 -0
- package/dist/src/json-cache.d.ts.map +1 -0
- package/dist/src/json-cache.js +289 -0
- package/dist/src/pool.d.ts +98 -0
- package/dist/src/pool.d.ts.map +1 -0
- package/dist/src/pool.js +331 -0
- package/dist/src/prisma-immediate-tx.d.ts +23 -0
- package/dist/src/prisma-immediate-tx.d.ts.map +1 -0
- package/dist/src/prisma-immediate-tx.js +42 -0
- package/dist/src/query-logger.d.ts +21 -0
- package/dist/src/query-logger.d.ts.map +1 -0
- package/dist/src/query-logger.js +60 -0
- package/dist/src/retry.d.ts +14 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +49 -0
- package/dist/src/ttl-cache.d.ts +57 -0
- package/dist/src/ttl-cache.d.ts.map +1 -0
- package/dist/src/ttl-cache.js +92 -0
- package/dist/src/worker.d.ts +38 -0
- package/dist/src/worker.d.ts.map +1 -0
- package/dist/src/worker.js +294 -0
- package/dist/src/write-mutex.d.ts +33 -0
- package/dist/src/write-mutex.d.ts.map +1 -0
- package/dist/src/write-mutex.js +60 -0
- package/package.json +48 -0
- package/scripts/benchmark.ts +373 -0
- package/scripts/load-test-utils.ts +370 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Shared load test utilities for application-level HTTP throughput testing.
|
|
4
|
+
*
|
|
5
|
+
* Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
|
|
6
|
+
* measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
|
|
7
|
+
* ORM (Prisma), SSR rendering, etc.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* Import into app-specific load tests:
|
|
11
|
+
* import { runLoadTest, formatResults } from '@bitclaw/sqlite/load-test-utils'
|
|
12
|
+
*
|
|
13
|
+
* Or run directly for a quick test:
|
|
14
|
+
* bun run packages/sqlite/scripts/load-test-utils.ts --url http://localhost:3001
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* ------------------------------------------------------------------
|
|
18
|
+
* Types
|
|
19
|
+
* ------------------------------------------------------------------ */
|
|
20
|
+
export type LoadTestConfig = {
|
|
21
|
+
/** Base URL of the application (e.g., http://localhost:3001) */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/** Endpoints to test, relative to baseUrl */
|
|
24
|
+
endpoints: EndpointConfig[];
|
|
25
|
+
/** Concurrency levels to test */
|
|
26
|
+
concurrencyLevels: number[];
|
|
27
|
+
/** Duration per scenario in seconds */
|
|
28
|
+
durationSec: number;
|
|
29
|
+
/** Optional: warm-up requests before timing */
|
|
30
|
+
warmupRequests?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type EndpointConfig = {
|
|
34
|
+
/** Path relative to baseUrl (e.g., '/healthcheck') */
|
|
35
|
+
path: string;
|
|
36
|
+
/** HTTP method (default: GET) */
|
|
37
|
+
method?: string;
|
|
38
|
+
/** Request body for POST/PUT */
|
|
39
|
+
body?: string;
|
|
40
|
+
/** Additional headers */
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
/** Human-readable label */
|
|
43
|
+
label?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type RequestResult = {
|
|
47
|
+
statusCode: number;
|
|
48
|
+
latencyMs: number;
|
|
49
|
+
success: boolean;
|
|
50
|
+
bodySize: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ScenarioResult = {
|
|
54
|
+
endpoint: string;
|
|
55
|
+
label: string;
|
|
56
|
+
method: string;
|
|
57
|
+
concurrency: number;
|
|
58
|
+
durationSec: number;
|
|
59
|
+
|
|
60
|
+
totalRequests: number;
|
|
61
|
+
successCount: number;
|
|
62
|
+
failCount: number;
|
|
63
|
+
successRate: number;
|
|
64
|
+
|
|
65
|
+
throughput: number; // req/s
|
|
66
|
+
|
|
67
|
+
p50: number;
|
|
68
|
+
p95: number;
|
|
69
|
+
p99: number;
|
|
70
|
+
min: number;
|
|
71
|
+
max: number;
|
|
72
|
+
avg: number;
|
|
73
|
+
|
|
74
|
+
statusCodes: Record<number, number>;
|
|
75
|
+
avgBodySize: number;
|
|
76
|
+
|
|
77
|
+
via?: 'cdn' | 'direct';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type LoadTestResults = {
|
|
81
|
+
baseUrl: string;
|
|
82
|
+
startedAt: string;
|
|
83
|
+
completedAt: string;
|
|
84
|
+
scenarios: ScenarioResult[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/* ------------------------------------------------------------------
|
|
88
|
+
* Percentile calculation (reused from benchmark.ts)
|
|
89
|
+
* ------------------------------------------------------------------ */
|
|
90
|
+
export function percentile(sorted: number[], p: number): number {
|
|
91
|
+
if (!sorted.length) return 0;
|
|
92
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
93
|
+
const lo = Math.floor(idx);
|
|
94
|
+
const hi = Math.ceil(idx);
|
|
95
|
+
const loVal = sorted[lo] ?? 0;
|
|
96
|
+
const hiVal = sorted[hi] ?? 0;
|
|
97
|
+
if (lo === hi) return loVal;
|
|
98
|
+
return loVal + (hiVal - loVal) * (idx - lo);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ------------------------------------------------------------------
|
|
102
|
+
* Single request measurement
|
|
103
|
+
* ------------------------------------------------------------------ */
|
|
104
|
+
export async function measureResponseTime(
|
|
105
|
+
url: string,
|
|
106
|
+
method = 'GET',
|
|
107
|
+
body?: string,
|
|
108
|
+
headers?: Record<string, string>
|
|
109
|
+
): Promise<RequestResult> {
|
|
110
|
+
const start = performance.now();
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(url, {
|
|
113
|
+
method,
|
|
114
|
+
body: method !== 'GET' ? body : undefined,
|
|
115
|
+
headers: {
|
|
116
|
+
Accept: 'text/html,application/json',
|
|
117
|
+
'User-Agent': 'sqlite-saas-load-test/1.0',
|
|
118
|
+
...headers
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
const responseBody = await response.text();
|
|
122
|
+
const latencyMs = performance.now() - start;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
statusCode: response.status,
|
|
126
|
+
latencyMs,
|
|
127
|
+
success: response.status >= 200 && response.status < 400,
|
|
128
|
+
bodySize: responseBody.length
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return {
|
|
132
|
+
statusCode: 0,
|
|
133
|
+
latencyMs: performance.now() - start,
|
|
134
|
+
success: false,
|
|
135
|
+
bodySize: 0
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ------------------------------------------------------------------
|
|
141
|
+
* Worker loop for sustained load
|
|
142
|
+
* ------------------------------------------------------------------ */
|
|
143
|
+
async function workerLoop(
|
|
144
|
+
url: string,
|
|
145
|
+
method: string,
|
|
146
|
+
body: string | undefined,
|
|
147
|
+
headers: Record<string, string> | undefined,
|
|
148
|
+
endTs: number,
|
|
149
|
+
results: RequestResult[]
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
while (performance.now() < endTs) {
|
|
152
|
+
const result = await measureResponseTime(url, method, body, headers);
|
|
153
|
+
results.push(result);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ------------------------------------------------------------------
|
|
158
|
+
* Run a single scenario (one endpoint at one concurrency level)
|
|
159
|
+
* ------------------------------------------------------------------ */
|
|
160
|
+
async function runScenario(
|
|
161
|
+
baseUrl: string,
|
|
162
|
+
endpoint: EndpointConfig,
|
|
163
|
+
concurrency: number,
|
|
164
|
+
durationSec: number,
|
|
165
|
+
warmupRequests: number
|
|
166
|
+
): Promise<ScenarioResult> {
|
|
167
|
+
const url = `${baseUrl}${endpoint.path}`;
|
|
168
|
+
const method = endpoint.method ?? 'GET';
|
|
169
|
+
const label = endpoint.label ?? endpoint.path;
|
|
170
|
+
|
|
171
|
+
// Warm-up phase
|
|
172
|
+
if (warmupRequests > 0) {
|
|
173
|
+
const warmups = Array.from(
|
|
174
|
+
{ length: Math.min(warmupRequests, concurrency) },
|
|
175
|
+
() => measureResponseTime(url, method, endpoint.body, endpoint.headers)
|
|
176
|
+
);
|
|
177
|
+
await Promise.allSettled(warmups);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Measurement phase
|
|
181
|
+
const results: RequestResult[] = [];
|
|
182
|
+
const endTs = performance.now() + durationSec * 1000;
|
|
183
|
+
|
|
184
|
+
const workers = Array.from({ length: concurrency }, () =>
|
|
185
|
+
workerLoop(url, method, endpoint.body, endpoint.headers, endTs, results)
|
|
186
|
+
);
|
|
187
|
+
await Promise.allSettled(workers);
|
|
188
|
+
|
|
189
|
+
// Calculate metrics
|
|
190
|
+
const latencies = results.map(r => r.latencyMs).sort((a, b) => a - b);
|
|
191
|
+
const successCount = results.filter(r => r.success).length;
|
|
192
|
+
const failCount = results.length - successCount;
|
|
193
|
+
const totalBodySize = results.reduce((sum, r) => sum + r.bodySize, 0);
|
|
194
|
+
|
|
195
|
+
const statusCodes: Record<number, number> = {};
|
|
196
|
+
for (const r of results) {
|
|
197
|
+
statusCodes[r.statusCode] = (statusCodes[r.statusCode] ?? 0) + 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
endpoint: endpoint.path,
|
|
202
|
+
label,
|
|
203
|
+
method,
|
|
204
|
+
concurrency,
|
|
205
|
+
durationSec,
|
|
206
|
+
|
|
207
|
+
totalRequests: results.length,
|
|
208
|
+
successCount,
|
|
209
|
+
failCount,
|
|
210
|
+
successRate: results.length > 0 ? (successCount / results.length) * 100 : 0,
|
|
211
|
+
|
|
212
|
+
throughput: results.length / durationSec,
|
|
213
|
+
|
|
214
|
+
p50: percentile(latencies, 50),
|
|
215
|
+
p95: percentile(latencies, 95),
|
|
216
|
+
p99: percentile(latencies, 99),
|
|
217
|
+
min: latencies[0] ?? 0,
|
|
218
|
+
max: latencies.at(-1) ?? 0,
|
|
219
|
+
avg:
|
|
220
|
+
latencies.length > 0
|
|
221
|
+
? latencies.reduce((a, b) => a + b, 0) / latencies.length
|
|
222
|
+
: 0,
|
|
223
|
+
|
|
224
|
+
statusCodes,
|
|
225
|
+
avgBodySize: results.length > 0 ? totalBodySize / results.length : 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------
|
|
230
|
+
* Main load test runner
|
|
231
|
+
* ------------------------------------------------------------------ */
|
|
232
|
+
export async function runLoadTest(
|
|
233
|
+
config: LoadTestConfig
|
|
234
|
+
): Promise<LoadTestResults> {
|
|
235
|
+
const startedAt = new Date().toISOString();
|
|
236
|
+
const scenarios: ScenarioResult[] = [];
|
|
237
|
+
const warmup = config.warmupRequests ?? 5;
|
|
238
|
+
|
|
239
|
+
for (const endpoint of config.endpoints) {
|
|
240
|
+
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);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
baseUrl: config.baseUrl,
|
|
256
|
+
startedAt,
|
|
257
|
+
completedAt: new Date().toISOString(),
|
|
258
|
+
scenarios
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ------------------------------------------------------------------
|
|
263
|
+
* Format results as a table
|
|
264
|
+
* ------------------------------------------------------------------ */
|
|
265
|
+
export function formatResults(results: LoadTestResults): string {
|
|
266
|
+
const lines: string[] = [];
|
|
267
|
+
|
|
268
|
+
lines.push('');
|
|
269
|
+
lines.push('='.repeat(100));
|
|
270
|
+
lines.push(' APPLICATION-LEVEL LOAD TEST RESULTS');
|
|
271
|
+
lines.push(` Target: ${results.baseUrl}`);
|
|
272
|
+
lines.push(` Started: ${results.startedAt}`);
|
|
273
|
+
lines.push(` Completed: ${results.completedAt}`);
|
|
274
|
+
lines.push('='.repeat(100));
|
|
275
|
+
lines.push('');
|
|
276
|
+
|
|
277
|
+
// Summary table header
|
|
278
|
+
const header = [
|
|
279
|
+
'Endpoint'.padEnd(25),
|
|
280
|
+
'Conc'.padStart(5),
|
|
281
|
+
'Req/s'.padStart(8),
|
|
282
|
+
'Total'.padStart(7),
|
|
283
|
+
'P50ms'.padStart(8),
|
|
284
|
+
'P95ms'.padStart(8),
|
|
285
|
+
'P99ms'.padStart(8),
|
|
286
|
+
'Success'.padStart(8),
|
|
287
|
+
'AvgBody'.padStart(8)
|
|
288
|
+
].join(' | ');
|
|
289
|
+
|
|
290
|
+
lines.push(header);
|
|
291
|
+
lines.push('-'.repeat(header.length));
|
|
292
|
+
|
|
293
|
+
for (const s of results.scenarios) {
|
|
294
|
+
const row = [
|
|
295
|
+
s.label.padEnd(25).slice(0, 25),
|
|
296
|
+
String(s.concurrency).padStart(5),
|
|
297
|
+
s.throughput.toFixed(0).padStart(8),
|
|
298
|
+
String(s.totalRequests).padStart(7),
|
|
299
|
+
s.p50.toFixed(1).padStart(8),
|
|
300
|
+
s.p95.toFixed(1).padStart(8),
|
|
301
|
+
s.p99.toFixed(1).padStart(8),
|
|
302
|
+
`${s.successRate.toFixed(1)}%`.padStart(8),
|
|
303
|
+
formatBytes(s.avgBodySize).padStart(8)
|
|
304
|
+
].join(' | ');
|
|
305
|
+
lines.push(row);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
lines.push('');
|
|
309
|
+
|
|
310
|
+
// Pool-level comparison note
|
|
311
|
+
lines.push('-'.repeat(100));
|
|
312
|
+
lines.push(
|
|
313
|
+
' NOTE: Pool-level benchmarks (raw pool.exec) show 6,102-13,781 req/s.'
|
|
314
|
+
);
|
|
315
|
+
lines.push(
|
|
316
|
+
' Application-level throughput is lower due to HTTP overhead, middleware,'
|
|
317
|
+
);
|
|
318
|
+
lines.push(' Prisma ORM, SSR rendering, and serialization.');
|
|
319
|
+
lines.push('-'.repeat(100));
|
|
320
|
+
lines.push('');
|
|
321
|
+
|
|
322
|
+
// Status code breakdown
|
|
323
|
+
lines.push(' Status Code Breakdown:');
|
|
324
|
+
for (const s of results.scenarios) {
|
|
325
|
+
const codes = Object.entries(s.statusCodes)
|
|
326
|
+
.map(([code, count]) => `${code}:${count}`)
|
|
327
|
+
.join(', ');
|
|
328
|
+
lines.push(` ${s.label} @${s.concurrency}: ${codes}`);
|
|
329
|
+
}
|
|
330
|
+
lines.push('');
|
|
331
|
+
|
|
332
|
+
return lines.join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function formatBytes(bytes: number): string {
|
|
336
|
+
if (bytes < 1024) return `${Math.round(bytes)}B`;
|
|
337
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
338
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* ------------------------------------------------------------------
|
|
342
|
+
* CLI: run directly for a quick smoke test
|
|
343
|
+
* ------------------------------------------------------------------ */
|
|
344
|
+
async function main() {
|
|
345
|
+
const args = process.argv.slice(2);
|
|
346
|
+
const urlIdx = args.indexOf('--url');
|
|
347
|
+
const baseUrl =
|
|
348
|
+
(urlIdx !== -1 ? args[urlIdx + 1] : undefined) ?? 'http://localhost:3001';
|
|
349
|
+
|
|
350
|
+
if (args.includes('--help')) {
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Quick smoke test against the provided URL
|
|
355
|
+
const _results = await runLoadTest({
|
|
356
|
+
baseUrl,
|
|
357
|
+
endpoints: [{ path: '/', label: 'Homepage' }],
|
|
358
|
+
concurrencyLevels: [1, 10],
|
|
359
|
+
durationSec: 5,
|
|
360
|
+
warmupRequests: 3
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Run if executed directly
|
|
365
|
+
if (import.meta.path === Bun.main) {
|
|
366
|
+
main().catch(err => {
|
|
367
|
+
console.error('Load test failed:', err);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
});
|
|
370
|
+
}
|