@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
package/dist/src/pool.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// packages/sqlite/src/pool.ts
|
|
2
|
+
// SQLite Connection Pool with Worker Threads
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { Worker } from 'node:worker_threads';
|
|
6
|
+
// Use require.resolve to find worker.js from the package exports
|
|
7
|
+
// This works correctly in both development and when bundled
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
class SQLiteConnectionPool extends EventEmitter {
|
|
10
|
+
workers = [];
|
|
11
|
+
maxWorkers;
|
|
12
|
+
workerTimeout;
|
|
13
|
+
databasePath;
|
|
14
|
+
pendingOperations = new Map();
|
|
15
|
+
nextWorkerIndex = 0;
|
|
16
|
+
isShuttingDown = false;
|
|
17
|
+
metrics = {
|
|
18
|
+
totalQueries: 0,
|
|
19
|
+
activeQueries: 0,
|
|
20
|
+
totalDuration: 0,
|
|
21
|
+
errors: 0,
|
|
22
|
+
workerStats: {}
|
|
23
|
+
};
|
|
24
|
+
constructor(config) {
|
|
25
|
+
super();
|
|
26
|
+
// Configuration from environment or config
|
|
27
|
+
this.maxWorkers =
|
|
28
|
+
config?.poolSize ??
|
|
29
|
+
Number.parseInt(process.env.SQLITE_POOL_SIZE ?? '4', 10);
|
|
30
|
+
this.workerTimeout =
|
|
31
|
+
config?.timeout ??
|
|
32
|
+
Number.parseInt(process.env.SQLITE_WORKER_TIMEOUT ?? '30000', 10);
|
|
33
|
+
this.databasePath = config?.databasePath;
|
|
34
|
+
this.initializePool();
|
|
35
|
+
}
|
|
36
|
+
initializePool() {
|
|
37
|
+
// Resolve worker path from package exports - works in both dev and bundled production
|
|
38
|
+
const workerPath = require.resolve('@bitclaw/sqlite/worker');
|
|
39
|
+
for (let i = 0; i < this.maxWorkers; i += 1) {
|
|
40
|
+
try {
|
|
41
|
+
const worker = new Worker(workerPath, {
|
|
42
|
+
workerData: { databasePath: this.databasePath }
|
|
43
|
+
});
|
|
44
|
+
worker.on('message', this.handleWorkerMessage.bind(this));
|
|
45
|
+
worker.on('error', this.handleWorkerError.bind(this));
|
|
46
|
+
worker.on('exit', this.handleWorkerExit.bind(this, i));
|
|
47
|
+
this.workers.push(worker);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error(`[pool] Failed to create worker ${i}:`, error);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
handleWorkerMessage(response) {
|
|
56
|
+
// Ignore shutdown responses
|
|
57
|
+
if (response.id.startsWith('shutdown-')) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const operation = this.pendingOperations.get(response.id);
|
|
61
|
+
if (!operation) {
|
|
62
|
+
console.warn(`[pool] Received response for unknown operation: ${response.id}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Clear timeout
|
|
66
|
+
clearTimeout(operation.timeout);
|
|
67
|
+
this.pendingOperations.delete(response.id);
|
|
68
|
+
// Update metrics
|
|
69
|
+
this.metrics.activeQueries -= 1;
|
|
70
|
+
const duration = Number(process.hrtime.bigint() - operation.startTime) / 1_000_000;
|
|
71
|
+
this.metrics.totalDuration += duration;
|
|
72
|
+
if (response.workerId) {
|
|
73
|
+
if (!this.metrics.workerStats[response.workerId]) {
|
|
74
|
+
this.metrics.workerStats[response.workerId] = {
|
|
75
|
+
queries: 0,
|
|
76
|
+
errors: 0,
|
|
77
|
+
totalDuration: 0
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const workerStat = this.metrics.workerStats[response.workerId];
|
|
81
|
+
workerStat.queries += 1;
|
|
82
|
+
workerStat.totalDuration += duration;
|
|
83
|
+
}
|
|
84
|
+
// Handle response
|
|
85
|
+
if (response.success && !response.error) {
|
|
86
|
+
operation.resolve(response.result);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.metrics.errors += 1;
|
|
90
|
+
if (response.workerId) {
|
|
91
|
+
this.metrics.workerStats[response.workerId].errors += 1;
|
|
92
|
+
}
|
|
93
|
+
const error = Object.assign(new Error(response.error?.message || 'Unknown SQLite error'), { code: response.error?.code, errno: response.error?.errno });
|
|
94
|
+
operation.reject(error);
|
|
95
|
+
}
|
|
96
|
+
// Emit metrics update
|
|
97
|
+
this.emit('metrics', this.getMetrics());
|
|
98
|
+
}
|
|
99
|
+
handleWorkerError(error) {
|
|
100
|
+
console.error('[pool] Worker error:', error);
|
|
101
|
+
this.metrics.errors += 1;
|
|
102
|
+
this.emit('error', error);
|
|
103
|
+
}
|
|
104
|
+
handleWorkerExit(workerIndex, code) {
|
|
105
|
+
if (this.isShuttingDown)
|
|
106
|
+
return;
|
|
107
|
+
console.warn(`[pool] Worker ${workerIndex} exited with code ${code}`);
|
|
108
|
+
this.emit('workerExit', { workerIndex, code });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Execute SQL query with parameters
|
|
112
|
+
*/
|
|
113
|
+
async exec(sql, params = []) {
|
|
114
|
+
if (this.isShuttingDown) {
|
|
115
|
+
throw new Error('Connection pool is shutting down');
|
|
116
|
+
}
|
|
117
|
+
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
118
|
+
const startTime = process.hrtime.bigint();
|
|
119
|
+
// Update metrics
|
|
120
|
+
this.metrics.totalQueries += 1;
|
|
121
|
+
this.metrics.activeQueries += 1;
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
// Set up timeout
|
|
124
|
+
const timeout = setTimeout(() => {
|
|
125
|
+
this.pendingOperations.delete(id);
|
|
126
|
+
this.metrics.activeQueries -= 1;
|
|
127
|
+
this.metrics.errors += 1;
|
|
128
|
+
reject(new Error(`SQLite operation timed out after ${this.workerTimeout}ms`));
|
|
129
|
+
}, this.workerTimeout);
|
|
130
|
+
// Store operation (cast resolve since the map is non-generic)
|
|
131
|
+
this.pendingOperations.set(id, {
|
|
132
|
+
resolve: resolve,
|
|
133
|
+
reject,
|
|
134
|
+
timeout,
|
|
135
|
+
sql,
|
|
136
|
+
startTime
|
|
137
|
+
});
|
|
138
|
+
// Send to worker using round-robin
|
|
139
|
+
const worker = this.workers[this.nextWorkerIndex];
|
|
140
|
+
this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length;
|
|
141
|
+
const message = { id, sql, params };
|
|
142
|
+
worker.postMessage(message);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Execute operations on a specific worker (for transactions)
|
|
147
|
+
*/
|
|
148
|
+
async execOnWorker(workerIndex, sql, params = []) {
|
|
149
|
+
if (this.isShuttingDown) {
|
|
150
|
+
throw new Error('Connection pool is shutting down');
|
|
151
|
+
}
|
|
152
|
+
if (workerIndex >= this.workers.length) {
|
|
153
|
+
throw new Error(`Invalid worker index: ${workerIndex}`);
|
|
154
|
+
}
|
|
155
|
+
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
156
|
+
const startTime = process.hrtime.bigint();
|
|
157
|
+
// Update metrics
|
|
158
|
+
this.metrics.totalQueries += 1;
|
|
159
|
+
this.metrics.activeQueries += 1;
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const timeout = setTimeout(() => {
|
|
162
|
+
this.pendingOperations.delete(id);
|
|
163
|
+
this.metrics.activeQueries -= 1;
|
|
164
|
+
this.metrics.errors += 1;
|
|
165
|
+
reject(new Error(`SQLite operation timed out after ${this.workerTimeout}ms`));
|
|
166
|
+
}, this.workerTimeout);
|
|
167
|
+
this.pendingOperations.set(id, {
|
|
168
|
+
resolve: resolve,
|
|
169
|
+
reject,
|
|
170
|
+
timeout,
|
|
171
|
+
sql,
|
|
172
|
+
startTime
|
|
173
|
+
});
|
|
174
|
+
// Send to specific worker
|
|
175
|
+
const worker = this.workers[workerIndex];
|
|
176
|
+
const message = { id, sql, params };
|
|
177
|
+
worker.postMessage(message);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get current pool metrics
|
|
182
|
+
*/
|
|
183
|
+
getMetrics() {
|
|
184
|
+
return {
|
|
185
|
+
...this.metrics,
|
|
186
|
+
workerStats: { ...this.metrics.workerStats }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get pool status for health checks
|
|
191
|
+
*/
|
|
192
|
+
getStatus() {
|
|
193
|
+
return {
|
|
194
|
+
workers: this.workers.length,
|
|
195
|
+
activeQueries: this.metrics.activeQueries,
|
|
196
|
+
isShuttingDown: this.isShuttingDown,
|
|
197
|
+
totalQueries: this.metrics.totalQueries,
|
|
198
|
+
errorRate: this.metrics.totalQueries > 0
|
|
199
|
+
? (this.metrics.errors / this.metrics.totalQueries) * 100
|
|
200
|
+
: 0,
|
|
201
|
+
avgDuration: this.metrics.totalQueries > 0
|
|
202
|
+
? this.metrics.totalDuration / this.metrics.totalQueries
|
|
203
|
+
: 0
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Graceful shutdown of the connection pool
|
|
208
|
+
*/
|
|
209
|
+
async shutdown(timeoutMs = 5000) {
|
|
210
|
+
if (this.isShuttingDown)
|
|
211
|
+
return;
|
|
212
|
+
this.isShuttingDown = true;
|
|
213
|
+
// Wait for pending operations
|
|
214
|
+
const startTime = Date.now();
|
|
215
|
+
const checkPending = () => this.pendingOperations.size === 0;
|
|
216
|
+
while (!checkPending() && Date.now() - startTime < timeoutMs) {
|
|
217
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
218
|
+
}
|
|
219
|
+
// Send shutdown messages to workers
|
|
220
|
+
for (let i = 0; i < this.workers.length; i++) {
|
|
221
|
+
const worker = this.workers[i];
|
|
222
|
+
if (worker) {
|
|
223
|
+
const shutdownMessage = {
|
|
224
|
+
id: `shutdown-${Date.now()}-${i}`,
|
|
225
|
+
sql: '__SHUTDOWN__',
|
|
226
|
+
params: []
|
|
227
|
+
};
|
|
228
|
+
try {
|
|
229
|
+
worker.postMessage(shutdownMessage);
|
|
230
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
231
|
+
await worker.terminate();
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
console.warn(`[pool] Error shutting down worker ${i}:`, error);
|
|
235
|
+
await worker.terminate();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
this.workers = [];
|
|
240
|
+
this.nextWorkerIndex = 0;
|
|
241
|
+
this.pendingOperations.clear();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Singleton instance
|
|
245
|
+
let pool = null;
|
|
246
|
+
/**
|
|
247
|
+
* Create a new connection pool instance
|
|
248
|
+
*/
|
|
249
|
+
export function createPool(config) {
|
|
250
|
+
if (pool) {
|
|
251
|
+
throw new Error('Pool already exists. Call getPool() to retrieve it or shutdown() first.');
|
|
252
|
+
}
|
|
253
|
+
pool = new SQLiteConnectionPool(config);
|
|
254
|
+
return pool;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Get the singleton connection pool instance
|
|
258
|
+
*/
|
|
259
|
+
export function getPool() {
|
|
260
|
+
if (!pool) {
|
|
261
|
+
pool = new SQLiteConnectionPool();
|
|
262
|
+
}
|
|
263
|
+
return pool;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Execute SQL query using the connection pool
|
|
267
|
+
*/
|
|
268
|
+
export async function exec(sql, params = []) {
|
|
269
|
+
return getPool().exec(sql, params);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Get pool metrics for monitoring
|
|
273
|
+
*/
|
|
274
|
+
export function getPoolMetrics() {
|
|
275
|
+
return getPool().getMetrics();
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Get pool status for health checks
|
|
279
|
+
*/
|
|
280
|
+
export function getPoolStatus() {
|
|
281
|
+
return getPool().getStatus();
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Shutdown the connection pool
|
|
285
|
+
*/
|
|
286
|
+
export async function shutdownPool(timeoutMs) {
|
|
287
|
+
if (pool) {
|
|
288
|
+
if (!pool.getStatus().isShuttingDown) {
|
|
289
|
+
try {
|
|
290
|
+
await exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
294
|
+
console.warn('[pool] WAL checkpoint skipped:', errorMessage);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
await pool.shutdown(timeoutMs);
|
|
298
|
+
pool = null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Execute multiple operations atomically on a single worker
|
|
303
|
+
*/
|
|
304
|
+
export async function withTransaction(operation) {
|
|
305
|
+
// Use first worker for all transaction operations
|
|
306
|
+
const workerIndex = 0;
|
|
307
|
+
const poolInstance = getPool();
|
|
308
|
+
// Create a transaction-scoped execute function
|
|
309
|
+
const executeInTransaction = async (sql, params = []) => {
|
|
310
|
+
return poolInstance.execOnWorker(workerIndex, sql, params);
|
|
311
|
+
};
|
|
312
|
+
let success = false;
|
|
313
|
+
try {
|
|
314
|
+
await executeInTransaction('BEGIN IMMEDIATE');
|
|
315
|
+
const result = await operation(executeInTransaction);
|
|
316
|
+
await executeInTransaction('COMMIT');
|
|
317
|
+
success = true;
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (!success) {
|
|
322
|
+
try {
|
|
323
|
+
await executeInTransaction('ROLLBACK');
|
|
324
|
+
}
|
|
325
|
+
catch (rollbackError) {
|
|
326
|
+
console.error('[tx] Rollback failed:', rollbackError);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type PrismaClientLike = {
|
|
2
|
+
$executeRawUnsafe: (sql: string) => Promise<number>;
|
|
3
|
+
$queryRawUnsafe: (sql: string) => Promise<unknown>;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Execute a Prisma callback within a BEGIN IMMEDIATE transaction.
|
|
7
|
+
*
|
|
8
|
+
* The libsql adapter uses a single connection per PrismaClient, so raw SQL
|
|
9
|
+
* (BEGIN/COMMIT/ROLLBACK) participates in the same connection state as
|
|
10
|
+
* Prisma's query builder. This means we can safely mix raw transaction
|
|
11
|
+
* control with Prisma model operations.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const [response] = await immediateTransaction(prisma, async () => [
|
|
16
|
+
* await prisma.gptResponse.create({ data: { userId, content } }),
|
|
17
|
+
* await prisma.user.update({ where: { id: userId }, data: { credits: { decrement: 1 } } }),
|
|
18
|
+
* ]);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function immediateTransaction<T>(client: PrismaClientLike, fn: () => Promise<T>): Promise<T>;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=prisma-immediate-tx.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prisma-immediate-tx.d.ts","sourceRoot":"","sources":["../../src/prisma-immediate-tx.ts"],"names":[],"mappings":"AAUA,KAAK,gBAAgB,GAAG;IACtB,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,eAAe,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAeZ"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// packages/sqlite/src/prisma-immediate-tx.ts
|
|
2
|
+
// BEGIN IMMEDIATE transaction wrapper for Prisma with libsql adapter
|
|
3
|
+
//
|
|
4
|
+
// Prisma's $transaction() uses BEGIN DEFERRED by default. When a deferred
|
|
5
|
+
// transaction tries to upgrade from read to write lock, SQLite returns
|
|
6
|
+
// SQLITE_BUSY *immediately* — bypassing busy_timeout entirely.
|
|
7
|
+
//
|
|
8
|
+
// This helper wraps writes in BEGIN IMMEDIATE via $executeRawUnsafe,
|
|
9
|
+
// which acquires the write lock upfront and respects busy_timeout.
|
|
10
|
+
/**
|
|
11
|
+
* Execute a Prisma callback within a BEGIN IMMEDIATE transaction.
|
|
12
|
+
*
|
|
13
|
+
* The libsql adapter uses a single connection per PrismaClient, so raw SQL
|
|
14
|
+
* (BEGIN/COMMIT/ROLLBACK) participates in the same connection state as
|
|
15
|
+
* Prisma's query builder. This means we can safely mix raw transaction
|
|
16
|
+
* control with Prisma model operations.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const [response] = await immediateTransaction(prisma, async () => [
|
|
21
|
+
* await prisma.gptResponse.create({ data: { userId, content } }),
|
|
22
|
+
* await prisma.user.update({ where: { id: userId }, data: { credits: { decrement: 1 } } }),
|
|
23
|
+
* ]);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export async function immediateTransaction(client, fn) {
|
|
27
|
+
await client.$executeRawUnsafe('BEGIN IMMEDIATE');
|
|
28
|
+
try {
|
|
29
|
+
const result = await fn();
|
|
30
|
+
await client.$executeRawUnsafe('COMMIT');
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
try {
|
|
35
|
+
await client.$executeRawUnsafe('ROLLBACK');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Rollback may fail if the connection is already aborted
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
export type QueryLoggerOptions = {
|
|
3
|
+
/** Label shown in log output, e.g. workspace ID or database name. */
|
|
4
|
+
label?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Wrap a bun:sqlite Database with query logging.
|
|
8
|
+
*
|
|
9
|
+
* In development, every `query()`, `prepare()`, `run()`, and `exec()` call
|
|
10
|
+
* logs the SQL to stdout in a format consistent with Prisma's `prisma:query`:
|
|
11
|
+
*
|
|
12
|
+
* sqlite:query [label] SELECT * FROM servers WHERE id = ?
|
|
13
|
+
*
|
|
14
|
+
* In production, returns the database unchanged (zero overhead).
|
|
15
|
+
*
|
|
16
|
+
* Usage — always wrap, logging auto-enables in dev:
|
|
17
|
+
*
|
|
18
|
+
* const db = wrapWithQueryLogging(new Database(path), { label: 'ws:abc123' });
|
|
19
|
+
*/
|
|
20
|
+
export declare function wrapWithQueryLogging<T extends Database>(db: T, options?: QueryLoggerOptions): T;
|
|
21
|
+
//# sourceMappingURL=query-logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-logger.d.ts","sourceRoot":"","sources":["../../src/query-logger.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,MAAM,MAAM,kBAAkB,GAAG;IAC/B,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAaF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,QAAQ,EACrD,EAAE,EAAE,CAAC,EACL,OAAO,GAAE,kBAAuB,GAC/B,CAAC,CAsCH"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// packages/sqlite/src/query-logger.ts
|
|
2
|
+
// Dev-mode query logging for bun:sqlite — mirrors Prisma's `prisma:query` output.
|
|
3
|
+
// Wraps a Database with a transparent Proxy that logs SQL on query/run/exec/prepare.
|
|
4
|
+
// Zero overhead in production: returns the database as-is when NODE_ENV !== 'development'.
|
|
5
|
+
// biome-ignore lint/suspicious/noConsole: intentional dev-mode query logging (mirrors Prisma's log: ['query'])
|
|
6
|
+
const log = console.log;
|
|
7
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
8
|
+
// ANSI green for the prefix — contrasts with Prisma's blue `prisma:query`
|
|
9
|
+
const GREEN = '\x1b[32m';
|
|
10
|
+
const RESET = '\x1b[0m';
|
|
11
|
+
const formatSql = (sql) => sql.replace(/\s+/g, ' ').trim();
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a bun:sqlite Database with query logging.
|
|
14
|
+
*
|
|
15
|
+
* In development, every `query()`, `prepare()`, `run()`, and `exec()` call
|
|
16
|
+
* logs the SQL to stdout in a format consistent with Prisma's `prisma:query`:
|
|
17
|
+
*
|
|
18
|
+
* sqlite:query [label] SELECT * FROM servers WHERE id = ?
|
|
19
|
+
*
|
|
20
|
+
* In production, returns the database unchanged (zero overhead).
|
|
21
|
+
*
|
|
22
|
+
* Usage — always wrap, logging auto-enables in dev:
|
|
23
|
+
*
|
|
24
|
+
* const db = wrapWithQueryLogging(new Database(path), { label: 'ws:abc123' });
|
|
25
|
+
*/
|
|
26
|
+
export function wrapWithQueryLogging(db, options = {}) {
|
|
27
|
+
if (!isDev)
|
|
28
|
+
return db;
|
|
29
|
+
const { label } = options;
|
|
30
|
+
const prefix = label
|
|
31
|
+
? `${GREEN}sqlite:query${RESET} [${label}]`
|
|
32
|
+
: `${GREEN}sqlite:query${RESET}`;
|
|
33
|
+
return new Proxy(db, {
|
|
34
|
+
get(target, prop, receiver) {
|
|
35
|
+
const value = Reflect.get(target, prop, receiver);
|
|
36
|
+
if (typeof value !== 'function')
|
|
37
|
+
return value;
|
|
38
|
+
if (prop === 'query' || prop === 'prepare') {
|
|
39
|
+
return (sql) => {
|
|
40
|
+
log(`${prefix} ${formatSql(sql)}`);
|
|
41
|
+
return value.call(target, sql);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (prop === 'run') {
|
|
45
|
+
return (sql, ...params) => {
|
|
46
|
+
log(`${prefix} ${formatSql(sql)}`);
|
|
47
|
+
return value.call(target, sql, ...params);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (prop === 'exec') {
|
|
51
|
+
return (sql) => {
|
|
52
|
+
log(`${prefix} ${formatSql(sql)}`);
|
|
53
|
+
return value.call(target, sql);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Bind all other methods to the real target
|
|
57
|
+
return value.bind(target);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type RetryOptions = {
|
|
2
|
+
maxAttempts?: number;
|
|
3
|
+
baseDelayMs?: number;
|
|
4
|
+
maxDelayMs?: number;
|
|
5
|
+
};
|
|
6
|
+
declare const isBusyError: (error: unknown) => boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Execute a function with retry logic for SQLITE_BUSY errors.
|
|
9
|
+
* Uses exponential backoff with jitter to avoid thundering herd.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
12
|
+
export type { RetryOptions };
|
|
13
|
+
export { isBusyError };
|
|
14
|
+
//# sourceMappingURL=retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/retry.ts"],"names":[],"mappings":"AAGA,KAAK,YAAY,GAAG;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAQF,QAAA,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,OAQrC,CAAC;AAeF;;;GAGG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,OAAO,CAAC,EAAE,YAAY,GACrB,OAAO,CAAC,CAAC,CAAC,CA4BZ;AAED,YAAY,EAAE,YAAY,EAAE,CAAC;AAC7B,OAAO,EAAE,WAAW,EAAE,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// packages/sqlite/src/retry.ts
|
|
2
|
+
// Retry with exponential backoff for SQLITE_BUSY errors
|
|
3
|
+
const DEFAULT_OPTIONS = {
|
|
4
|
+
maxAttempts: 3,
|
|
5
|
+
baseDelayMs: 100,
|
|
6
|
+
maxDelayMs: 2000
|
|
7
|
+
};
|
|
8
|
+
const isBusyError = (error) => {
|
|
9
|
+
if (!(error instanceof Error))
|
|
10
|
+
return false;
|
|
11
|
+
const message = error.message.toLowerCase();
|
|
12
|
+
return (message.includes('database is locked') ||
|
|
13
|
+
message.includes('sqlite_busy') ||
|
|
14
|
+
('code' in error && error.code === 'SQLITE_BUSY'));
|
|
15
|
+
};
|
|
16
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
17
|
+
const calculateDelay = (attempt, baseDelayMs, maxDelayMs) => {
|
|
18
|
+
const exponentialDelay = baseDelayMs * 2 ** attempt;
|
|
19
|
+
const jitter = Math.random() * baseDelayMs;
|
|
20
|
+
return Math.min(exponentialDelay + jitter, maxDelayMs);
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Execute a function with retry logic for SQLITE_BUSY errors.
|
|
24
|
+
* Uses exponential backoff with jitter to avoid thundering herd.
|
|
25
|
+
*/
|
|
26
|
+
export async function withRetry(fn, options) {
|
|
27
|
+
const { maxAttempts, baseDelayMs, maxDelayMs } = {
|
|
28
|
+
...DEFAULT_OPTIONS,
|
|
29
|
+
...options
|
|
30
|
+
};
|
|
31
|
+
let lastError;
|
|
32
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
lastError = error;
|
|
38
|
+
if (!isBusyError(error) || attempt === maxAttempts - 1) {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const delay = calculateDelay(attempt, baseDelayMs, maxDelayMs);
|
|
42
|
+
console.warn(`[sqlite] SQLITE_BUSY on attempt ${attempt + 1}/${maxAttempts}, retrying in ${Math.round(delay)}ms`);
|
|
43
|
+
await sleep(delay);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Should never reach here, but TypeScript needs it
|
|
47
|
+
throw lastError;
|
|
48
|
+
}
|
|
49
|
+
export { isBusyError };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type TTLCacheOptions = {
|
|
2
|
+
/** Time-to-live in milliseconds. Default: 30_000 (30s). */
|
|
3
|
+
ttl?: number;
|
|
4
|
+
/** Maximum entries before auto-pruning expired items. Default: 100. */
|
|
5
|
+
maxSize?: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* In-memory TTL cache backed by a Map.
|
|
9
|
+
*
|
|
10
|
+
* Designed for server-side deduplication of expensive lookups (auth sessions,
|
|
11
|
+
* membership checks, bootstrap data) across HTTP requests. Entries expire
|
|
12
|
+
* after `ttl` milliseconds and are automatically pruned when the map exceeds
|
|
13
|
+
* `maxSize`.
|
|
14
|
+
*
|
|
15
|
+
* Unlike WeakMap per-request caching (which deduplicates within a single
|
|
16
|
+
* request), TTLCache deduplicates across requests — e.g. when TanStack
|
|
17
|
+
* Router replays `beforeLoad` on client hydration, the server returns the
|
|
18
|
+
* cached result instantly (0 DB queries).
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { TTLCache } from '@bitclaw/sqlite/ttl-cache';
|
|
23
|
+
*
|
|
24
|
+
* type BootstrapData = { user: User; workspaces: Workspace[] };
|
|
25
|
+
*
|
|
26
|
+
* const bootstrapCache = new TTLCache<BootstrapData>({ ttl: 30_000 });
|
|
27
|
+
*
|
|
28
|
+
* // In your server function:
|
|
29
|
+
* const cached = bootstrapCache.get(sessionId);
|
|
30
|
+
* if (cached) return cached;
|
|
31
|
+
*
|
|
32
|
+
* const data = await expensiveQuery();
|
|
33
|
+
* bootstrapCache.set(sessionId, data);
|
|
34
|
+
* return data;
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare class TTLCache<T> {
|
|
38
|
+
private cache;
|
|
39
|
+
private ttl;
|
|
40
|
+
private maxSize;
|
|
41
|
+
constructor(options?: TTLCacheOptions);
|
|
42
|
+
/** Get a cached value if it exists and hasn't expired. */
|
|
43
|
+
get(key: string): T | undefined;
|
|
44
|
+
/** Check if a non-expired entry exists for the given key. */
|
|
45
|
+
has(key: string): boolean;
|
|
46
|
+
/** Store a value with the configured TTL. Auto-prunes if maxSize exceeded. */
|
|
47
|
+
set(key: string, value: T): void;
|
|
48
|
+
/** Remove a specific entry. */
|
|
49
|
+
delete(key: string): boolean;
|
|
50
|
+
/** Remove all entries. */
|
|
51
|
+
clear(): void;
|
|
52
|
+
/** Number of entries (including potentially expired ones). */
|
|
53
|
+
get size(): number;
|
|
54
|
+
/** Remove all expired entries. */
|
|
55
|
+
prune(): number;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=ttl-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ttl-cache.d.ts","sourceRoot":"","sources":["../../src/ttl-cache.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,eAAe,GAAG;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,QAAQ,CAAC,CAAC;IACrB,OAAO,CAAC,KAAK,CAAoC;IACjD,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,OAAO,CAAS;gBAEZ,OAAO,GAAE,eAAoB;IAKzC,0DAA0D;IAC1D,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAU/B,6DAA6D;IAC7D,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIzB,8EAA8E;IAC9E,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI;IAWhC,+BAA+B;IAC/B,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAI5B,0BAA0B;IAC1B,KAAK,IAAI,IAAI;IAIb,8DAA8D;IAC9D,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,kCAAkC;IAClC,KAAK,IAAI,MAAM;CAWhB"}
|