wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs
typescript
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}