wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs typescript
at main 29 kB view raw
1import type { 2 TieredStorageConfig, 3 SetOptions, 4 StorageResult, 5 SetResult, 6 StorageMetadata, 7 StorageTier, 8 AllTierStats, 9 StorageSnapshot, 10 StreamResult, 11 StreamSetOptions, 12} from './types/index'; 13import { 14 compress, 15 decompress, 16 createCompressStream, 17 createDecompressStream, 18} from './utils/compression.js'; 19import { defaultSerialize, defaultDeserialize } from './utils/serialization.js'; 20import { calculateChecksum } from './utils/checksum.js'; 21import { matchGlob } from './utils/glob.js'; 22import { PassThrough, type Readable } from 'node:stream'; 23import { createHash } from 'node:crypto'; 24 25/** 26 * Main orchestrator for tiered storage system. 27 * 28 * @typeParam T - The type of data being stored 29 * 30 * @remarks 31 * Implements a cascading containment model: 32 * - **Write Strategy (Cascading Down):** Write to hot → also writes to warm and cold 33 * - **Read Strategy (Bubbling Up):** Check hot first → if miss, check warm → if miss, check cold 34 * - **Bootstrap Strategy:** Hot can bootstrap from warm, warm can bootstrap from cold 35 * 36 * The cold tier is the source of truth and is required. 37 * Hot and warm tiers are optional performance optimizations. 38 * 39 * @example 40 * ```typescript 41 * const storage = new TieredStorage({ 42 * tiers: { 43 * hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), // 100MB 44 * warm: new DiskStorageTier({ directory: './cache' }), 45 * cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }), 46 * }, 47 * compression: true, 48 * defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days 49 * promotionStrategy: 'lazy', 50 * }); 51 * 52 * // Store data (cascades to all tiers) 53 * await storage.set('user:123', { name: 'Alice' }); 54 * 55 * // Retrieve data (bubbles up from cold → warm → hot) 56 * const user = await storage.get('user:123'); 57 * 58 * // Invalidate all keys with prefix 59 * await storage.invalidate('user:'); 60 * ``` 61 */ 62export class TieredStorage<T = unknown> { 63 private serialize: (data: unknown) => Promise<Uint8Array>; 64 private deserialize: (data: Uint8Array) => Promise<unknown>; 65 66 constructor(private config: TieredStorageConfig) { 67 if (!config.tiers.cold) { 68 throw new Error('Cold tier is required'); 69 } 70 71 this.serialize = config.serialization?.serialize ?? defaultSerialize; 72 this.deserialize = config.serialization?.deserialize ?? defaultDeserialize; 73 } 74 75 /** 76 * Retrieve data for a key. 77 * 78 * @param key - The key to retrieve 79 * @returns The data, or null if not found or expired 80 * 81 * @remarks 82 * Checks tiers in order: hot → warm → cold. 83 * On cache miss, promotes data to upper tiers based on promotionStrategy. 84 * Automatically handles decompression and deserialization. 85 * Returns null if key doesn't exist or has expired (TTL). 86 */ 87 async get(key: string): Promise<T | null> { 88 const result = await this.getWithMetadata(key); 89 return result ? result.data : null; 90 } 91 92 /** 93 * Retrieve data with metadata and source tier information. 94 * 95 * @param key - The key to retrieve 96 * @returns The data, metadata, and source tier, or null if not found 97 * 98 * @remarks 99 * Use this when you need to know: 100 * - Which tier served the data (for observability) 101 * - Metadata like access count, TTL, checksum 102 * - When the data was created/last accessed 103 */ 104 async getWithMetadata(key: string): Promise<StorageResult<T> | null> { 105 // 1. Check hot tier first 106 if (this.config.tiers.hot) { 107 const result = await this.getFromTier(this.config.tiers.hot, key); 108 if (result) { 109 if (this.isExpired(result.metadata)) { 110 await this.delete(key); 111 return null; 112 } 113 // Fire-and-forget access stats update (non-critical) 114 void this.updateAccessStats(key, 'hot'); 115 return { 116 data: (await this.deserializeData(result.data)) as T, 117 metadata: result.metadata, 118 source: 'hot', 119 }; 120 } 121 } 122 123 // 2. Check warm tier 124 if (this.config.tiers.warm) { 125 const result = await this.getFromTier(this.config.tiers.warm, key); 126 if (result) { 127 if (this.isExpired(result.metadata)) { 128 await this.delete(key); 129 return null; 130 } 131 // Eager promotion to hot tier (awaited - guaranteed to complete) 132 if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') { 133 await this.config.tiers.hot.set(key, result.data, result.metadata); 134 } 135 // Fire-and-forget access stats update (non-critical) 136 void this.updateAccessStats(key, 'warm'); 137 return { 138 data: (await this.deserializeData(result.data)) as T, 139 metadata: result.metadata, 140 source: 'warm', 141 }; 142 } 143 } 144 145 // 3. Check cold tier (source of truth) 146 const result = await this.getFromTier(this.config.tiers.cold, key); 147 if (result) { 148 if (this.isExpired(result.metadata)) { 149 await this.delete(key); 150 return null; 151 } 152 153 // Promote to warm and hot (if configured) 154 // Eager promotion is awaited to guarantee completion 155 if (this.config.promotionStrategy === 'eager') { 156 const promotions: Promise<void>[] = []; 157 if (this.config.tiers.warm) { 158 promotions.push(this.config.tiers.warm.set(key, result.data, result.metadata)); 159 } 160 if (this.config.tiers.hot) { 161 promotions.push(this.config.tiers.hot.set(key, result.data, result.metadata)); 162 } 163 await Promise.all(promotions); 164 } 165 166 // Fire-and-forget access stats update (non-critical) 167 void this.updateAccessStats(key, 'cold'); 168 return { 169 data: (await this.deserializeData(result.data)) as T, 170 metadata: result.metadata, 171 source: 'cold', 172 }; 173 } 174 175 return null; 176 } 177 178 /** 179 * Get data and metadata from a tier using the most efficient method. 180 * 181 * @remarks 182 * Uses the tier's getWithMetadata if available, otherwise falls back 183 * to separate get() and getMetadata() calls. 184 */ 185 private async getFromTier( 186 tier: StorageTier, 187 key: string, 188 ): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null> { 189 // Use optimized combined method if available 190 if (tier.getWithMetadata) { 191 return tier.getWithMetadata(key); 192 } 193 194 // Fallback: separate calls 195 const data = await tier.get(key); 196 if (!data) { 197 return null; 198 } 199 const metadata = await tier.getMetadata(key); 200 if (!metadata) { 201 return null; 202 } 203 return { data, metadata }; 204 } 205 206 /** 207 * Retrieve data as a readable stream with metadata. 208 * 209 * @param key - The key to retrieve 210 * @returns A readable stream, metadata, and source tier, or null if not found 211 * 212 * @remarks 213 * Use this for large files to avoid loading entire content into memory. 214 * The stream must be consumed or destroyed by the caller. 215 * 216 * Checks tiers in order: hot → warm → cold. 217 * On cache miss, does NOT promote data to upper tiers (streaming would 218 * require buffering, defeating the purpose). 219 * 220 * Decompression is automatically handled if the data was stored with 221 * compression enabled (metadata.compressed = true). 222 * 223 * @example 224 * ```typescript 225 * const result = await storage.getStream('large-file.mp4'); 226 * if (result) { 227 * result.stream.pipe(response); // Stream directly to HTTP response 228 * } 229 * ``` 230 */ 231 async getStream(key: string): Promise<StreamResult | null> { 232 // 1. Check hot tier first 233 if (this.config.tiers.hot?.getStream) { 234 const result = await this.config.tiers.hot.getStream(key); 235 if (result) { 236 if (this.isExpired(result.metadata)) { 237 (result.stream as Readable).destroy?.(); 238 await this.delete(key); 239 return null; 240 } 241 void this.updateAccessStats(key, 'hot'); 242 return this.wrapStreamWithDecompression(result, 'hot'); 243 } 244 } 245 246 // 2. Check warm tier 247 if (this.config.tiers.warm?.getStream) { 248 const result = await this.config.tiers.warm.getStream(key); 249 if (result) { 250 if (this.isExpired(result.metadata)) { 251 (result.stream as Readable).destroy?.(); 252 await this.delete(key); 253 return null; 254 } 255 // NOTE: No promotion for streaming (would require buffering) 256 void this.updateAccessStats(key, 'warm'); 257 return this.wrapStreamWithDecompression(result, 'warm'); 258 } 259 } 260 261 // 3. Check cold tier (source of truth) 262 if (this.config.tiers.cold.getStream) { 263 const result = await this.config.tiers.cold.getStream(key); 264 if (result) { 265 if (this.isExpired(result.metadata)) { 266 (result.stream as Readable).destroy?.(); 267 await this.delete(key); 268 return null; 269 } 270 // NOTE: No promotion for streaming (would require buffering) 271 void this.updateAccessStats(key, 'cold'); 272 return this.wrapStreamWithDecompression(result, 'cold'); 273 } 274 } 275 276 return null; 277 } 278 279 /** 280 * Wrap a stream result with decompression if needed. 281 */ 282 private wrapStreamWithDecompression( 283 result: { stream: NodeJS.ReadableStream; metadata: StorageMetadata }, 284 source: 'hot' | 'warm' | 'cold', 285 ): StreamResult { 286 if (result.metadata.compressed) { 287 // Pipe through decompression stream 288 const decompressStream = createDecompressStream(); 289 (result.stream as Readable).pipe(decompressStream); 290 return { stream: decompressStream, metadata: result.metadata, source }; 291 } 292 return { ...result, source }; 293 } 294 295 /** 296 * Store data from a readable stream. 297 * 298 * @param key - The key to store under 299 * @param stream - Readable stream of data to store 300 * @param options - Configuration including size (required), checksum, and tier options 301 * @returns Information about what was stored and where 302 * 303 * @remarks 304 * Use this for large files to avoid loading entire content into memory. 305 * 306 * **Important differences from set():** 307 * - `options.size` is required (stream size cannot be determined upfront) 308 * - Serialization is NOT applied (stream is stored as-is) 309 * - If no checksum is provided, one will be computed during streaming 310 * - Checksum is computed on the original (pre-compression) data 311 * 312 * **Compression:** 313 * - If `config.compression` is true, the stream is compressed before storage 314 * - Checksum is always computed on the original uncompressed data 315 * 316 * **Tier handling:** 317 * - Only writes to tiers that support streaming (have setStream method) 318 * - Hot tier is skipped by default for streaming (typically memory-based) 319 * - Tees the stream to write to multiple tiers simultaneously 320 * 321 * @example 322 * ```typescript 323 * const fileStream = fs.createReadStream('large-file.mp4'); 324 * const stat = fs.statSync('large-file.mp4'); 325 * 326 * await storage.setStream('videos/large.mp4', fileStream, { 327 * size: stat.size, 328 * mimeType: 'video/mp4', 329 * }); 330 * ``` 331 */ 332 async setStream( 333 key: string, 334 stream: NodeJS.ReadableStream, 335 options: StreamSetOptions, 336 ): Promise<SetResult> { 337 const shouldCompress = this.config.compression ?? false; 338 339 // Create metadata 340 const now = new Date(); 341 const ttl = options.ttl ?? this.config.defaultTTL; 342 343 const metadata: StorageMetadata = { 344 key, 345 size: options.size, // Original uncompressed size 346 createdAt: now, 347 lastAccessed: now, 348 accessCount: 0, 349 compressed: shouldCompress, 350 checksum: options.checksum ?? '', // Will be computed if not provided 351 ...(options.mimeType && { mimeType: options.mimeType }), 352 }; 353 354 if (ttl) { 355 metadata.ttl = new Date(now.getTime() + ttl); 356 } 357 358 if (options.metadata) { 359 metadata.customMetadata = options.metadata; 360 } 361 362 // Determine which tiers to write to 363 // Default: skip hot tier for streaming (typically memory-based, defeats purpose) 364 const allowedTiers = this.getTiersForKey(key, options.skipTiers ?? ['hot']); 365 366 // Collect tiers that support streaming 367 const streamingTiers: Array<{ name: 'hot' | 'warm' | 'cold'; tier: StorageTier }> = []; 368 369 if (this.config.tiers.hot?.setStream && allowedTiers.includes('hot')) { 370 streamingTiers.push({ name: 'hot', tier: this.config.tiers.hot }); 371 } 372 373 if (this.config.tiers.warm?.setStream && allowedTiers.includes('warm')) { 374 streamingTiers.push({ name: 'warm', tier: this.config.tiers.warm }); 375 } 376 377 if (this.config.tiers.cold.setStream) { 378 streamingTiers.push({ name: 'cold', tier: this.config.tiers.cold }); 379 } 380 381 const tiersWritten: ('hot' | 'warm' | 'cold')[] = []; 382 383 if (streamingTiers.length === 0) { 384 throw new Error('No tiers support streaming. Use set() for buffered writes.'); 385 } 386 387 // We always need to compute checksum on uncompressed data if not provided 388 const needsChecksum = !options.checksum; 389 390 // Create pass-through streams for each tier 391 const passThroughs = streamingTiers.map(() => new PassThrough()); 392 const hashStream = needsChecksum ? createHash('sha256') : null; 393 394 // Set up the stream pipeline: 395 // source -> (hash) -> (compress) -> tee to all tier streams 396 const sourceStream = stream as Readable; 397 398 // If compression is enabled, we need to: 399 // 1. Compute hash on original data 400 // 2. Then compress 401 // 3. Then tee to all tiers 402 if (shouldCompress) { 403 const compressStream = createCompressStream(); 404 405 // Hash the original uncompressed data 406 sourceStream.on('data', (chunk: Buffer) => { 407 if (hashStream) { 408 hashStream.update(chunk); 409 } 410 }); 411 412 // Pipe source through compression 413 sourceStream.pipe(compressStream); 414 415 // Tee compressed output to all tier streams 416 compressStream.on('data', (chunk: Buffer) => { 417 for (const pt of passThroughs) { 418 pt.write(chunk); 419 } 420 }); 421 422 compressStream.on('end', () => { 423 for (const pt of passThroughs) { 424 pt.end(); 425 } 426 }); 427 428 compressStream.on('error', (err) => { 429 for (const pt of passThroughs) { 430 pt.destroy(err); 431 } 432 }); 433 } else { 434 // No compression - hash and tee directly 435 sourceStream.on('data', (chunk: Buffer) => { 436 for (const pt of passThroughs) { 437 pt.write(chunk); 438 } 439 if (hashStream) { 440 hashStream.update(chunk); 441 } 442 }); 443 444 sourceStream.on('end', () => { 445 for (const pt of passThroughs) { 446 pt.end(); 447 } 448 }); 449 450 sourceStream.on('error', (err) => { 451 for (const pt of passThroughs) { 452 pt.destroy(err); 453 } 454 }); 455 } 456 457 // Wait for all tier writes 458 const writePromises = streamingTiers.map(async ({ name, tier }, index) => { 459 await tier.setStream!(key, passThroughs[index]!, metadata); 460 tiersWritten.push(name); 461 }); 462 463 await Promise.all(writePromises); 464 465 // Update checksum in metadata if computed 466 if (hashStream) { 467 metadata.checksum = hashStream.digest('hex'); 468 // Update metadata in all tiers with the computed checksum 469 await Promise.all(streamingTiers.map(({ tier }) => tier.setMetadata(key, metadata))); 470 } 471 472 return { key, metadata, tiersWritten }; 473 } 474 475 /** 476 * Store data with optional configuration. 477 * 478 * @param key - The key to store under 479 * @param data - The data to store 480 * @param options - Optional configuration (TTL, metadata, tier skipping) 481 * @returns Information about what was stored and where 482 * 483 * @remarks 484 * Data cascades down through tiers: 485 * - If written to hot, also written to warm and cold 486 * - If written to warm (hot skipped), also written to cold 487 * - Cold is always written (source of truth) 488 * 489 * Use `skipTiers` to control placement. For example: 490 * - Large files: `skipTiers: ['hot']` to avoid memory bloat 491 * - Critical small files: Write to all tiers for fastest access 492 * 493 * Automatically handles serialization and optional compression. 494 */ 495 async set(key: string, data: T, options?: SetOptions): Promise<SetResult> { 496 // 1. Serialize data 497 const serialized = await this.serialize(data); 498 499 // 2. Optionally compress 500 const finalData = this.config.compression ? await compress(serialized) : serialized; 501 502 // 3. Create metadata 503 const metadata = this.createMetadata(key, finalData, options); 504 505 // 4. Determine which tiers to write to 506 const allowedTiers = this.getTiersForKey(key, options?.skipTiers); 507 508 // 5. Write to tiers 509 const tiersWritten: ('hot' | 'warm' | 'cold')[] = []; 510 511 if (this.config.tiers.hot && allowedTiers.includes('hot')) { 512 await this.config.tiers.hot.set(key, finalData, metadata); 513 tiersWritten.push('hot'); 514 } 515 516 if (this.config.tiers.warm && allowedTiers.includes('warm')) { 517 await this.config.tiers.warm.set(key, finalData, metadata); 518 tiersWritten.push('warm'); 519 } 520 521 // Always write to cold (source of truth) 522 await this.config.tiers.cold.set(key, finalData, metadata); 523 tiersWritten.push('cold'); 524 525 return { key, metadata, tiersWritten }; 526 } 527 528 /** 529 * Determine which tiers a key should be written to. 530 * 531 * @param key - The key being stored 532 * @param skipTiers - Explicit tiers to skip (overrides placement rules) 533 * @returns Array of tiers to write to 534 * 535 * @remarks 536 * Priority: skipTiers option > placementRules > all configured tiers 537 */ 538 private getTiersForKey( 539 key: string, 540 skipTiers?: ('hot' | 'warm')[], 541 ): ('hot' | 'warm' | 'cold')[] { 542 // If explicit skipTiers provided, use that 543 if (skipTiers && skipTiers.length > 0) { 544 const allTiers: ('hot' | 'warm' | 'cold')[] = ['hot', 'warm', 'cold']; 545 return allTiers.filter((t) => !skipTiers.includes(t as 'hot' | 'warm')); 546 } 547 548 // Check placement rules 549 if (this.config.placementRules) { 550 for (const rule of this.config.placementRules) { 551 if (matchGlob(rule.pattern, key)) { 552 // Ensure cold is always included 553 if (!rule.tiers.includes('cold')) { 554 return [...rule.tiers, 'cold']; 555 } 556 return rule.tiers; 557 } 558 } 559 } 560 561 // Default: write to all configured tiers 562 return ['hot', 'warm', 'cold']; 563 } 564 565 /** 566 * Delete data from all tiers. 567 * 568 * @param key - The key to delete 569 * 570 * @remarks 571 * Deletes from all configured tiers in parallel. 572 * Does not throw if the key doesn't exist. 573 */ 574 async delete(key: string): Promise<void> { 575 await Promise.all([ 576 this.config.tiers.hot?.delete(key), 577 this.config.tiers.warm?.delete(key), 578 this.config.tiers.cold.delete(key), 579 ]); 580 } 581 582 /** 583 * Check if a key exists in any tier. 584 * 585 * @param key - The key to check 586 * @returns true if the key exists and hasn't expired 587 * 588 * @remarks 589 * Checks tiers in order: hot → warm → cold. 590 * Returns false if key exists but has expired. 591 */ 592 async exists(key: string): Promise<boolean> { 593 // Check hot first (fastest) 594 if (this.config.tiers.hot && (await this.config.tiers.hot.exists(key))) { 595 const metadata = await this.config.tiers.hot.getMetadata(key); 596 if (metadata && !this.isExpired(metadata)) { 597 return true; 598 } 599 } 600 601 // Check warm 602 if (this.config.tiers.warm && (await this.config.tiers.warm.exists(key))) { 603 const metadata = await this.config.tiers.warm.getMetadata(key); 604 if (metadata && !this.isExpired(metadata)) { 605 return true; 606 } 607 } 608 609 // Check cold (source of truth) 610 if (await this.config.tiers.cold.exists(key)) { 611 const metadata = await this.config.tiers.cold.getMetadata(key); 612 if (metadata && !this.isExpired(metadata)) { 613 return true; 614 } 615 } 616 617 return false; 618 } 619 620 /** 621 * Renew TTL for a key. 622 * 623 * @param key - The key to touch 624 * @param ttlMs - Optional new TTL in milliseconds (uses default if not provided) 625 * 626 * @remarks 627 * Updates the TTL and lastAccessed timestamp in all tiers. 628 * Useful for implementing "keep alive" behavior for actively used keys. 629 * Does nothing if no TTL is configured. 630 */ 631 async touch(key: string, ttlMs?: number): Promise<void> { 632 const ttl = ttlMs ?? this.config.defaultTTL; 633 if (!ttl) return; 634 635 const newTTL = new Date(Date.now() + ttl); 636 637 for (const tier of [ 638 this.config.tiers.hot, 639 this.config.tiers.warm, 640 this.config.tiers.cold, 641 ]) { 642 if (!tier) continue; 643 644 const metadata = await tier.getMetadata(key); 645 if (metadata) { 646 metadata.ttl = newTTL; 647 metadata.lastAccessed = new Date(); 648 await tier.setMetadata(key, metadata); 649 } 650 } 651 } 652 653 /** 654 * Invalidate all keys matching a prefix. 655 * 656 * @param prefix - The prefix to match (e.g., 'user:' matches 'user:123', 'user:456') 657 * @returns Number of keys deleted 658 * 659 * @remarks 660 * Useful for bulk invalidation: 661 * - Site invalidation: `invalidate('site:abc:')` 662 * - User invalidation: `invalidate('user:123:')` 663 * - Global invalidation: `invalidate('')` (deletes everything) 664 * 665 * Deletes from all tiers in parallel for efficiency. 666 */ 667 async invalidate(prefix: string): Promise<number> { 668 const keysToDelete = new Set<string>(); 669 670 // Collect all keys matching prefix from all tiers 671 if (this.config.tiers.hot) { 672 for await (const key of this.config.tiers.hot.listKeys(prefix)) { 673 keysToDelete.add(key); 674 } 675 } 676 677 if (this.config.tiers.warm) { 678 for await (const key of this.config.tiers.warm.listKeys(prefix)) { 679 keysToDelete.add(key); 680 } 681 } 682 683 for await (const key of this.config.tiers.cold.listKeys(prefix)) { 684 keysToDelete.add(key); 685 } 686 687 // Delete from all tiers in parallel 688 const keys = Array.from(keysToDelete); 689 690 await Promise.all([ 691 this.config.tiers.hot?.deleteMany(keys), 692 this.config.tiers.warm?.deleteMany(keys), 693 this.config.tiers.cold.deleteMany(keys), 694 ]); 695 696 return keys.length; 697 } 698 699 /** 700 * List all keys, optionally filtered by prefix. 701 * 702 * @param prefix - Optional prefix to filter keys 703 * @returns Async iterator of keys 704 * 705 * @remarks 706 * Returns keys from the cold tier (source of truth). 707 * Memory-efficient - streams keys rather than loading all into memory. 708 * 709 * @example 710 * ```typescript 711 * for await (const key of storage.listKeys('user:')) { 712 * console.log(key); 713 * } 714 * ``` 715 */ 716 async *listKeys(prefix?: string): AsyncIterableIterator<string> { 717 // List from cold tier (source of truth) 718 for await (const key of this.config.tiers.cold.listKeys(prefix)) { 719 yield key; 720 } 721 } 722 723 /** 724 * Get aggregated statistics across all tiers. 725 * 726 * @returns Statistics including size, item count, hits, misses, hit rate 727 * 728 * @remarks 729 * Useful for monitoring and capacity planning. 730 * Hit rate is calculated as: hits / (hits + misses). 731 */ 732 async getStats(): Promise<AllTierStats> { 733 const [hot, warm, cold] = await Promise.all([ 734 this.config.tiers.hot?.getStats(), 735 this.config.tiers.warm?.getStats(), 736 this.config.tiers.cold.getStats(), 737 ]); 738 739 const totalHits = (hot?.hits ?? 0) + (warm?.hits ?? 0) + (cold?.hits ?? 0); 740 const totalMisses = (hot?.misses ?? 0) + (warm?.misses ?? 0) + (cold?.misses ?? 0); 741 const hitRate = totalHits + totalMisses > 0 ? totalHits / (totalHits + totalMisses) : 0; 742 743 return { 744 ...(hot && { hot }), 745 ...(warm && { warm }), 746 cold, 747 totalHits, 748 totalMisses, 749 hitRate, 750 }; 751 } 752 753 /** 754 * Clear all data from all tiers. 755 * 756 * @remarks 757 * Use with extreme caution! This will delete all data in the entire storage system. 758 * Cannot be undone. 759 */ 760 async clear(): Promise<void> { 761 await Promise.all([ 762 this.config.tiers.hot?.clear(), 763 this.config.tiers.warm?.clear(), 764 this.config.tiers.cold.clear(), 765 ]); 766 } 767 768 /** 769 * Clear a specific tier. 770 * 771 * @param tier - Which tier to clear 772 * 773 * @remarks 774 * Useful for: 775 * - Clearing hot tier to test warm/cold performance 776 * - Clearing warm tier to force rebuilding from cold 777 * - Clearing cold tier to start fresh (⚠️ loses source of truth!) 778 */ 779 async clearTier(tier: 'hot' | 'warm' | 'cold'): Promise<void> { 780 switch (tier) { 781 case 'hot': 782 await this.config.tiers.hot?.clear(); 783 break; 784 case 'warm': 785 await this.config.tiers.warm?.clear(); 786 break; 787 case 'cold': 788 await this.config.tiers.cold.clear(); 789 break; 790 } 791 } 792 793 /** 794 * Export metadata snapshot for backup or migration. 795 * 796 * @returns Snapshot containing all keys, metadata, and statistics 797 * 798 * @remarks 799 * The snapshot includes metadata but not the actual data (data remains in tiers). 800 * Useful for: 801 * - Backup and restore 802 * - Migration between storage systems 803 * - Auditing and compliance 804 */ 805 async export(): Promise<StorageSnapshot> { 806 const keys: string[] = []; 807 const metadata: Record<string, StorageMetadata> = {}; 808 809 // Export from cold tier (source of truth) 810 for await (const key of this.config.tiers.cold.listKeys()) { 811 keys.push(key); 812 const meta = await this.config.tiers.cold.getMetadata(key); 813 if (meta) { 814 metadata[key] = meta; 815 } 816 } 817 818 const stats = await this.getStats(); 819 820 return { 821 version: 1, 822 exportedAt: new Date(), 823 keys, 824 metadata, 825 stats, 826 }; 827 } 828 829 /** 830 * Import metadata snapshot. 831 * 832 * @param snapshot - Snapshot to import 833 * 834 * @remarks 835 * Validates version compatibility before importing. 836 * Only imports metadata - assumes data already exists in cold tier. 837 */ 838 async import(snapshot: StorageSnapshot): Promise<void> { 839 if (snapshot.version !== 1) { 840 throw new Error(`Unsupported snapshot version: ${snapshot.version}`); 841 } 842 843 // Import metadata into all configured tiers 844 for (const key of snapshot.keys) { 845 const metadata = snapshot.metadata[key]; 846 if (!metadata) continue; 847 848 if (this.config.tiers.hot) { 849 await this.config.tiers.hot.setMetadata(key, metadata); 850 } 851 852 if (this.config.tiers.warm) { 853 await this.config.tiers.warm.setMetadata(key, metadata); 854 } 855 856 await this.config.tiers.cold.setMetadata(key, metadata); 857 } 858 } 859 860 /** 861 * Bootstrap hot tier from warm tier. 862 * 863 * @param limit - Optional limit on number of items to load 864 * @returns Number of items loaded 865 * 866 * @remarks 867 * Loads the most frequently accessed items from warm into hot. 868 * Useful for warming up the cache after a restart. 869 * Items are sorted by: accessCount * lastAccessed timestamp (higher is better). 870 */ 871 async bootstrapHot(limit?: number): Promise<number> { 872 if (!this.config.tiers.hot || !this.config.tiers.warm) { 873 return 0; 874 } 875 876 let loaded = 0; 877 const keyMetadata: Array<[string, StorageMetadata]> = []; 878 879 // Load metadata for all keys 880 for await (const key of this.config.tiers.warm.listKeys()) { 881 const metadata = await this.config.tiers.warm.getMetadata(key); 882 if (metadata) { 883 keyMetadata.push([key, metadata]); 884 } 885 } 886 887 // Sort by access count * recency (simple scoring) 888 keyMetadata.sort((a, b) => { 889 const scoreA = a[1].accessCount * a[1].lastAccessed.getTime(); 890 const scoreB = b[1].accessCount * b[1].lastAccessed.getTime(); 891 return scoreB - scoreA; 892 }); 893 894 // Load top N keys into hot tier 895 const keysToLoad = limit ? keyMetadata.slice(0, limit) : keyMetadata; 896 897 for (const [key, metadata] of keysToLoad) { 898 const data = await this.config.tiers.warm.get(key); 899 if (data) { 900 await this.config.tiers.hot.set(key, data, metadata); 901 loaded++; 902 } 903 } 904 905 return loaded; 906 } 907 908 /** 909 * Bootstrap warm tier from cold tier. 910 * 911 * @param options - Optional limit and date filter 912 * @returns Number of items loaded 913 * 914 * @remarks 915 * Loads recent items from cold into warm. 916 * Useful for: 917 * - Initial cache population 918 * - Recovering from warm tier failure 919 * - Migrating to a new warm tier implementation 920 */ 921 async bootstrapWarm(options?: { limit?: number; sinceDate?: Date }): Promise<number> { 922 if (!this.config.tiers.warm) { 923 return 0; 924 } 925 926 let loaded = 0; 927 928 for await (const key of this.config.tiers.cold.listKeys()) { 929 const metadata = await this.config.tiers.cold.getMetadata(key); 930 if (!metadata) continue; 931 932 // Skip if too old 933 if (options?.sinceDate && metadata.lastAccessed < options.sinceDate) { 934 continue; 935 } 936 937 const data = await this.config.tiers.cold.get(key); 938 if (data) { 939 await this.config.tiers.warm.set(key, data, metadata); 940 loaded++; 941 942 if (options?.limit && loaded >= options.limit) { 943 break; 944 } 945 } 946 } 947 948 return loaded; 949 } 950 951 /** 952 * Check if data has expired based on TTL. 953 */ 954 private isExpired(metadata: StorageMetadata): boolean { 955 if (!metadata.ttl) return false; 956 return Date.now() > metadata.ttl.getTime(); 957 } 958 959 /** 960 * Update access statistics for a key. 961 */ 962 private async updateAccessStats(key: string, tier: 'hot' | 'warm' | 'cold'): Promise<void> { 963 const tierObj = 964 tier === 'hot' 965 ? this.config.tiers.hot 966 : tier === 'warm' 967 ? this.config.tiers.warm 968 : this.config.tiers.cold; 969 970 if (!tierObj) return; 971 972 const metadata = await tierObj.getMetadata(key); 973 if (metadata) { 974 metadata.lastAccessed = new Date(); 975 metadata.accessCount++; 976 await tierObj.setMetadata(key, metadata); 977 } 978 } 979 980 /** 981 * Create metadata for new data. 982 */ 983 private createMetadata(key: string, data: Uint8Array, options?: SetOptions): StorageMetadata { 984 const now = new Date(); 985 const ttl = options?.ttl ?? this.config.defaultTTL; 986 987 const metadata: StorageMetadata = { 988 key, 989 size: data.byteLength, 990 createdAt: now, 991 lastAccessed: now, 992 accessCount: 0, 993 compressed: this.config.compression ?? false, 994 checksum: calculateChecksum(data), 995 }; 996 997 if (ttl) { 998 metadata.ttl = new Date(now.getTime() + ttl); 999 } 1000 1001 if (options?.metadata) { 1002 metadata.customMetadata = options.metadata; 1003 } 1004 1005 return metadata; 1006 } 1007 1008 /** 1009 * Deserialize data, handling compression automatically. 1010 */ 1011 private async deserializeData(data: Uint8Array): Promise<unknown> { 1012 // Decompress if needed (check for gzip magic bytes) 1013 const finalData = 1014 this.config.compression && data[0] === 0x1f && data[1] === 0x8b 1015 ? await decompress(data) 1016 : data; 1017 1018 return this.deserialize(finalData); 1019 } 1020}