@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,235 @@
|
|
|
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
|
+
* Percentile calculation (reused from benchmark.ts)
|
|
18
|
+
* ------------------------------------------------------------------ */
|
|
19
|
+
export function percentile(sorted, p) {
|
|
20
|
+
if (!sorted.length)
|
|
21
|
+
return 0;
|
|
22
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
23
|
+
const lo = Math.floor(idx);
|
|
24
|
+
const hi = Math.ceil(idx);
|
|
25
|
+
const loVal = sorted[lo] ?? 0;
|
|
26
|
+
const hiVal = sorted[hi] ?? 0;
|
|
27
|
+
if (lo === hi)
|
|
28
|
+
return loVal;
|
|
29
|
+
return loVal + (hiVal - loVal) * (idx - lo);
|
|
30
|
+
}
|
|
31
|
+
/* ------------------------------------------------------------------
|
|
32
|
+
* Single request measurement
|
|
33
|
+
* ------------------------------------------------------------------ */
|
|
34
|
+
export async function measureResponseTime(url, method = 'GET', body, headers) {
|
|
35
|
+
const start = performance.now();
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
method,
|
|
39
|
+
body: method !== 'GET' ? body : undefined,
|
|
40
|
+
headers: {
|
|
41
|
+
Accept: 'text/html,application/json',
|
|
42
|
+
'User-Agent': 'sqlite-saas-load-test/1.0',
|
|
43
|
+
...headers
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const responseBody = await response.text();
|
|
47
|
+
const latencyMs = performance.now() - start;
|
|
48
|
+
return {
|
|
49
|
+
statusCode: response.status,
|
|
50
|
+
latencyMs,
|
|
51
|
+
success: response.status >= 200 && response.status < 400,
|
|
52
|
+
bodySize: responseBody.length
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return {
|
|
57
|
+
statusCode: 0,
|
|
58
|
+
latencyMs: performance.now() - start,
|
|
59
|
+
success: false,
|
|
60
|
+
bodySize: 0
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/* ------------------------------------------------------------------
|
|
65
|
+
* Worker loop for sustained load
|
|
66
|
+
* ------------------------------------------------------------------ */
|
|
67
|
+
async function workerLoop(url, method, body, headers, endTs, results) {
|
|
68
|
+
while (performance.now() < endTs) {
|
|
69
|
+
const result = await measureResponseTime(url, method, body, headers);
|
|
70
|
+
results.push(result);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/* ------------------------------------------------------------------
|
|
74
|
+
* Run a single scenario (one endpoint at one concurrency level)
|
|
75
|
+
* ------------------------------------------------------------------ */
|
|
76
|
+
async function runScenario(baseUrl, endpoint, concurrency, durationSec, warmupRequests) {
|
|
77
|
+
const url = `${baseUrl}${endpoint.path}`;
|
|
78
|
+
const method = endpoint.method ?? 'GET';
|
|
79
|
+
const label = endpoint.label ?? endpoint.path;
|
|
80
|
+
// Warm-up phase
|
|
81
|
+
if (warmupRequests > 0) {
|
|
82
|
+
const warmups = Array.from({ length: Math.min(warmupRequests, concurrency) }, () => measureResponseTime(url, method, endpoint.body, endpoint.headers));
|
|
83
|
+
await Promise.allSettled(warmups);
|
|
84
|
+
}
|
|
85
|
+
// Measurement phase
|
|
86
|
+
const results = [];
|
|
87
|
+
const endTs = performance.now() + durationSec * 1000;
|
|
88
|
+
const workers = Array.from({ length: concurrency }, () => workerLoop(url, method, endpoint.body, endpoint.headers, endTs, results));
|
|
89
|
+
await Promise.allSettled(workers);
|
|
90
|
+
// Calculate metrics
|
|
91
|
+
const latencies = results.map(r => r.latencyMs).sort((a, b) => a - b);
|
|
92
|
+
const successCount = results.filter(r => r.success).length;
|
|
93
|
+
const failCount = results.length - successCount;
|
|
94
|
+
const totalBodySize = results.reduce((sum, r) => sum + r.bodySize, 0);
|
|
95
|
+
const statusCodes = {};
|
|
96
|
+
for (const r of results) {
|
|
97
|
+
statusCodes[r.statusCode] = (statusCodes[r.statusCode] ?? 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
endpoint: endpoint.path,
|
|
101
|
+
label,
|
|
102
|
+
method,
|
|
103
|
+
concurrency,
|
|
104
|
+
durationSec,
|
|
105
|
+
totalRequests: results.length,
|
|
106
|
+
successCount,
|
|
107
|
+
failCount,
|
|
108
|
+
successRate: results.length > 0 ? (successCount / results.length) * 100 : 0,
|
|
109
|
+
throughput: results.length / durationSec,
|
|
110
|
+
p50: percentile(latencies, 50),
|
|
111
|
+
p95: percentile(latencies, 95),
|
|
112
|
+
p99: percentile(latencies, 99),
|
|
113
|
+
min: latencies[0] ?? 0,
|
|
114
|
+
max: latencies.at(-1) ?? 0,
|
|
115
|
+
avg: latencies.length > 0
|
|
116
|
+
? latencies.reduce((a, b) => a + b, 0) / latencies.length
|
|
117
|
+
: 0,
|
|
118
|
+
statusCodes,
|
|
119
|
+
avgBodySize: results.length > 0 ? totalBodySize / results.length : 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/* ------------------------------------------------------------------
|
|
123
|
+
* Main load test runner
|
|
124
|
+
* ------------------------------------------------------------------ */
|
|
125
|
+
export async function runLoadTest(config) {
|
|
126
|
+
const startedAt = new Date().toISOString();
|
|
127
|
+
const scenarios = [];
|
|
128
|
+
const warmup = config.warmupRequests ?? 5;
|
|
129
|
+
for (const endpoint of config.endpoints) {
|
|
130
|
+
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);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
baseUrl: config.baseUrl,
|
|
138
|
+
startedAt,
|
|
139
|
+
completedAt: new Date().toISOString(),
|
|
140
|
+
scenarios
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/* ------------------------------------------------------------------
|
|
144
|
+
* Format results as a table
|
|
145
|
+
* ------------------------------------------------------------------ */
|
|
146
|
+
export function formatResults(results) {
|
|
147
|
+
const lines = [];
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('='.repeat(100));
|
|
150
|
+
lines.push(' APPLICATION-LEVEL LOAD TEST RESULTS');
|
|
151
|
+
lines.push(` Target: ${results.baseUrl}`);
|
|
152
|
+
lines.push(` Started: ${results.startedAt}`);
|
|
153
|
+
lines.push(` Completed: ${results.completedAt}`);
|
|
154
|
+
lines.push('='.repeat(100));
|
|
155
|
+
lines.push('');
|
|
156
|
+
// Summary table header
|
|
157
|
+
const header = [
|
|
158
|
+
'Endpoint'.padEnd(25),
|
|
159
|
+
'Conc'.padStart(5),
|
|
160
|
+
'Req/s'.padStart(8),
|
|
161
|
+
'Total'.padStart(7),
|
|
162
|
+
'P50ms'.padStart(8),
|
|
163
|
+
'P95ms'.padStart(8),
|
|
164
|
+
'P99ms'.padStart(8),
|
|
165
|
+
'Success'.padStart(8),
|
|
166
|
+
'AvgBody'.padStart(8)
|
|
167
|
+
].join(' | ');
|
|
168
|
+
lines.push(header);
|
|
169
|
+
lines.push('-'.repeat(header.length));
|
|
170
|
+
for (const s of results.scenarios) {
|
|
171
|
+
const row = [
|
|
172
|
+
s.label.padEnd(25).slice(0, 25),
|
|
173
|
+
String(s.concurrency).padStart(5),
|
|
174
|
+
s.throughput.toFixed(0).padStart(8),
|
|
175
|
+
String(s.totalRequests).padStart(7),
|
|
176
|
+
s.p50.toFixed(1).padStart(8),
|
|
177
|
+
s.p95.toFixed(1).padStart(8),
|
|
178
|
+
s.p99.toFixed(1).padStart(8),
|
|
179
|
+
`${s.successRate.toFixed(1)}%`.padStart(8),
|
|
180
|
+
formatBytes(s.avgBodySize).padStart(8)
|
|
181
|
+
].join(' | ');
|
|
182
|
+
lines.push(row);
|
|
183
|
+
}
|
|
184
|
+
lines.push('');
|
|
185
|
+
// Pool-level comparison note
|
|
186
|
+
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.');
|
|
190
|
+
lines.push('-'.repeat(100));
|
|
191
|
+
lines.push('');
|
|
192
|
+
// Status code breakdown
|
|
193
|
+
lines.push(' Status Code Breakdown:');
|
|
194
|
+
for (const s of results.scenarios) {
|
|
195
|
+
const codes = Object.entries(s.statusCodes)
|
|
196
|
+
.map(([code, count]) => `${code}:${count}`)
|
|
197
|
+
.join(', ');
|
|
198
|
+
lines.push(` ${s.label} @${s.concurrency}: ${codes}`);
|
|
199
|
+
}
|
|
200
|
+
lines.push('');
|
|
201
|
+
return lines.join('\n');
|
|
202
|
+
}
|
|
203
|
+
function formatBytes(bytes) {
|
|
204
|
+
if (bytes < 1024)
|
|
205
|
+
return `${Math.round(bytes)}B`;
|
|
206
|
+
if (bytes < 1024 * 1024)
|
|
207
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
208
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
209
|
+
}
|
|
210
|
+
/* ------------------------------------------------------------------
|
|
211
|
+
* CLI: run directly for a quick smoke test
|
|
212
|
+
* ------------------------------------------------------------------ */
|
|
213
|
+
async function main() {
|
|
214
|
+
const args = process.argv.slice(2);
|
|
215
|
+
const urlIdx = args.indexOf('--url');
|
|
216
|
+
const baseUrl = (urlIdx !== -1 ? args[urlIdx + 1] : undefined) ?? 'http://localhost:3001';
|
|
217
|
+
if (args.includes('--help')) {
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
// Quick smoke test against the provided URL
|
|
221
|
+
const _results = await runLoadTest({
|
|
222
|
+
baseUrl,
|
|
223
|
+
endpoints: [{ path: '/', label: 'Homepage' }],
|
|
224
|
+
concurrencyLevels: [1, 10],
|
|
225
|
+
durationSec: 5,
|
|
226
|
+
warmupRequests: 3
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Run if executed directly
|
|
230
|
+
if (import.meta.path === Bun.main) {
|
|
231
|
+
main().catch(err => {
|
|
232
|
+
console.error('Load test failed:', err);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
export type LockResult = {
|
|
3
|
+
acquired: boolean;
|
|
4
|
+
owner: string;
|
|
5
|
+
};
|
|
6
|
+
export declare class CacheLock {
|
|
7
|
+
private readonly db;
|
|
8
|
+
private readonly acquireStmt;
|
|
9
|
+
private readonly checkOwnerStmt;
|
|
10
|
+
private readonly releaseStmt;
|
|
11
|
+
private readonly forceReleaseStmt;
|
|
12
|
+
private readonly refreshStmt;
|
|
13
|
+
private readonly isLockedStmt;
|
|
14
|
+
private readonly pruneExpiredStmt;
|
|
15
|
+
private readonly deleteExpiredStmt;
|
|
16
|
+
constructor(db: Database);
|
|
17
|
+
acquire(key: string, ttlMs: number, owner?: string): LockResult;
|
|
18
|
+
release(key: string, owner: string): boolean;
|
|
19
|
+
forceRelease(key: string): boolean;
|
|
20
|
+
refresh(key: string, ttlMs: number, owner: string): boolean;
|
|
21
|
+
isLocked(key: string): boolean;
|
|
22
|
+
pruneExpired(): number;
|
|
23
|
+
withLock<T>(key: string, ttlMs: number, fn: () => T | Promise<T>): Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=cache-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache-lock.d.ts","sourceRoot":"","sources":["../../src/cache-lock.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAG3C,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAW;IAE9B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAC7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAC7B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC;IAClC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAC7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAC9B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC;IAClC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;gBAEvB,EAAE,EAAE,QAAQ;IAyCxB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,UAAU;IA+B/D,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAK5C,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAKlC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAU3D,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAK9B,YAAY,IAAI,MAAM;IAKhB,QAAQ,CAAC,CAAC,EACd,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACvB,OAAO,CAAC,CAAC,CAAC;CAYd"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// packages/sqlite/src/cache-lock.ts
|
|
2
|
+
// DB-backed distributed locks for mutual exclusion using bun:sqlite
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export class CacheLock {
|
|
5
|
+
db;
|
|
6
|
+
acquireStmt;
|
|
7
|
+
checkOwnerStmt;
|
|
8
|
+
releaseStmt;
|
|
9
|
+
forceReleaseStmt;
|
|
10
|
+
refreshStmt;
|
|
11
|
+
isLockedStmt;
|
|
12
|
+
pruneExpiredStmt;
|
|
13
|
+
deleteExpiredStmt;
|
|
14
|
+
constructor(db) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
db.run(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS cache_locks (
|
|
18
|
+
key TEXT PRIMARY KEY,
|
|
19
|
+
owner TEXT NOT NULL,
|
|
20
|
+
expiration INTEGER NOT NULL
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
db.run(`
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_cache_locks_expiration
|
|
25
|
+
ON cache_locks (expiration)
|
|
26
|
+
`);
|
|
27
|
+
this.deleteExpiredStmt = db.query('DELETE FROM cache_locks WHERE key = $key AND expiration <= $now');
|
|
28
|
+
this.acquireStmt = db.query('INSERT OR IGNORE INTO cache_locks (key, owner, expiration) VALUES ($key, $owner, $expiration)');
|
|
29
|
+
this.checkOwnerStmt = db.query('SELECT owner FROM cache_locks WHERE key = $key');
|
|
30
|
+
this.releaseStmt = db.query('DELETE FROM cache_locks WHERE key = $key AND owner = $owner');
|
|
31
|
+
this.forceReleaseStmt = db.query('DELETE FROM cache_locks WHERE key = $key');
|
|
32
|
+
this.refreshStmt = db.query('UPDATE cache_locks SET expiration = $expiration WHERE key = $key AND owner = $owner');
|
|
33
|
+
this.isLockedStmt = db.query('SELECT 1 FROM cache_locks WHERE key = $key AND expiration > $now LIMIT 1');
|
|
34
|
+
this.pruneExpiredStmt = db.query('DELETE FROM cache_locks WHERE expiration <= $now');
|
|
35
|
+
}
|
|
36
|
+
acquire(key, ttlMs, owner) {
|
|
37
|
+
const actualOwner = owner ?? randomUUID();
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const expiration = now + ttlMs;
|
|
40
|
+
const result = this.db.transaction(() => {
|
|
41
|
+
// Remove expired lock for this key first
|
|
42
|
+
this.deleteExpiredStmt.run({ $key: key, $now: now });
|
|
43
|
+
// Try to insert — OR IGNORE means it fails silently if key exists
|
|
44
|
+
this.acquireStmt.run({
|
|
45
|
+
$key: key,
|
|
46
|
+
$owner: actualOwner,
|
|
47
|
+
$expiration: expiration
|
|
48
|
+
});
|
|
49
|
+
// Check who owns the lock
|
|
50
|
+
const row = this.checkOwnerStmt.get({ $key: key });
|
|
51
|
+
if (row && row.owner === actualOwner) {
|
|
52
|
+
return { acquired: true, owner: actualOwner };
|
|
53
|
+
}
|
|
54
|
+
return { acquired: false, owner: row?.owner ?? '' };
|
|
55
|
+
});
|
|
56
|
+
return result.immediate();
|
|
57
|
+
}
|
|
58
|
+
release(key, owner) {
|
|
59
|
+
const result = this.releaseStmt.run({ $key: key, $owner: owner });
|
|
60
|
+
return result.changes > 0;
|
|
61
|
+
}
|
|
62
|
+
forceRelease(key) {
|
|
63
|
+
const result = this.forceReleaseStmt.run({ $key: key });
|
|
64
|
+
return result.changes > 0;
|
|
65
|
+
}
|
|
66
|
+
refresh(key, ttlMs, owner) {
|
|
67
|
+
const expiration = Date.now() + ttlMs;
|
|
68
|
+
const result = this.refreshStmt.run({
|
|
69
|
+
$key: key,
|
|
70
|
+
$owner: owner,
|
|
71
|
+
$expiration: expiration
|
|
72
|
+
});
|
|
73
|
+
return result.changes > 0;
|
|
74
|
+
}
|
|
75
|
+
isLocked(key) {
|
|
76
|
+
const row = this.isLockedStmt.get({ $key: key, $now: Date.now() });
|
|
77
|
+
return row != null;
|
|
78
|
+
}
|
|
79
|
+
pruneExpired() {
|
|
80
|
+
const result = this.pruneExpiredStmt.run({ $now: Date.now() });
|
|
81
|
+
return result.changes;
|
|
82
|
+
}
|
|
83
|
+
async withLock(key, ttlMs, fn) {
|
|
84
|
+
const result = this.acquire(key, ttlMs);
|
|
85
|
+
if (!result.acquired) {
|
|
86
|
+
throw new Error(`Failed to acquire lock: ${key}`);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return await fn();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
this.release(key, result.owner);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
export type ConnectionConfig = {
|
|
3
|
+
path: string;
|
|
4
|
+
readonly?: boolean;
|
|
5
|
+
verbose?: boolean;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Initialize a SQLite database with optimal settings
|
|
9
|
+
*/
|
|
10
|
+
export declare function initializeConnection(config: ConnectionConfig): Database;
|
|
11
|
+
/**
|
|
12
|
+
* Close database connection gracefully
|
|
13
|
+
*/
|
|
14
|
+
export declare function closeConnection(db: Database): void;
|
|
15
|
+
/**
|
|
16
|
+
* Check database health
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkHealth(db: Database): {
|
|
19
|
+
healthy: boolean;
|
|
20
|
+
details: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Get database statistics
|
|
24
|
+
*/
|
|
25
|
+
export declare function getStats(db: Database): Record<string, unknown>;
|
|
26
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/connection.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,gBAAgB,GAAG,QAAQ,CA+BvE;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,QAAQ,GAAG,IAAI,CAalD;AAkCD;;GAEG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,QAAQ,GAAG;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,CA6BA;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAiB9D"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// packages/sqlite/src/connection.ts
|
|
2
|
+
// Database connection initialization and configuration
|
|
3
|
+
// Uses bun:sqlite for 3-6x faster reads compared to better-sqlite3
|
|
4
|
+
import { Database } from 'bun:sqlite';
|
|
5
|
+
/**
|
|
6
|
+
* Initialize a SQLite database with optimal settings
|
|
7
|
+
*/
|
|
8
|
+
export function initializeConnection(config) {
|
|
9
|
+
const db = new Database(config.path, {
|
|
10
|
+
readonly: config.readonly ?? false,
|
|
11
|
+
create: true
|
|
12
|
+
});
|
|
13
|
+
// Apply optimal PRAGMA settings
|
|
14
|
+
if (config.path !== ':memory:') {
|
|
15
|
+
// WAL mode for better concurrency
|
|
16
|
+
db.run('PRAGMA journal_mode = WAL');
|
|
17
|
+
// Reduced busy timeout for worker coordination
|
|
18
|
+
db.run('PRAGMA busy_timeout = 5000');
|
|
19
|
+
// More frequent checkpoints
|
|
20
|
+
db.run('PRAGMA wal_autocheckpoint = 100');
|
|
21
|
+
// Note: cache_shared is not available in bun:sqlite
|
|
22
|
+
}
|
|
23
|
+
// Performance optimizations
|
|
24
|
+
db.run('PRAGMA foreign_keys = ON');
|
|
25
|
+
db.run('PRAGMA synchronous = NORMAL'); // Safe with WAL
|
|
26
|
+
db.run('PRAGMA cache_size = -4000'); // 4MB cache
|
|
27
|
+
db.run('PRAGMA temp_store = MEMORY');
|
|
28
|
+
db.run('PRAGMA mmap_size = 67108864'); // 64MB mmap
|
|
29
|
+
// Query optimizer
|
|
30
|
+
db.run('PRAGMA optimize');
|
|
31
|
+
return db;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Close database connection gracefully
|
|
35
|
+
*/
|
|
36
|
+
export function closeConnection(db) {
|
|
37
|
+
try {
|
|
38
|
+
// Checkpoint WAL before closing
|
|
39
|
+
try {
|
|
40
|
+
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.warn('[db] WAL checkpoint failed:', error);
|
|
44
|
+
}
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error('[db] Error closing connection:', error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whitelist of allowed PRAGMA names to prevent SQL injection
|
|
53
|
+
* via string interpolation in getPragmaValue.
|
|
54
|
+
*/
|
|
55
|
+
const ALLOWED_PRAGMAS = new Set([
|
|
56
|
+
'journal_mode',
|
|
57
|
+
'wal_autocheckpoint',
|
|
58
|
+
'busy_timeout',
|
|
59
|
+
'cache_size',
|
|
60
|
+
'foreign_keys',
|
|
61
|
+
'synchronous',
|
|
62
|
+
'temp_store',
|
|
63
|
+
'mmap_size',
|
|
64
|
+
'page_size',
|
|
65
|
+
'locking_mode',
|
|
66
|
+
'page_count',
|
|
67
|
+
'wal_checkpoint'
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* Helper to get a PRAGMA value
|
|
71
|
+
*/
|
|
72
|
+
function getPragmaValue(db, pragma) {
|
|
73
|
+
if (!ALLOWED_PRAGMAS.has(pragma)) {
|
|
74
|
+
throw new Error(`PRAGMA "${pragma}" is not allowed. Allowed PRAGMAs: ${[...ALLOWED_PRAGMAS].join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
const result = db.query(`PRAGMA ${pragma}`).get();
|
|
77
|
+
return result ? Object.values(result)[0] : null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Check database health
|
|
81
|
+
*/
|
|
82
|
+
export function checkHealth(db) {
|
|
83
|
+
try {
|
|
84
|
+
// Simple query to verify database is accessible
|
|
85
|
+
const result = db.query('SELECT 1 as health').get();
|
|
86
|
+
const walInfo = db.query('PRAGMA wal_checkpoint').all();
|
|
87
|
+
const pageCount = getPragmaValue(db, 'page_count');
|
|
88
|
+
const pageSize = getPragmaValue(db, 'page_size');
|
|
89
|
+
return {
|
|
90
|
+
healthy: result.health === 1,
|
|
91
|
+
details: {
|
|
92
|
+
accessible: true,
|
|
93
|
+
journalMode: getPragmaValue(db, 'journal_mode'),
|
|
94
|
+
pageCount,
|
|
95
|
+
pageSize,
|
|
96
|
+
databaseSize: pageCount * pageSize,
|
|
97
|
+
walInfo
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
healthy: false,
|
|
104
|
+
details: {
|
|
105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
106
|
+
accessible: false
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Get database statistics
|
|
113
|
+
*/
|
|
114
|
+
export function getStats(db) {
|
|
115
|
+
try {
|
|
116
|
+
return {
|
|
117
|
+
journalMode: getPragmaValue(db, 'journal_mode'),
|
|
118
|
+
pageCount: getPragmaValue(db, 'page_count'),
|
|
119
|
+
pageSize: getPragmaValue(db, 'page_size'),
|
|
120
|
+
cacheSize: getPragmaValue(db, 'cache_size'),
|
|
121
|
+
mmapSize: getPragmaValue(db, 'mmap_size'),
|
|
122
|
+
walAutocheckpoint: getPragmaValue(db, 'wal_autocheckpoint'),
|
|
123
|
+
synchronous: getPragmaValue(db, 'synchronous'),
|
|
124
|
+
foreignKeys: getPragmaValue(db, 'foreign_keys')
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return {
|
|
129
|
+
error: error instanceof Error ? error.message : String(error)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type CacheEntry<T> = {
|
|
2
|
+
value: T;
|
|
3
|
+
metadata: {
|
|
4
|
+
createdTime: number;
|
|
5
|
+
ttl: number | null;
|
|
6
|
+
swr?: number | null;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export type CacheOptions = {
|
|
10
|
+
ttl?: number;
|
|
11
|
+
swr?: number;
|
|
12
|
+
};
|
|
13
|
+
export type JsonCacheConfig = {
|
|
14
|
+
cacheDir: string;
|
|
15
|
+
defaultTtl?: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Simple, fast JSON file cache inspired by levelsio's approach
|
|
19
|
+
* - Each cache key is a separate JSON file
|
|
20
|
+
* - Atomic writes using temp files
|
|
21
|
+
* - TTL-based expiration
|
|
22
|
+
* - Stale-while-revalidate support
|
|
23
|
+
* - No external dependencies
|
|
24
|
+
*/
|
|
25
|
+
export declare class JsonCache {
|
|
26
|
+
private cacheDir;
|
|
27
|
+
private defaultTtl;
|
|
28
|
+
private pendingWrites;
|
|
29
|
+
constructor(config: JsonCacheConfig);
|
|
30
|
+
/**
|
|
31
|
+
* Initialize cache directory
|
|
32
|
+
*/
|
|
33
|
+
init(): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Get cache file path for a key
|
|
36
|
+
*/
|
|
37
|
+
private getFilePath;
|
|
38
|
+
/**
|
|
39
|
+
* Get value from cache
|
|
40
|
+
*/
|
|
41
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Set value in cache with atomic write
|
|
44
|
+
*/
|
|
45
|
+
set<T = unknown>(key: string, value: T, options?: CacheOptions): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Atomic write using temp file
|
|
48
|
+
*/
|
|
49
|
+
private _atomicWrite;
|
|
50
|
+
/**
|
|
51
|
+
* Delete cache entry
|
|
52
|
+
*/
|
|
53
|
+
delete(key: string): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Check if cache entry exists
|
|
56
|
+
*/
|
|
57
|
+
has(key: string): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* Clear all cache entries
|
|
60
|
+
*/
|
|
61
|
+
clear(): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Get cache statistics
|
|
64
|
+
*/
|
|
65
|
+
stats(): Promise<{
|
|
66
|
+
size: number;
|
|
67
|
+
entries: string[];
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Clean up expired entries
|
|
71
|
+
*/
|
|
72
|
+
cleanup(): Promise<number>;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Factory function to create and initialize a JSON cache instance.
|
|
76
|
+
* Awaits directory creation so the cache is ready to use immediately.
|
|
77
|
+
*/
|
|
78
|
+
export declare const createJsonCache: (config: JsonCacheConfig) => Promise<JsonCache>;
|
|
79
|
+
/**
|
|
80
|
+
* Helper function for cachified pattern
|
|
81
|
+
*/
|
|
82
|
+
export declare const cachified: <T>(options: {
|
|
83
|
+
cache: JsonCache;
|
|
84
|
+
key: string;
|
|
85
|
+
getFreshValue: () => Promise<T>;
|
|
86
|
+
ttl?: number;
|
|
87
|
+
swr?: number;
|
|
88
|
+
}) => Promise<T>;
|
|
89
|
+
//# sourceMappingURL=json-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-cache.d.ts","sourceRoot":"","sources":["../../src/json-cache.ts"],"names":[],"mappings":"AAmBA,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI;IAC1B,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE;QACR,WAAW,EAAE,MAAM,CAAC;QACpB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACrB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;;;;;;GAOG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAoC;gBAE7C,MAAM,EAAE,eAAe;IAKnC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAW3B;;OAEG;IACH,OAAO,CAAC,WAAW;IAMnB;;OAEG;IACG,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IA+CtD;;OAEG;IACG,GAAG,CAAC,CAAC,GAAG,OAAO,EACnB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,YAAY,GACrB,OAAO,CAAC,IAAI,CAAC;IAoBhB;;OAEG;YACW,YAAY;IA4C1B;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBxC;;OAEG;IACG,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAWxC;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAiB5B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAc3D;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;CA+CjC;AAED;;;GAGG;AACH,eAAO,MAAM,eAAe,GAC1B,QAAQ,eAAe,KACtB,OAAO,CAAC,SAAS,CAenB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS,GAAU,CAAC,EAAE,SAAS;IAC1C,KAAK,EAAE,SAAS,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,KAAG,OAAO,CAAC,CAAC,CAkBZ,CAAC"}
|