@framers/sql-storage-adapter 0.3.5 → 0.4.2

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.
@@ -9,7 +9,9 @@
9
9
  * - Parameter validation and sanitization
10
10
  * - Error handling and standardization
11
11
  * - Lifecycle management (open/close state tracking)
12
- * - Performance monitoring
12
+ * - Performance monitoring with configurable tiers
13
+ * - Query result caching (optional)
14
+ * - Lifecycle hooks for RAG integration
13
15
  * - Logging and diagnostics
14
16
  *
15
17
  * @example Implementing a new adapter
@@ -26,7 +28,23 @@
26
28
  * // ... implement other abstract methods
27
29
  * }
28
30
  * ```
31
+ *
32
+ * @example Using with performance tiers and hooks
33
+ * ```typescript
34
+ * const adapter = new MyAdapter({
35
+ * performance: { tier: 'balanced', trackMetrics: true },
36
+ * hooks: {
37
+ * onBeforeWrite: async (ctx) => {
38
+ * // Generate embedding for RAG
39
+ * ctx.metadata = { embedding: await embed(ctx.parameters?.[0]) };
40
+ * return ctx;
41
+ * }
42
+ * }
43
+ * });
44
+ * ```
29
45
  */
46
+ import { resolvePerformanceConfig, isTransientError, } from '../core/contracts/performance.js';
47
+ import { generateOperationId } from '../core/contracts/hooks.js';
30
48
  /**
31
49
  * Base state for all adapters.
32
50
  */
@@ -45,7 +63,9 @@ var AdapterState;
45
63
  * - State management (open/close tracking)
46
64
  * - Parameter validation
47
65
  * - Error handling and wrapping
48
- * - Performance metrics
66
+ * - Performance metrics with configurable tiers
67
+ * - Query result caching (tier-dependent)
68
+ * - Lifecycle hooks for RAG and analytics
49
69
  * - Logging and diagnostics
50
70
  */
51
71
  export class BaseStorageAdapter {
@@ -53,6 +73,31 @@ export class BaseStorageAdapter {
53
73
  * Creates a new adapter instance.
54
74
  *
55
75
  * @param options - Configuration options for the adapter
76
+ *
77
+ * @example Default balanced tier
78
+ * ```typescript
79
+ * const adapter = new MyAdapter();
80
+ * ```
81
+ *
82
+ * @example Fast tier for development
83
+ * ```typescript
84
+ * const adapter = new MyAdapter({
85
+ * performance: { tier: 'fast' },
86
+ * verbose: true
87
+ * });
88
+ * ```
89
+ *
90
+ * @example With RAG hooks
91
+ * ```typescript
92
+ * const adapter = new MyAdapter({
93
+ * hooks: {
94
+ * onBeforeWrite: async (ctx) => {
95
+ * ctx.metadata = { embedding: await embed(ctx.parameters) };
96
+ * return ctx;
97
+ * }
98
+ * }
99
+ * });
100
+ * ```
56
101
  */
57
102
  constructor(options = {}) {
58
103
  // State management
@@ -64,17 +109,39 @@ export class BaseStorageAdapter {
64
109
  totalTransactions: 0,
65
110
  totalErrors: 0,
66
111
  averageQueryDuration: 0,
67
- openedAt: null
112
+ openedAt: null,
113
+ slowQueries: 0,
114
+ retries: 0,
115
+ };
116
+ // Cache
117
+ this.queryCache = new Map();
118
+ this.cacheStats = {
119
+ hits: 0,
120
+ misses: 0,
121
+ hitRatio: 0,
122
+ size: 0,
123
+ bytesUsed: 0,
124
+ evictions: 0,
125
+ invalidations: 0,
68
126
  };
69
127
  // Performance tracking
70
128
  this.queryDurations = [];
71
129
  this.MAX_DURATION_SAMPLES = 100; // Keep last 100 for rolling average
72
- this.options = {
73
- verbose: options.verbose ?? false,
74
- validateSQL: options.validateSQL ?? true,
75
- trackPerformance: options.trackPerformance ?? true,
76
- maxRetries: options.maxRetries ?? 3
77
- };
130
+ this.options = options;
131
+ // Resolve performance config, allowing top-level validateSQL to override
132
+ const perfConfig = options.performance ?? {};
133
+ const resolvedSettings = resolvePerformanceConfig(perfConfig);
134
+ // Allow top-level validateSQL to override performance.validateSql
135
+ if (options.validateSQL !== undefined) {
136
+ this.performanceSettings = {
137
+ ...resolvedSettings,
138
+ validateSql: options.validateSQL,
139
+ };
140
+ }
141
+ else {
142
+ this.performanceSettings = resolvedSettings;
143
+ }
144
+ this.hooks = options.hooks ?? {};
78
145
  }
79
146
  // ============================================================================
80
147
  // Public Interface (Template Methods)
@@ -106,59 +173,205 @@ export class BaseStorageAdapter {
106
173
  }
107
174
  /**
108
175
  * Executes a mutation statement (INSERT, UPDATE, DELETE).
176
+ *
177
+ * Invokes `onBeforeWrite` and `onAfterWrite` hooks if configured.
178
+ * Supports retry on transient errors based on performance tier.
109
179
  */
110
180
  async run(statement, parameters) {
111
181
  this.assertOpen();
112
182
  this.validateStatement(statement);
113
183
  const startTime = Date.now();
184
+ const operationId = generateOperationId();
185
+ // Build write context for hooks
186
+ let context = {
187
+ operationId,
188
+ operation: 'run',
189
+ startTime,
190
+ adapterKind: this.kind,
191
+ statement,
192
+ parameters,
193
+ affectedTables: this.extractTables(statement),
194
+ };
114
195
  try {
115
- const result = await this.performRun(statement, parameters);
196
+ // Execute onBeforeWrite hook
197
+ if (this.hooks.onBeforeWrite) {
198
+ const hookResult = await this.hooks.onBeforeWrite(context);
199
+ if (hookResult === undefined) {
200
+ // Hook aborted the operation
201
+ return { changes: 0 };
202
+ }
203
+ context = hookResult;
204
+ }
205
+ // Execute with retry logic
206
+ const result = await this.executeWithRetry(() => this.performRun(context.statement, context.parameters), context);
207
+ const duration = Date.now() - startTime;
116
208
  this.metrics.totalMutations++;
117
- this.trackDuration(Date.now() - startTime);
209
+ this.trackDuration(duration);
210
+ this.checkSlowQuery(duration, context.statement);
211
+ // Invalidate cache for affected tables
212
+ if (this.performanceSettings.cacheEnabled && context.affectedTables) {
213
+ this.invalidateCache(context.affectedTables);
214
+ }
118
215
  this.log(`Mutation executed: ${statement.substring(0, 50)}... (${result.changes} rows affected)`);
216
+ // Execute onAfterWrite hook
217
+ if (this.hooks.onAfterWrite) {
218
+ await this.hooks.onAfterWrite(context, result);
219
+ }
119
220
  return result;
120
221
  }
121
222
  catch (error) {
122
223
  this.metrics.totalErrors++;
123
- throw this.wrapError(`Failed to execute mutation: ${statement}`, error);
224
+ const wrappedError = this.wrapError(`Failed to execute mutation: ${statement}`, error);
225
+ // Execute onError hook
226
+ if (this.hooks.onError) {
227
+ const hookResult = await this.hooks.onError(wrappedError, context);
228
+ if (hookResult === undefined) {
229
+ // Hook suppressed the error
230
+ return { changes: 0 };
231
+ }
232
+ throw hookResult;
233
+ }
234
+ throw wrappedError;
124
235
  }
125
236
  }
126
237
  /**
127
238
  * Retrieves a single row.
239
+ *
240
+ * Supports caching based on performance tier and invokes query hooks.
128
241
  */
129
242
  async get(statement, parameters) {
130
243
  this.assertOpen();
131
244
  this.validateStatement(statement);
132
245
  const startTime = Date.now();
246
+ const operationId = generateOperationId();
247
+ const cacheKey = this.getCacheKey('get', statement, parameters);
248
+ // Check cache first
249
+ if (this.performanceSettings.cacheEnabled) {
250
+ const cached = this.getFromCache(cacheKey);
251
+ if (cached !== undefined) {
252
+ return cached;
253
+ }
254
+ }
255
+ // Build query context for hooks
256
+ let context = {
257
+ operationId,
258
+ operation: 'get',
259
+ startTime,
260
+ adapterKind: this.kind,
261
+ statement,
262
+ parameters,
263
+ affectedTables: this.extractTables(statement),
264
+ };
133
265
  try {
134
- const result = await this.performGet(statement, parameters);
266
+ // Execute onBeforeQuery hook
267
+ if (this.hooks.onBeforeQuery) {
268
+ const hookResult = await this.hooks.onBeforeQuery(context);
269
+ if (hookResult === undefined) {
270
+ return null; // Hook aborted
271
+ }
272
+ context = hookResult;
273
+ }
274
+ // Execute with retry
275
+ let result = await this.executeWithRetry(() => this.performGet(context.statement, context.parameters), context);
276
+ const duration = Date.now() - startTime;
135
277
  this.metrics.totalQueries++;
136
- this.trackDuration(Date.now() - startTime);
278
+ this.trackDuration(duration);
279
+ this.checkSlowQuery(duration, context.statement);
137
280
  this.log(`Query executed: ${statement.substring(0, 50)}... (${result ? '1 row' : 'no rows'})`);
281
+ // Execute onAfterQuery hook
282
+ if (this.hooks.onAfterQuery) {
283
+ const hookResult = await this.hooks.onAfterQuery(context, result);
284
+ if (hookResult !== undefined) {
285
+ result = hookResult;
286
+ }
287
+ }
288
+ // Cache the result
289
+ if (this.performanceSettings.cacheEnabled) {
290
+ this.setCache(cacheKey, result, context.affectedTables);
291
+ }
138
292
  return result;
139
293
  }
140
294
  catch (error) {
141
295
  this.metrics.totalErrors++;
142
- throw this.wrapError(`Failed to execute query: ${statement}`, error);
296
+ const wrappedError = this.wrapError(`Failed to execute query: ${statement}`, error);
297
+ if (this.hooks.onError) {
298
+ const hookResult = await this.hooks.onError(wrappedError, context);
299
+ if (hookResult === undefined) {
300
+ return null;
301
+ }
302
+ throw hookResult;
303
+ }
304
+ throw wrappedError;
143
305
  }
144
306
  }
145
307
  /**
146
308
  * Retrieves all rows.
309
+ *
310
+ * Supports caching based on performance tier and invokes query hooks.
147
311
  */
148
312
  async all(statement, parameters) {
149
313
  this.assertOpen();
150
314
  this.validateStatement(statement);
151
315
  const startTime = Date.now();
316
+ const operationId = generateOperationId();
317
+ const cacheKey = this.getCacheKey('all', statement, parameters);
318
+ // Check cache first
319
+ if (this.performanceSettings.cacheEnabled) {
320
+ const cached = this.getFromCache(cacheKey);
321
+ if (cached !== undefined) {
322
+ return cached;
323
+ }
324
+ }
325
+ // Build query context for hooks
326
+ let context = {
327
+ operationId,
328
+ operation: 'all',
329
+ startTime,
330
+ adapterKind: this.kind,
331
+ statement,
332
+ parameters,
333
+ affectedTables: this.extractTables(statement),
334
+ };
152
335
  try {
153
- const results = await this.performAll(statement, parameters);
336
+ // Execute onBeforeQuery hook
337
+ if (this.hooks.onBeforeQuery) {
338
+ const hookResult = await this.hooks.onBeforeQuery(context);
339
+ if (hookResult === undefined) {
340
+ return []; // Hook aborted
341
+ }
342
+ context = hookResult;
343
+ }
344
+ // Execute with retry
345
+ let results = await this.executeWithRetry(() => this.performAll(context.statement, context.parameters), context);
346
+ const duration = Date.now() - startTime;
154
347
  this.metrics.totalQueries++;
155
- this.trackDuration(Date.now() - startTime);
348
+ this.trackDuration(duration);
349
+ this.checkSlowQuery(duration, context.statement);
156
350
  this.log(`Query executed: ${statement.substring(0, 50)}... (${results.length} rows)`);
351
+ // Execute onAfterQuery hook
352
+ if (this.hooks.onAfterQuery) {
353
+ const hookResult = await this.hooks.onAfterQuery(context, results);
354
+ if (hookResult !== undefined) {
355
+ results = hookResult;
356
+ }
357
+ }
358
+ // Cache the result
359
+ if (this.performanceSettings.cacheEnabled) {
360
+ this.setCache(cacheKey, results, context.affectedTables);
361
+ }
157
362
  return results;
158
363
  }
159
364
  catch (error) {
160
365
  this.metrics.totalErrors++;
161
- throw this.wrapError(`Failed to execute query: ${statement}`, error);
366
+ const wrappedError = this.wrapError(`Failed to execute query: ${statement}`, error);
367
+ if (this.hooks.onError) {
368
+ const hookResult = await this.hooks.onError(wrappedError, context);
369
+ if (hookResult === undefined) {
370
+ return [];
371
+ }
372
+ throw hookResult;
373
+ }
374
+ throw wrappedError;
162
375
  }
163
376
  }
164
377
  /**
@@ -182,20 +395,60 @@ export class BaseStorageAdapter {
182
395
  }
183
396
  /**
184
397
  * Executes a transaction.
398
+ *
399
+ * Invokes transaction hooks and invalidates cache on completion.
185
400
  */
186
401
  async transaction(fn) {
187
402
  this.assertOpen();
188
403
  const startTime = Date.now();
404
+ const operationId = generateOperationId();
405
+ // Build transaction context
406
+ let context = {
407
+ operationId,
408
+ operation: 'transaction',
409
+ startTime,
410
+ adapterKind: this.kind,
411
+ };
189
412
  try {
413
+ // Execute onBeforeTransaction hook
414
+ if (this.hooks.onBeforeTransaction) {
415
+ const hookResult = await this.hooks.onBeforeTransaction(context);
416
+ if (hookResult === undefined) {
417
+ throw new Error('Transaction aborted by hook');
418
+ }
419
+ context = hookResult;
420
+ }
190
421
  const result = await this.performTransaction(fn);
422
+ context.outcome = 'committed';
191
423
  this.metrics.totalTransactions++;
192
424
  this.trackDuration(Date.now() - startTime);
193
425
  this.log('Transaction committed successfully');
426
+ // Invalidate all cache on transaction commit (conservative approach)
427
+ if (this.performanceSettings.cacheEnabled) {
428
+ this.clearCache();
429
+ }
430
+ // Execute onAfterTransaction hook
431
+ if (this.hooks.onAfterTransaction) {
432
+ await this.hooks.onAfterTransaction(context);
433
+ }
194
434
  return result;
195
435
  }
196
436
  catch (error) {
437
+ context.outcome = 'rolled_back';
197
438
  this.metrics.totalErrors++;
198
- throw this.wrapError('Transaction failed', error);
439
+ const wrappedError = this.wrapError('Transaction failed', error);
440
+ // Execute hooks
441
+ if (this.hooks.onAfterTransaction) {
442
+ await this.hooks.onAfterTransaction(context);
443
+ }
444
+ if (this.hooks.onError) {
445
+ const hookResult = await this.hooks.onError(wrappedError, context);
446
+ if (hookResult === undefined) {
447
+ throw new Error('Transaction rolled back');
448
+ }
449
+ throw hookResult;
450
+ }
451
+ throw wrappedError;
199
452
  }
200
453
  }
201
454
  /**
@@ -277,7 +530,7 @@ export class BaseStorageAdapter {
277
530
  * @throws {Error} If statement is invalid
278
531
  */
279
532
  validateStatement(statement) {
280
- if (!this.options.validateSQL) {
533
+ if (!this.performanceSettings.validateSql) {
281
534
  return;
282
535
  }
283
536
  if (!statement || !statement.trim()) {
@@ -288,6 +541,195 @@ export class BaseStorageAdapter {
288
541
  this.log('Warning: SQL comment detected in statement');
289
542
  }
290
543
  }
544
+ // ============================================================================
545
+ // Cache Management
546
+ // ============================================================================
547
+ /**
548
+ * Generates a cache key for a query.
549
+ * Includes operation type to prevent get/all cache collisions.
550
+ */
551
+ getCacheKey(operation, statement, parameters) {
552
+ const paramStr = parameters ? JSON.stringify(parameters) : '';
553
+ return `${operation}::${statement}::${paramStr}`;
554
+ }
555
+ /**
556
+ * Gets a value from cache if valid.
557
+ */
558
+ getFromCache(key) {
559
+ const entry = this.queryCache.get(key);
560
+ if (!entry) {
561
+ this.cacheStats.misses++;
562
+ this.updateCacheHitRatio();
563
+ return undefined;
564
+ }
565
+ // Check expiration
566
+ if (Date.now() > entry.expiresAt) {
567
+ this.queryCache.delete(key);
568
+ this.cacheStats.misses++;
569
+ this.updateCacheHitRatio();
570
+ return undefined;
571
+ }
572
+ // Update LRU tracking
573
+ entry.hits++;
574
+ entry.lastAccessedAt = Date.now();
575
+ this.cacheStats.hits++;
576
+ this.updateCacheHitRatio();
577
+ return entry.data;
578
+ }
579
+ /**
580
+ * Sets a value in cache.
581
+ */
582
+ setCache(key, data, affectedTables) {
583
+ // Enforce max entries
584
+ if (this.queryCache.size >= this.performanceSettings.cacheMaxEntries) {
585
+ this.evictLRU();
586
+ }
587
+ const now = Date.now();
588
+ const entry = {
589
+ data,
590
+ createdAt: now,
591
+ expiresAt: now + this.performanceSettings.cacheTtlMs,
592
+ affectedTables: affectedTables ?? [],
593
+ hits: 0,
594
+ lastAccessedAt: now,
595
+ };
596
+ this.queryCache.set(key, entry);
597
+ this.cacheStats.size = this.queryCache.size;
598
+ }
599
+ /**
600
+ * Invalidates cache entries for specific tables.
601
+ */
602
+ invalidateCache(tables) {
603
+ const tableSet = new Set(tables.map(t => t.toLowerCase()));
604
+ for (const [key, entry] of this.queryCache.entries()) {
605
+ const hasAffectedTable = entry.affectedTables.some(t => tableSet.has(t.toLowerCase()));
606
+ if (hasAffectedTable) {
607
+ this.queryCache.delete(key);
608
+ this.cacheStats.invalidations++;
609
+ }
610
+ }
611
+ this.cacheStats.size = this.queryCache.size;
612
+ }
613
+ /**
614
+ * Clears entire cache.
615
+ */
616
+ clearCache() {
617
+ this.queryCache.clear();
618
+ this.cacheStats.size = 0;
619
+ }
620
+ /**
621
+ * Evicts least recently used cache entry.
622
+ */
623
+ evictLRU() {
624
+ let oldest = null;
625
+ for (const [key, entry] of this.queryCache.entries()) {
626
+ if (!oldest || entry.lastAccessedAt < oldest.time) {
627
+ oldest = { key, time: entry.lastAccessedAt };
628
+ }
629
+ }
630
+ if (oldest) {
631
+ this.queryCache.delete(oldest.key);
632
+ this.cacheStats.evictions++;
633
+ }
634
+ }
635
+ /**
636
+ * Updates cache hit ratio.
637
+ */
638
+ updateCacheHitRatio() {
639
+ const total = this.cacheStats.hits + this.cacheStats.misses;
640
+ this.cacheStats.hitRatio = total > 0 ? this.cacheStats.hits / total : 0;
641
+ }
642
+ // ============================================================================
643
+ // Retry Logic
644
+ // ============================================================================
645
+ /**
646
+ * Executes an operation with retry logic.
647
+ */
648
+ async executeWithRetry(operation, context) {
649
+ if (!this.performanceSettings.retryOnError) {
650
+ return operation();
651
+ }
652
+ let lastError;
653
+ for (let attempt = 0; attempt <= this.performanceSettings.maxRetries; attempt++) {
654
+ try {
655
+ return await operation();
656
+ }
657
+ catch (error) {
658
+ lastError = error instanceof Error ? error : new Error(String(error));
659
+ if (!isTransientError(lastError) || attempt >= this.performanceSettings.maxRetries) {
660
+ throw lastError;
661
+ }
662
+ this.metrics.retries++;
663
+ const delay = this.performanceSettings.retryDelayMs * Math.pow(2, attempt);
664
+ this.log(`Retrying operation ${context.operationId} in ${delay}ms (attempt ${attempt + 1})`);
665
+ await this.sleep(delay);
666
+ }
667
+ }
668
+ throw lastError;
669
+ }
670
+ /**
671
+ * Sleep utility for retry delays.
672
+ */
673
+ sleep(ms) {
674
+ return new Promise(resolve => setTimeout(resolve, ms));
675
+ }
676
+ // ============================================================================
677
+ // SQL Parsing Utilities
678
+ // ============================================================================
679
+ /**
680
+ * Extracts table names from a SQL statement.
681
+ *
682
+ * @remarks
683
+ * Simple regex-based extraction. For complex queries, consider
684
+ * using a proper SQL parser.
685
+ */
686
+ extractTables(statement) {
687
+ const tables = [];
688
+ const upperStatement = statement.toUpperCase();
689
+ // Match FROM table_name
690
+ const fromMatch = upperStatement.match(/FROM\s+([^\s,;(]+)/gi);
691
+ if (fromMatch) {
692
+ fromMatch.forEach(match => {
693
+ const table = match.replace(/^FROM\s+/i, '').trim();
694
+ if (table && !table.startsWith('(')) {
695
+ tables.push(table.toLowerCase());
696
+ }
697
+ });
698
+ }
699
+ // Match INSERT INTO table_name
700
+ const insertMatch = statement.match(/INSERT\s+INTO\s+([^\s(]+)/i);
701
+ if (insertMatch?.[1]) {
702
+ tables.push(insertMatch[1].toLowerCase());
703
+ }
704
+ // Match UPDATE table_name
705
+ const updateMatch = statement.match(/UPDATE\s+([^\s]+)/i);
706
+ if (updateMatch?.[1]) {
707
+ tables.push(updateMatch[1].toLowerCase());
708
+ }
709
+ // Match DELETE FROM table_name
710
+ const deleteMatch = statement.match(/DELETE\s+FROM\s+([^\s]+)/i);
711
+ if (deleteMatch?.[1]) {
712
+ tables.push(deleteMatch[1].toLowerCase());
713
+ }
714
+ // Match JOIN table_name
715
+ const joinMatches = statement.match(/JOIN\s+([^\s]+)/gi);
716
+ if (joinMatches) {
717
+ joinMatches.forEach(match => {
718
+ const table = match.replace(/^JOIN\s+/i, '').trim();
719
+ tables.push(table.toLowerCase());
720
+ });
721
+ }
722
+ return [...new Set(tables)]; // Deduplicate
723
+ }
724
+ /**
725
+ * Checks if query duration exceeds slow query threshold.
726
+ */
727
+ checkSlowQuery(duration, statement) {
728
+ if (duration > this.performanceSettings.slowQueryThresholdMs) {
729
+ this.metrics.slowQueries++;
730
+ this.log(`SLOW QUERY (${duration}ms): ${statement.substring(0, 100)}...`);
731
+ }
732
+ }
291
733
  /**
292
734
  * Wraps an error with adapter context.
293
735
  */
@@ -308,6 +750,12 @@ export class BaseStorageAdapter {
308
750
  console.log(`[${this.kind}] ${message}`);
309
751
  }
310
752
  }
753
+ /**
754
+ * Logs a warning message (always outputs regardless of verbose).
755
+ */
756
+ logWarn(message) {
757
+ console.warn(`[${this.kind}] ${message}`);
758
+ }
311
759
  /**
312
760
  * Tracks query duration for performance metrics.
313
761
  */
@@ -337,7 +785,25 @@ export class BaseStorageAdapter {
337
785
  * Gets performance metrics.
338
786
  */
339
787
  getMetrics() {
340
- return { ...this.metrics };
788
+ return {
789
+ ...this.metrics,
790
+ cache: this.performanceSettings.cacheEnabled ? { ...this.cacheStats } : undefined,
791
+ };
792
+ }
793
+ /**
794
+ * Gets cache statistics.
795
+ */
796
+ getCacheStats() {
797
+ if (!this.performanceSettings.cacheEnabled) {
798
+ return null;
799
+ }
800
+ return { ...this.cacheStats };
801
+ }
802
+ /**
803
+ * Gets current performance settings.
804
+ */
805
+ getPerformanceSettings() {
806
+ return { ...this.performanceSettings };
341
807
  }
342
808
  /**
343
809
  * Checks if adapter is open.