@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,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"}