@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,373 @@
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
+ /* ------------------------------------------------------------------
10
+ * Hetzner VPS Tiers (vs AWS Lambda)
11
+ * ------------------------------------------------------------------ */
12
+ const HETZNER_TIERS = [
13
+ { name: 'CPX11', vcpu: 2, ram: 2048, pool: 2, monthly: 4.15 }, // Entry
14
+ { name: 'CPX21', vcpu: 3, ram: 4096, pool: 4, monthly: 7.49 }, // DEFAULT
15
+ { name: 'CPX31', vcpu: 4, ram: 8192, pool: 4, monthly: 15.49 }, // Mid
16
+ { name: 'CPX41', vcpu: 8, ram: 16384, pool: 6, monthly: 30.99 } // High
17
+ ] as const;
18
+
19
+ /* ------------------------------------------------------------------
20
+ * Performance Targets
21
+ * ------------------------------------------------------------------ */
22
+ const TARGETS = {
23
+ p95LatencyMs: 40,
24
+ errorRatePct: 0.5,
25
+ throughputQps: 1000
26
+ } as const;
27
+
28
+ /* ------------------------------------------------------------------
29
+ * Types
30
+ * ------------------------------------------------------------------ */
31
+ type BenchmarkConfig = {
32
+ poolSize: number;
33
+ concurrency: number;
34
+ durationSec: number;
35
+ op: 'read' | 'write' | 'mixed';
36
+ tierName?: string;
37
+ tierCost?: number;
38
+ };
39
+
40
+ type BenchResult = {
41
+ op: string;
42
+ pool: number;
43
+ tierName?: string;
44
+ tierCost?: number;
45
+
46
+ total: number;
47
+ ok: number;
48
+ fail: number;
49
+
50
+ errPct: number;
51
+ tput: number;
52
+
53
+ p50: number;
54
+ p95: number;
55
+ p99: number;
56
+ max: number;
57
+ min: number;
58
+ };
59
+
60
+ /* ------------------------------------------------------------------
61
+ * Helper utilities
62
+ * ------------------------------------------------------------------ */
63
+ function percentile(sorted: number[], p: number): number {
64
+ if (!sorted.length) return 0;
65
+ const idx = (p / 100) * (sorted.length - 1);
66
+ const lo = Math.floor(idx);
67
+ const hi = Math.ceil(idx);
68
+ if (lo === hi) return sorted[lo]!;
69
+ return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (idx - lo);
70
+ }
71
+
72
+ /* ------------------------------------------------------------------
73
+ * Benchmark runner class
74
+ * ------------------------------------------------------------------ */
75
+ class PoolBench {
76
+ private results: BenchResult[] = [];
77
+ private failCount = 0;
78
+
79
+ async run(cfg: BenchmarkConfig): Promise<void> {
80
+ this.failCount = 0;
81
+
82
+ await PoolBench.initDatabase();
83
+
84
+ const pool = createPool({
85
+ databasePath: process.env.DATABASE_PATH,
86
+ poolSize: cfg.poolSize,
87
+ timeout: 30000
88
+ });
89
+
90
+ const lat: number[] = [];
91
+ const t0 = performance.now();
92
+ const end = t0 + cfg.durationSec * 1_000;
93
+
94
+ // Create concurrent workers
95
+ const workers = Array.from({ length: cfg.concurrency }, () =>
96
+ PoolBench.workerLoop(cfg.op, pool.exec.bind(pool), end, lat, this)
97
+ );
98
+
99
+ await Promise.allSettled(workers);
100
+
101
+ const durSec = (performance.now() - t0) / 1_000;
102
+ lat.sort((a, b) => a - b);
103
+
104
+ const total = lat.length;
105
+ const fail = this.failCount;
106
+ const ok = total - fail;
107
+
108
+ const r: BenchResult = {
109
+ op: cfg.op,
110
+ pool: cfg.poolSize,
111
+ tierName: cfg.tierName,
112
+ tierCost: cfg.tierCost,
113
+
114
+ total,
115
+ ok,
116
+ fail,
117
+
118
+ errPct: total ? (fail / total) * 100 : 0,
119
+ tput: total / durSec,
120
+
121
+ p50: percentile(lat, 50),
122
+ p95: percentile(lat, 95),
123
+ p99: percentile(lat, 99),
124
+ max: lat.at(-1) ?? 0,
125
+ min: lat[0] ?? 0
126
+ };
127
+ this.results.push(r);
128
+
129
+ await PoolBench.cleanupPool();
130
+ }
131
+
132
+ private static async initDatabase(): Promise<void> {
133
+ // Ensure data/ directory exists
134
+ const dataDir = path.resolve(import.meta.dir, '..', 'data');
135
+ if (!fs.existsSync(dataDir)) {
136
+ fs.mkdirSync(dataDir, { recursive: true });
137
+ }
138
+
139
+ const timestamp = Date.now();
140
+ const random = Math.random().toString(36).substring(2, 8);
141
+ const pid = process.pid;
142
+ const dbFile = path.join(dataDir, `bench-${timestamp}-${pid}-${random}.db`);
143
+ process.env.DATABASE_PATH = dbFile;
144
+
145
+ // Clean up old test files
146
+ for (const ext of ['', '-shm', '-wal']) {
147
+ const file = dbFile + ext;
148
+ if (fs.existsSync(file)) {
149
+ fs.unlinkSync(file);
150
+ }
151
+ }
152
+
153
+ await new Promise(resolve => setTimeout(resolve, 100));
154
+
155
+ // Create test table using bun:sqlite
156
+ const { Database } = await import('bun:sqlite');
157
+ const testDb = new Database(dbFile);
158
+ try {
159
+ // Pre-set WAL mode so pool workers don't race to set it
160
+ testDb.run('PRAGMA journal_mode = WAL');
161
+ testDb.run('PRAGMA busy_timeout = 5000');
162
+ testDb.run(`
163
+ CREATE TABLE IF NOT EXISTS users (
164
+ id TEXT PRIMARY KEY,
165
+ primary_email TEXT NOT NULL UNIQUE,
166
+ name TEXT,
167
+ max_sessions INTEGER DEFAULT 5,
168
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
169
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(primary_email);
172
+ `);
173
+ } catch (error: unknown) {
174
+ console.error('[db] CRITICAL: Tables not found:', error);
175
+ throw new Error('Migration verification failed');
176
+ } finally {
177
+ testDb.close();
178
+ }
179
+
180
+ // Give the OS time to release the file lock after close
181
+ await new Promise(resolve => setTimeout(resolve, 200));
182
+ }
183
+
184
+ private static async cleanupPool(): Promise<void> {
185
+ try {
186
+ // shutdownPool can hang if workers are stuck; add a timeout
187
+ await Promise.race([
188
+ shutdownPool(),
189
+ new Promise(resolve => setTimeout(resolve, 5000))
190
+ ]);
191
+ } finally {
192
+ const dbPath = process.env.DATABASE_PATH;
193
+ if (dbPath && dbPath !== ':memory:' && fs.existsSync(dbPath)) {
194
+ try {
195
+ await new Promise(resolve => setTimeout(resolve, 200));
196
+
197
+ for (const ext of ['', '-shm', '-wal']) {
198
+ const file = dbPath + ext;
199
+ if (fs.existsSync(file)) {
200
+ fs.unlinkSync(file);
201
+ }
202
+ }
203
+ } catch (error: unknown) {
204
+ console.warn(`Warning: Could not clean up ${dbPath}:`, error);
205
+ }
206
+ }
207
+ delete process.env.DATABASE_PATH;
208
+
209
+ await new Promise(resolve => setTimeout(resolve, 1000));
210
+ }
211
+ }
212
+
213
+ private static async workerLoop(
214
+ op: BenchmarkConfig['op'],
215
+ exec: (sql: string, params?: unknown[]) => Promise<unknown>,
216
+ endTs: number,
217
+ lat: number[],
218
+ benchInstance: PoolBench
219
+ ): Promise<void> {
220
+ let localFailCount = 0;
221
+
222
+ while (performance.now() < endTs) {
223
+ const start = performance.now();
224
+ try {
225
+ await PoolBench.doOne(op, exec);
226
+ } catch (error: unknown) {
227
+ localFailCount += 1;
228
+ benchInstance.failCount += 1;
229
+
230
+ if (localFailCount <= 3) {
231
+ console.error(
232
+ `Worker failure ${localFailCount}:`,
233
+ error instanceof Error ? error.message : String(error)
234
+ );
235
+ }
236
+ } finally {
237
+ lat.push(performance.now() - start);
238
+ }
239
+ await new Promise(res => setTimeout(res, 1));
240
+ }
241
+ }
242
+
243
+ private static async doOne(
244
+ op: BenchmarkConfig['op'],
245
+ exec: (sql: string, params?: unknown[]) => Promise<unknown>
246
+ ) {
247
+ const id = `u-${Math.random().toString(36).slice(2)}`;
248
+ const email = `${id}@bench.test`;
249
+
250
+ switch (op) {
251
+ case 'read':
252
+ return exec('SELECT COUNT(*) as c FROM users;');
253
+ case 'write':
254
+ return exec(
255
+ `INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
256
+ VALUES (?, ?, 5, ?, ?)`,
257
+ [id, email, new Date().toISOString(), new Date().toISOString()]
258
+ );
259
+ default: // mixed
260
+ return Math.random() < 0.7
261
+ ? exec('SELECT COUNT(*) as c FROM users;')
262
+ : exec(
263
+ `INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
264
+ VALUES (?, ?, 5, ?, ?)`,
265
+ [id, email, new Date().toISOString(), new Date().toISOString()]
266
+ );
267
+ }
268
+ }
269
+
270
+ print(): void {
271
+ console.info('\n═══════════════════════════════════════════════════════');
272
+ console.info(' SQLite Pool Benchmark Results');
273
+ console.info('═══════════════════════════════════════════════════════\n');
274
+ for (const r of this.results) {
275
+ const tierText = r.tierName ? ` tier=${r.tierName}` : '';
276
+ const costText = r.tierCost ? ` (€${r.tierCost}/mo)` : '';
277
+ console.info(` op=${r.op} pool=${r.pool}${tierText}${costText}`);
278
+ console.info(
279
+ ` total=${r.total} ok=${r.ok} fail=${r.fail} err=${r.errPct.toFixed(2)}%`
280
+ );
281
+ console.info(` throughput=${r.tput.toFixed(0)} qps`);
282
+ console.info(
283
+ ` 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`
284
+ );
285
+ console.info('');
286
+ }
287
+ }
288
+
289
+ allPassed(): boolean {
290
+ return this.results.every(
291
+ r =>
292
+ r.p95 <= TARGETS.p95LatencyMs &&
293
+ r.errPct <= TARGETS.errorRatePct &&
294
+ r.tput >= TARGETS.throughputQps
295
+ );
296
+ }
297
+ }
298
+
299
+ /* ------------------------------------------------------------------
300
+ * CLI runner
301
+ * ------------------------------------------------------------------ */
302
+ async function main(): Promise<void> {
303
+ const bench = new PoolBench();
304
+ const args = process.argv.slice(2);
305
+
306
+ const wantQuick = args.includes('--quick');
307
+ const wantTiers = args.includes('--tiers');
308
+ const wantCpx21 = args.includes('--tier') && args.includes('cpx21');
309
+
310
+ if (args.includes('--help')) {
311
+ console.info(`Usage: bun benchmark [options]
312
+
313
+ Options:
314
+ --quick Quick benchmark (pool=4, concurrency=20, 5s)
315
+ --tier cpx21 Simulate CPX21 tier (pool=4, concurrency=60, 10s)
316
+ --tiers Run all Hetzner tiers (CPX11/21/31/41)
317
+ --help Show this help
318
+
319
+ Targets: p95 < ${TARGETS.p95LatencyMs}ms, error < ${TARGETS.errorRatePct}%, throughput > ${TARGETS.throughputQps} qps
320
+ `);
321
+ process.exit(0);
322
+ }
323
+
324
+ try {
325
+ if (wantTiers) {
326
+ for (const t of HETZNER_TIERS) {
327
+ await bench.run({
328
+ poolSize: t.pool,
329
+ concurrency: t.pool * 15,
330
+ durationSec: 10,
331
+ op: 'mixed',
332
+ tierName: t.name,
333
+ tierCost: t.monthly
334
+ });
335
+ }
336
+ } else if (wantCpx21) {
337
+ const cpx21 = HETZNER_TIERS.find(t => t.name === 'CPX21')!;
338
+ await bench.run({
339
+ poolSize: cpx21.pool,
340
+ concurrency: cpx21.pool * 15,
341
+ durationSec: 10,
342
+ op: 'mixed',
343
+ tierName: cpx21.name,
344
+ tierCost: cpx21.monthly
345
+ });
346
+ } else if (wantQuick) {
347
+ await bench.run({
348
+ poolSize: 4,
349
+ concurrency: 20,
350
+ durationSec: 5,
351
+ op: 'mixed'
352
+ });
353
+ } else {
354
+ // Default: Quick test
355
+ await bench.run({
356
+ poolSize: 4,
357
+ concurrency: 20,
358
+ durationSec: 5,
359
+ op: 'mixed'
360
+ });
361
+ }
362
+
363
+ bench.print();
364
+ const ok = bench.allPassed();
365
+
366
+ process.exit(ok ? 0 : 1);
367
+ } catch (error: unknown) {
368
+ console.error('❌ Benchmark failed:', error);
369
+ process.exit(1);
370
+ }
371
+ }
372
+
373
+ main().catch(console.error);