@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Chavez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # @bitclaw/sqlite
2
+
3
+ High-performance SQLite optimization package for multi-app SaaS deployments.
4
+
5
+ ## Features
6
+
7
+ - **Worker Thread Pool**: Multiple SQLite connections for concurrent read/write operations
8
+ - **Statement Caching**: Automatic prepared statement caching per worker
9
+ - **Write Concurrency**: Per-resource write mutex and retry with exponential backoff
10
+ - **Prisma Integration**: BEGIN IMMEDIATE transaction wrapper for Prisma + libsql
11
+ - **JSON File Cache**: levelsio-style file-based caching with TTL support
12
+ - **Zero External Dependencies**: Uses bun:sqlite (built into the Bun runtime)
13
+ - **TypeScript**: Full type safety and IDE autocomplete
14
+
15
+ ## Modules
16
+
17
+ | Import | Purpose |
18
+ |--------|---------|
19
+ | `@bitclaw/sqlite/pool` | Worker thread pool for concurrent SQLite operations |
20
+ | `@bitclaw/sqlite/connection` | Single connection with optimized PRAGMAs |
21
+ | `@bitclaw/sqlite/json-cache` | File-based JSON cache with TTL |
22
+ | `@bitclaw/sqlite/write-mutex` | Promise-based per-resource write mutex |
23
+ | `@bitclaw/sqlite/retry` | Exponential backoff retry for SQLITE_BUSY errors |
24
+ | `@bitclaw/sqlite/prisma-immediate-tx` | BEGIN IMMEDIATE wrapper for Prisma transactions |
25
+ | `@bitclaw/sqlite/query-logger` | Dev-mode SQL logging for bun:sqlite (mirrors Prisma's `prisma:query`) |
26
+ | `@bitclaw/sqlite/ttl-cache` | In-memory TTL cache for server-side deduplication across HTTP requests |
27
+
28
+ ## Performance
29
+
30
+ ### Pool-Level Benchmarks (raw `pool.exec()`)
31
+
32
+ Based on benchmarks from SecureLogin project:
33
+
34
+ - **6,102 - 13,781 req/s** on Hetzner CPX21 (3 vCPU, 4GB RAM)
35
+ - **P95 latency**: 12-22ms
36
+ - **100% success rate** under load
37
+ - **Prepared statement cache hit rate**: 100%
38
+
39
+ These numbers reflect **direct SQLite pool operations** — no HTTP server, no ORM, no middleware. Application-level throughput (through TanStack Start + Prisma + SSR) will be lower. Use `bun run test:load` in each app for end-to-end numbers.
40
+
41
+ ## Benchmarking Methodology
42
+
43
+ ### Pool-Level (`scripts/benchmark.ts`)
44
+
45
+ Tests raw `pool.exec()` calls against an in-process SQLite database:
46
+
47
+ - Creates a temporary database with a `users` table
48
+ - Runs concurrent workers executing `SELECT COUNT(*)` (reads) and `INSERT` (writes)
49
+ - Measures latency per operation, calculates P50/P95/P99
50
+ - No HTTP, no ORM, no serialization overhead
51
+
52
+ This represents the **theoretical ceiling** for SQLite throughput on given hardware.
53
+
54
+ ### Application-Level (`bun run test:load`)
55
+
56
+ Tests end-to-end HTTP throughput through the full stack:
57
+
58
+ - Uses native `fetch()` against a running application server
59
+ - Measures real response times including: HTTP parsing, middleware, Prisma ORM, SSR rendering, serialization
60
+ - Tests at multiple concurrency levels (10/50/100)
61
+ - Reports req/s, P50/P95/P99 latency, success rate
62
+
63
+ This represents **actual user-facing performance**.
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ bun add @bitclaw/sqlite
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ ### Worker Pool
74
+
75
+ ```typescript
76
+ import { createPool } from '@bitclaw/sqlite/pool';
77
+
78
+ const pool = createPool({
79
+ databasePath: './data/app.db',
80
+ poolSize: 4,
81
+ timeout: 30000
82
+ });
83
+
84
+ // Execute query
85
+ const users = await pool.exec('SELECT * FROM users WHERE id = ?', [userId]);
86
+
87
+ // Graceful shutdown
88
+ await pool.shutdown();
89
+ ```
90
+
91
+ ### Write Mutex
92
+
93
+ Serialize writes per resource to prevent SQLITE_BUSY contention before it reaches SQLite:
94
+
95
+ ```typescript
96
+ import { WriteMutex, WriteMutexMap } from '@bitclaw/sqlite/write-mutex';
97
+
98
+ // Single mutex
99
+ const mutex = new WriteMutex();
100
+ const result = await mutex.acquire(() => db.run('INSERT INTO ...'));
101
+
102
+ // Named mutexes (e.g. per-tenant)
103
+ const mutexes = new WriteMutexMap();
104
+ await mutexes.withLock('workspace-123', () => db.run('INSERT INTO ...'));
105
+ ```
106
+
107
+ ### Retry with Backoff
108
+
109
+ Retry operations that fail with SQLITE_BUSY using exponential backoff + jitter:
110
+
111
+ ```typescript
112
+ import { withRetry } from '@bitclaw/sqlite/retry';
113
+
114
+ const result = await withRetry(
115
+ () => db.run('INSERT INTO ...'),
116
+ { maxAttempts: 3, baseDelayMs: 100 }
117
+ );
118
+ ```
119
+
120
+ ### Prisma BEGIN IMMEDIATE
121
+
122
+ Prevent SQLITE_BUSY from Prisma's default deferred transactions:
123
+
124
+ ```typescript
125
+ import { immediateTransaction } from '@bitclaw/sqlite/prisma-immediate-tx';
126
+ import { withRetry } from '@bitclaw/sqlite/retry';
127
+
128
+ await withRetry(() =>
129
+ immediateTransaction(prisma, async () => {
130
+ await prisma.user.create({ data: { ... } });
131
+ await prisma.session.update({ where: { ... }, data: { ... } });
132
+ })
133
+ );
134
+ ```
135
+
136
+ ### JSON Cache
137
+
138
+ ```typescript
139
+ import { createJsonCache } from '@bitclaw/sqlite/json-cache';
140
+
141
+ const cache = createJsonCache({
142
+ cacheDir: './cache',
143
+ defaultTtl: 300000 // 5 minutes
144
+ });
145
+
146
+ // Set value
147
+ await cache.set('user:123', userData, { ttl: 600000 });
148
+
149
+ // Get value
150
+ const user = await cache.get('user:123');
151
+
152
+ // Delete value
153
+ await cache.delete('user:123');
154
+ ```
155
+
156
+ ### Query Logger
157
+
158
+ Dev-mode SQL logging for bun:sqlite — mirrors Prisma's `prisma:query` output. Zero overhead in production.
159
+
160
+ ```typescript
161
+ import { Database } from 'bun:sqlite';
162
+ import { wrapWithQueryLogging } from '@bitclaw/sqlite/query-logger';
163
+
164
+ const raw = new Database('./data/workspace.db');
165
+ const db = wrapWithQueryLogging(raw, { label: 'ws:abc123' });
166
+
167
+ // In development, every query/prepare/run/exec logs to stdout:
168
+ // sqlite:query [ws:abc123] SELECT * FROM servers WHERE id = ?
169
+ //
170
+ // In production (NODE_ENV !== 'development'), returns the database unchanged.
171
+ db.query('SELECT * FROM servers WHERE id = ?').get(serverId);
172
+ ```
173
+
174
+ ### TTL Cache
175
+
176
+ In-memory TTL cache for server-side deduplication across HTTP requests. Designed for caching expensive lookups (auth sessions, membership checks, bootstrap data) that repeat when frameworks like TanStack Router re-run loaders on client hydration.
177
+
178
+ ```typescript
179
+ import { TTLCache } from '@bitclaw/sqlite/ttl-cache';
180
+
181
+ type BootstrapData = { user: User; workspaces: Workspace[] };
182
+
183
+ const bootstrapCache = new TTLCache<BootstrapData>({
184
+ ttl: 30_000, // 30s (default)
185
+ maxSize: 100 // auto-prune expired entries when exceeded (default)
186
+ });
187
+
188
+ // In your server function:
189
+ const cached = bootstrapCache.get(sessionId);
190
+ if (cached) return cached;
191
+
192
+ const data = await expensiveQuery();
193
+ bootstrapCache.set(sessionId, data);
194
+ return data;
195
+ ```
196
+
197
+ Unlike `WeakMap` per-request caching (which deduplicates within a single SSR request), `TTLCache` deduplicates **across** HTTP requests — e.g. when TanStack Router replays `beforeLoad` on client hydration, the server returns the cached result instantly (0 DB queries).
198
+
199
+ ## Configuration
200
+
201
+ ### Environment Variables
202
+
203
+ ```bash
204
+ SQLITE_POOL_SIZE=4 # Number of worker threads
205
+ SQLITE_WORKER_TIMEOUT=30000 # Query timeout in milliseconds
206
+ DATABASE_PATH=./data/app.db # Database file path
207
+ JSON_CACHE_DIR=./cache # Cache directory
208
+ JSON_CACHE_TTL=300000 # Default TTL in milliseconds
209
+ ```
210
+
211
+ ## Benchmarking
212
+
213
+ ```bash
214
+ # Quick benchmark (5 seconds, default)
215
+ bun run benchmark
216
+
217
+ # Quick benchmark (explicit)
218
+ bun run benchmark:quick
219
+
220
+ # CPX21 tier benchmark (matches production)
221
+ bun run benchmark -- --tier cpx21
222
+
223
+ # All Hetzner tiers
224
+ bun run benchmark -- --tiers
225
+
226
+ # Help
227
+ bun run benchmark -- --help
228
+ ```
229
+
230
+ ## Architecture
231
+
232
+ ### Worker Pool
233
+ - Uses Node.js worker threads for true concurrency
234
+ - Round-robin query distribution
235
+ - Automatic connection health monitoring
236
+ - Graceful shutdown with connection cleanup
237
+
238
+ ### Write Concurrency
239
+ - **Write Mutex**: Promise-based per-resource lock serializes writes before they hit SQLite, eliminating contention at near-zero overhead
240
+ - **Retry**: Exponential backoff with jitter catches any remaining SQLITE_BUSY errors as a safety net (default: 3 attempts, 100ms base delay)
241
+ - **BEGIN IMMEDIATE**: Prisma wrapper acquires write lock upfront instead of deferring, preventing the deadlock-prone lock upgrade path
242
+
243
+ ### JSON Cache
244
+ - Atomic writes using temp files
245
+ - TTL-based expiration
246
+ - Stale-while-revalidate support
247
+ - No external dependencies
248
+
249
+ ## License
250
+
251
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=benchmark.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"benchmark.d.ts","sourceRoot":"","sources":["../../scripts/benchmark.ts"],"names":[],"mappings":""}
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env bun
2
+ // packages/sqlite/scripts/benchmark.ts
3
+ // SQLite performance benchmark for Hetzner VPS tiers
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+ import { createPool, shutdownPool } from '../src/pool';
8
+ /* ------------------------------------------------------------------
9
+ * Hetzner VPS Tiers (vs AWS Lambda)
10
+ * ------------------------------------------------------------------ */
11
+ const HETZNER_TIERS = [
12
+ { name: 'CPX11', vcpu: 2, ram: 2048, pool: 2, monthly: 4.15 }, // Entry
13
+ { name: 'CPX21', vcpu: 3, ram: 4096, pool: 4, monthly: 7.49 }, // DEFAULT
14
+ { name: 'CPX31', vcpu: 4, ram: 8192, pool: 4, monthly: 15.49 }, // Mid
15
+ { name: 'CPX41', vcpu: 8, ram: 16384, pool: 6, monthly: 30.99 } // High
16
+ ];
17
+ /* ------------------------------------------------------------------
18
+ * Performance Targets
19
+ * ------------------------------------------------------------------ */
20
+ const TARGETS = {
21
+ p95LatencyMs: 40,
22
+ errorRatePct: 0.5,
23
+ throughputQps: 1000
24
+ };
25
+ /* ------------------------------------------------------------------
26
+ * Helper utilities
27
+ * ------------------------------------------------------------------ */
28
+ function percentile(sorted, p) {
29
+ if (!sorted.length)
30
+ return 0;
31
+ const idx = (p / 100) * (sorted.length - 1);
32
+ const lo = Math.floor(idx);
33
+ const hi = Math.ceil(idx);
34
+ if (lo === hi)
35
+ return sorted[lo];
36
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
37
+ }
38
+ /* ------------------------------------------------------------------
39
+ * Benchmark runner class
40
+ * ------------------------------------------------------------------ */
41
+ class PoolBench {
42
+ results = [];
43
+ failCount = 0;
44
+ async run(cfg) {
45
+ this.failCount = 0;
46
+ await PoolBench.initDatabase();
47
+ const pool = createPool({
48
+ databasePath: process.env.DATABASE_PATH,
49
+ poolSize: cfg.poolSize,
50
+ timeout: 30000
51
+ });
52
+ const lat = [];
53
+ const t0 = performance.now();
54
+ const end = t0 + cfg.durationSec * 1_000;
55
+ // Create concurrent workers
56
+ const workers = Array.from({ length: cfg.concurrency }, () => PoolBench.workerLoop(cfg.op, pool.exec.bind(pool), end, lat, this));
57
+ await Promise.allSettled(workers);
58
+ const durSec = (performance.now() - t0) / 1_000;
59
+ lat.sort((a, b) => a - b);
60
+ const total = lat.length;
61
+ const fail = this.failCount;
62
+ const ok = total - fail;
63
+ const r = {
64
+ op: cfg.op,
65
+ pool: cfg.poolSize,
66
+ tierName: cfg.tierName,
67
+ tierCost: cfg.tierCost,
68
+ total,
69
+ ok,
70
+ fail,
71
+ errPct: total ? (fail / total) * 100 : 0,
72
+ tput: total / durSec,
73
+ p50: percentile(lat, 50),
74
+ p95: percentile(lat, 95),
75
+ p99: percentile(lat, 99),
76
+ max: lat.at(-1) ?? 0,
77
+ min: lat[0] ?? 0
78
+ };
79
+ this.results.push(r);
80
+ await PoolBench.cleanupPool();
81
+ }
82
+ static async initDatabase() {
83
+ // Ensure data/ directory exists
84
+ const dataDir = path.resolve(import.meta.dir, '..', 'data');
85
+ if (!fs.existsSync(dataDir)) {
86
+ fs.mkdirSync(dataDir, { recursive: true });
87
+ }
88
+ const timestamp = Date.now();
89
+ const random = Math.random().toString(36).substring(2, 8);
90
+ const pid = process.pid;
91
+ const dbFile = path.join(dataDir, `bench-${timestamp}-${pid}-${random}.db`);
92
+ process.env.DATABASE_PATH = dbFile;
93
+ // Clean up old test files
94
+ for (const ext of ['', '-shm', '-wal']) {
95
+ const file = dbFile + ext;
96
+ if (fs.existsSync(file)) {
97
+ fs.unlinkSync(file);
98
+ }
99
+ }
100
+ await new Promise(resolve => setTimeout(resolve, 100));
101
+ // Create test table using bun:sqlite
102
+ const { Database } = await import('bun:sqlite');
103
+ const testDb = new Database(dbFile);
104
+ try {
105
+ // Pre-set WAL mode so pool workers don't race to set it
106
+ testDb.run('PRAGMA journal_mode = WAL');
107
+ testDb.run('PRAGMA busy_timeout = 5000');
108
+ testDb.run(`
109
+ CREATE TABLE IF NOT EXISTS users (
110
+ id TEXT PRIMARY KEY,
111
+ primary_email TEXT NOT NULL UNIQUE,
112
+ name TEXT,
113
+ max_sessions INTEGER DEFAULT 5,
114
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
115
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
116
+ );
117
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(primary_email);
118
+ `);
119
+ }
120
+ catch (error) {
121
+ console.error('[db] CRITICAL: Tables not found:', error);
122
+ throw new Error('Migration verification failed');
123
+ }
124
+ finally {
125
+ testDb.close();
126
+ }
127
+ // Give the OS time to release the file lock after close
128
+ await new Promise(resolve => setTimeout(resolve, 200));
129
+ }
130
+ static async cleanupPool() {
131
+ try {
132
+ // shutdownPool can hang if workers are stuck; add a timeout
133
+ await Promise.race([
134
+ shutdownPool(),
135
+ new Promise(resolve => setTimeout(resolve, 5000))
136
+ ]);
137
+ }
138
+ finally {
139
+ const dbPath = process.env.DATABASE_PATH;
140
+ if (dbPath && dbPath !== ':memory:' && fs.existsSync(dbPath)) {
141
+ try {
142
+ await new Promise(resolve => setTimeout(resolve, 200));
143
+ for (const ext of ['', '-shm', '-wal']) {
144
+ const file = dbPath + ext;
145
+ if (fs.existsSync(file)) {
146
+ fs.unlinkSync(file);
147
+ }
148
+ }
149
+ }
150
+ catch (error) {
151
+ console.warn(`Warning: Could not clean up ${dbPath}:`, error);
152
+ }
153
+ }
154
+ delete process.env.DATABASE_PATH;
155
+ await new Promise(resolve => setTimeout(resolve, 1000));
156
+ }
157
+ }
158
+ static async workerLoop(op, exec, endTs, lat, benchInstance) {
159
+ let localFailCount = 0;
160
+ while (performance.now() < endTs) {
161
+ const start = performance.now();
162
+ try {
163
+ await PoolBench.doOne(op, exec);
164
+ }
165
+ catch (error) {
166
+ localFailCount += 1;
167
+ benchInstance.failCount += 1;
168
+ if (localFailCount <= 3) {
169
+ console.error(`Worker failure ${localFailCount}:`, error instanceof Error ? error.message : String(error));
170
+ }
171
+ }
172
+ finally {
173
+ lat.push(performance.now() - start);
174
+ }
175
+ await new Promise(res => setTimeout(res, 1));
176
+ }
177
+ }
178
+ static async doOne(op, exec) {
179
+ const id = `u-${Math.random().toString(36).slice(2)}`;
180
+ const email = `${id}@bench.test`;
181
+ switch (op) {
182
+ case 'read':
183
+ return exec('SELECT COUNT(*) as c FROM users;');
184
+ case 'write':
185
+ return exec(`INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
186
+ VALUES (?, ?, 5, ?, ?)`, [id, email, new Date().toISOString(), new Date().toISOString()]);
187
+ default: // mixed
188
+ return Math.random() < 0.7
189
+ ? exec('SELECT COUNT(*) as c FROM users;')
190
+ : exec(`INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
191
+ VALUES (?, ?, 5, ?, ?)`, [id, email, new Date().toISOString(), new Date().toISOString()]);
192
+ }
193
+ }
194
+ print() {
195
+ console.info('\n═══════════════════════════════════════════════════════');
196
+ console.info(' SQLite Pool Benchmark Results');
197
+ console.info('═══════════════════════════════════════════════════════\n');
198
+ for (const r of this.results) {
199
+ const tierText = r.tierName ? ` tier=${r.tierName}` : '';
200
+ const costText = r.tierCost ? ` (€${r.tierCost}/mo)` : '';
201
+ console.info(` op=${r.op} pool=${r.pool}${tierText}${costText}`);
202
+ console.info(` total=${r.total} ok=${r.ok} fail=${r.fail} err=${r.errPct.toFixed(2)}%`);
203
+ console.info(` throughput=${r.tput.toFixed(0)} qps`);
204
+ console.info(` latency: p50=${r.p50.toFixed(1)}ms p95=${r.p95.toFixed(1)}ms p99=${r.p99.toFixed(1)}ms max=${r.max.toFixed(1)}ms`);
205
+ console.info('');
206
+ }
207
+ }
208
+ allPassed() {
209
+ return this.results.every(r => r.p95 <= TARGETS.p95LatencyMs &&
210
+ r.errPct <= TARGETS.errorRatePct &&
211
+ r.tput >= TARGETS.throughputQps);
212
+ }
213
+ }
214
+ /* ------------------------------------------------------------------
215
+ * CLI runner
216
+ * ------------------------------------------------------------------ */
217
+ async function main() {
218
+ const bench = new PoolBench();
219
+ const args = process.argv.slice(2);
220
+ const wantQuick = args.includes('--quick');
221
+ const wantTiers = args.includes('--tiers');
222
+ const wantCpx21 = args.includes('--tier') && args.includes('cpx21');
223
+ if (args.includes('--help')) {
224
+ console.info(`Usage: bun benchmark [options]
225
+
226
+ Options:
227
+ --quick Quick benchmark (pool=4, concurrency=20, 5s)
228
+ --tier cpx21 Simulate CPX21 tier (pool=4, concurrency=60, 10s)
229
+ --tiers Run all Hetzner tiers (CPX11/21/31/41)
230
+ --help Show this help
231
+
232
+ Targets: p95 < ${TARGETS.p95LatencyMs}ms, error < ${TARGETS.errorRatePct}%, throughput > ${TARGETS.throughputQps} qps
233
+ `);
234
+ process.exit(0);
235
+ }
236
+ try {
237
+ if (wantTiers) {
238
+ for (const t of HETZNER_TIERS) {
239
+ await bench.run({
240
+ poolSize: t.pool,
241
+ concurrency: t.pool * 15,
242
+ durationSec: 10,
243
+ op: 'mixed',
244
+ tierName: t.name,
245
+ tierCost: t.monthly
246
+ });
247
+ }
248
+ }
249
+ else if (wantCpx21) {
250
+ const cpx21 = HETZNER_TIERS.find(t => t.name === 'CPX21');
251
+ await bench.run({
252
+ poolSize: cpx21.pool,
253
+ concurrency: cpx21.pool * 15,
254
+ durationSec: 10,
255
+ op: 'mixed',
256
+ tierName: cpx21.name,
257
+ tierCost: cpx21.monthly
258
+ });
259
+ }
260
+ else if (wantQuick) {
261
+ await bench.run({
262
+ poolSize: 4,
263
+ concurrency: 20,
264
+ durationSec: 5,
265
+ op: 'mixed'
266
+ });
267
+ }
268
+ else {
269
+ // Default: Quick test
270
+ await bench.run({
271
+ poolSize: 4,
272
+ concurrency: 20,
273
+ durationSec: 5,
274
+ op: 'mixed'
275
+ });
276
+ }
277
+ bench.print();
278
+ const ok = bench.allPassed();
279
+ process.exit(ok ? 0 : 1);
280
+ }
281
+ catch (error) {
282
+ console.error('❌ Benchmark failed:', error);
283
+ process.exit(1);
284
+ }
285
+ }
286
+ main().catch(console.error);
@@ -0,0 +1,77 @@
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
+ export type LoadTestConfig = {
17
+ /** Base URL of the application (e.g., http://localhost:3001) */
18
+ baseUrl: string;
19
+ /** Endpoints to test, relative to baseUrl */
20
+ endpoints: EndpointConfig[];
21
+ /** Concurrency levels to test */
22
+ concurrencyLevels: number[];
23
+ /** Duration per scenario in seconds */
24
+ durationSec: number;
25
+ /** Optional: warm-up requests before timing */
26
+ warmupRequests?: number;
27
+ };
28
+ export type EndpointConfig = {
29
+ /** Path relative to baseUrl (e.g., '/healthcheck') */
30
+ path: string;
31
+ /** HTTP method (default: GET) */
32
+ method?: string;
33
+ /** Request body for POST/PUT */
34
+ body?: string;
35
+ /** Additional headers */
36
+ headers?: Record<string, string>;
37
+ /** Human-readable label */
38
+ label?: string;
39
+ };
40
+ export type RequestResult = {
41
+ statusCode: number;
42
+ latencyMs: number;
43
+ success: boolean;
44
+ bodySize: number;
45
+ };
46
+ export type ScenarioResult = {
47
+ endpoint: string;
48
+ label: string;
49
+ method: string;
50
+ concurrency: number;
51
+ durationSec: number;
52
+ totalRequests: number;
53
+ successCount: number;
54
+ failCount: number;
55
+ successRate: number;
56
+ throughput: number;
57
+ p50: number;
58
+ p95: number;
59
+ p99: number;
60
+ min: number;
61
+ max: number;
62
+ avg: number;
63
+ statusCodes: Record<number, number>;
64
+ avgBodySize: number;
65
+ via?: 'cdn' | 'direct';
66
+ };
67
+ export type LoadTestResults = {
68
+ baseUrl: string;
69
+ startedAt: string;
70
+ completedAt: string;
71
+ scenarios: ScenarioResult[];
72
+ };
73
+ export declare function percentile(sorted: number[], p: number): number;
74
+ export declare function measureResponseTime(url: string, method?: string, body?: string, headers?: Record<string, string>): Promise<RequestResult>;
75
+ export declare function runLoadTest(config: LoadTestConfig): Promise<LoadTestResults>;
76
+ export declare function formatResults(results: LoadTestResults): string;
77
+ //# sourceMappingURL=load-test-utils.d.ts.map
@@ -0,0 +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"}