@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/dist/scripts/benchmark.d.ts +3 -0
  4. package/dist/scripts/benchmark.d.ts.map +1 -0
  5. package/dist/scripts/benchmark.js +286 -0
  6. package/dist/scripts/load-test-utils.d.ts +77 -0
  7. package/dist/scripts/load-test-utils.d.ts.map +1 -0
  8. package/dist/scripts/load-test-utils.js +235 -0
  9. package/dist/src/cache-lock.d.ts +25 -0
  10. package/dist/src/cache-lock.d.ts.map +1 -0
  11. package/dist/src/cache-lock.js +95 -0
  12. package/dist/src/connection.d.ts +26 -0
  13. package/dist/src/connection.d.ts.map +1 -0
  14. package/dist/src/connection.js +132 -0
  15. package/dist/src/json-cache.d.ts +89 -0
  16. package/dist/src/json-cache.d.ts.map +1 -0
  17. package/dist/src/json-cache.js +289 -0
  18. package/dist/src/pool.d.ts +98 -0
  19. package/dist/src/pool.d.ts.map +1 -0
  20. package/dist/src/pool.js +331 -0
  21. package/dist/src/prisma-immediate-tx.d.ts +23 -0
  22. package/dist/src/prisma-immediate-tx.d.ts.map +1 -0
  23. package/dist/src/prisma-immediate-tx.js +42 -0
  24. package/dist/src/query-logger.d.ts +21 -0
  25. package/dist/src/query-logger.d.ts.map +1 -0
  26. package/dist/src/query-logger.js +60 -0
  27. package/dist/src/retry.d.ts +14 -0
  28. package/dist/src/retry.d.ts.map +1 -0
  29. package/dist/src/retry.js +49 -0
  30. package/dist/src/ttl-cache.d.ts +57 -0
  31. package/dist/src/ttl-cache.d.ts.map +1 -0
  32. package/dist/src/ttl-cache.js +92 -0
  33. package/dist/src/worker.d.ts +38 -0
  34. package/dist/src/worker.d.ts.map +1 -0
  35. package/dist/src/worker.js +294 -0
  36. package/dist/src/write-mutex.d.ts +33 -0
  37. package/dist/src/write-mutex.d.ts.map +1 -0
  38. package/dist/src/write-mutex.js +60 -0
  39. package/package.json +48 -0
  40. package/scripts/benchmark.ts +373 -0
  41. 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
+ }