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 AllTierStats,
8 StorageSnapshot,
9} from './types/index.js';
10import { compress, decompress } from './utils/compression.js';
11import { defaultSerialize, defaultDeserialize } from './utils/serialization.js';
12import { calculateChecksum } from './utils/checksum.js';
13
14/**
15 * Main orchestrator for tiered storage system.
16 *
17 * @typeParam T - The type of data being stored
18 *
19 * @remarks
20 * Implements a cascading containment model:
21 * - **Write Strategy (Cascading Down):** Write to hot → also writes to warm and cold
22 * - **Read Strategy (Bubbling Up):** Check hot first → if miss, check warm → if miss, check cold
23 * - **Bootstrap Strategy:** Hot can bootstrap from warm, warm can bootstrap from cold
24 *
25 * The cold tier is the source of truth and is required.
26 * Hot and warm tiers are optional performance optimizations.
27 *
28 * @example
29 * ```typescript
30 * const storage = new TieredStorage({
31 * tiers: {
32 * hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), // 100MB
33 * warm: new DiskStorageTier({ directory: './cache' }),
34 * cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
35 * },
36 * compression: true,
37 * defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days
38 * promotionStrategy: 'lazy',
39 * });
40 *
41 * // Store data (cascades to all tiers)
42 * await storage.set('user:123', { name: 'Alice' });
43 *
44 * // Retrieve data (bubbles up from cold → warm → hot)
45 * const user = await storage.get('user:123');
46 *
47 * // Invalidate all keys with prefix
48 * await storage.invalidate('user:');
49 * ```
50 */
51export class TieredStorage<T = unknown> {
52 private serialize: (data: unknown) => Promise<Uint8Array>;
53 private deserialize: (data: Uint8Array) => Promise<unknown>;
54
55 constructor(private config: TieredStorageConfig) {
56 if (!config.tiers.cold) {
57 throw new Error('Cold tier is required');
58 }
59
60 this.serialize = config.serialization?.serialize ?? defaultSerialize;
61 this.deserialize = config.serialization?.deserialize ?? defaultDeserialize;
62 }
63
64 /**
65 * Retrieve data for a key.
66 *
67 * @param key - The key to retrieve
68 * @returns The data, or null if not found or expired
69 *
70 * @remarks
71 * Checks tiers in order: hot → warm → cold.
72 * On cache miss, promotes data to upper tiers based on promotionStrategy.
73 * Automatically handles decompression and deserialization.
74 * Returns null if key doesn't exist or has expired (TTL).
75 */
76 async get(key: string): Promise<T | null> {
77 const result = await this.getWithMetadata(key);
78 return result ? result.data : null;
79 }
80
81 /**
82 * Retrieve data with metadata and source tier information.
83 *
84 * @param key - The key to retrieve
85 * @returns The data, metadata, and source tier, or null if not found
86 *
87 * @remarks
88 * Use this when you need to know:
89 * - Which tier served the data (for observability)
90 * - Metadata like access count, TTL, checksum
91 * - When the data was created/last accessed
92 */
93 async getWithMetadata(key: string): Promise<StorageResult<T> | null> {
94 // 1. Check hot tier first
95 if (this.config.tiers.hot) {
96 const data = await this.config.tiers.hot.get(key);
97 if (data) {
98 const metadata = await this.config.tiers.hot.getMetadata(key);
99 if (!metadata) {
100 await this.delete(key);
101 } else if (this.isExpired(metadata)) {
102 await this.delete(key);
103 return null;
104 } else {
105 await this.updateAccessStats(key, 'hot');
106 return {
107 data: (await this.deserializeData(data)) as T,
108 metadata,
109 source: 'hot',
110 };
111 }
112 }
113 }
114
115 // 2. Check warm tier
116 if (this.config.tiers.warm) {
117 const data = await this.config.tiers.warm.get(key);
118 if (data) {
119 const metadata = await this.config.tiers.warm.getMetadata(key);
120 if (!metadata) {
121 await this.delete(key);
122 } else if (this.isExpired(metadata)) {
123 await this.delete(key);
124 return null;
125 } else {
126 if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') {
127 await this.config.tiers.hot.set(key, data, metadata);
128 }
129
130 await this.updateAccessStats(key, 'warm');
131 return {
132 data: (await this.deserializeData(data)) as T,
133 metadata,
134 source: 'warm',
135 };
136 }
137 }
138 }
139
140 // 3. Check cold tier (source of truth)
141 const data = await this.config.tiers.cold.get(key);
142 if (data) {
143 const metadata = await this.config.tiers.cold.getMetadata(key);
144 if (!metadata) {
145 await this.config.tiers.cold.delete(key);
146 return null;
147 }
148
149 if (this.isExpired(metadata)) {
150 await this.delete(key);
151 return null;
152 }
153
154 // Promote to warm and hot (if configured)
155 if (this.config.promotionStrategy === 'eager') {
156 if (this.config.tiers.warm) {
157 await this.config.tiers.warm.set(key, data, metadata);
158 }
159 if (this.config.tiers.hot) {
160 await this.config.tiers.hot.set(key, data, metadata);
161 }
162 }
163
164 await this.updateAccessStats(key, 'cold');
165 return {
166 data: (await this.deserializeData(data)) as T,
167 metadata,
168 source: 'cold',
169 };
170 }
171
172 return null;
173 }
174
175 /**
176 * Store data with optional configuration.
177 *
178 * @param key - The key to store under
179 * @param data - The data to store
180 * @param options - Optional configuration (TTL, metadata, tier skipping)
181 * @returns Information about what was stored and where
182 *
183 * @remarks
184 * Data cascades down through tiers:
185 * - If written to hot, also written to warm and cold
186 * - If written to warm (hot skipped), also written to cold
187 * - Cold is always written (source of truth)
188 *
189 * Use `skipTiers` to control placement. For example:
190 * - Large files: `skipTiers: ['hot']` to avoid memory bloat
191 * - Critical small files: Write to all tiers for fastest access
192 *
193 * Automatically handles serialization and optional compression.
194 */
195 async set(key: string, data: T, options?: SetOptions): Promise<SetResult> {
196 // 1. Serialize data
197 const serialized = await this.serialize(data);
198
199 // 2. Optionally compress
200 const finalData = this.config.compression ? await compress(serialized) : serialized;
201
202 // 3. Create metadata
203 const metadata = this.createMetadata(key, finalData, options);
204
205 // 4. Write to all tiers (cascading down)
206 const tiersWritten: ('hot' | 'warm' | 'cold')[] = [];
207
208 // Write to hot (if configured and not skipped)
209 if (this.config.tiers.hot && !options?.skipTiers?.includes('hot')) {
210 await this.config.tiers.hot.set(key, finalData, metadata);
211 tiersWritten.push('hot');
212
213 // Hot writes cascade to warm
214 if (this.config.tiers.warm && !options?.skipTiers?.includes('warm')) {
215 await this.config.tiers.warm.set(key, finalData, metadata);
216 tiersWritten.push('warm');
217 }
218 } else if (this.config.tiers.warm && !options?.skipTiers?.includes('warm')) {
219 // Write to warm (if hot skipped)
220 await this.config.tiers.warm.set(key, finalData, metadata);
221 tiersWritten.push('warm');
222 }
223
224 // Always write to cold (source of truth)
225 await this.config.tiers.cold.set(key, finalData, metadata);
226 tiersWritten.push('cold');
227
228 return { key, metadata, tiersWritten };
229 }
230
231 /**
232 * Delete data from all tiers.
233 *
234 * @param key - The key to delete
235 *
236 * @remarks
237 * Deletes from all configured tiers in parallel.
238 * Does not throw if the key doesn't exist.
239 */
240 async delete(key: string): Promise<void> {
241 await Promise.all([
242 this.config.tiers.hot?.delete(key),
243 this.config.tiers.warm?.delete(key),
244 this.config.tiers.cold.delete(key),
245 ]);
246 }
247
248 /**
249 * Check if a key exists in any tier.
250 *
251 * @param key - The key to check
252 * @returns true if the key exists and hasn't expired
253 *
254 * @remarks
255 * Checks tiers in order: hot → warm → cold.
256 * Returns false if key exists but has expired.
257 */
258 async exists(key: string): Promise<boolean> {
259 // Check hot first (fastest)
260 if (this.config.tiers.hot && (await this.config.tiers.hot.exists(key))) {
261 const metadata = await this.config.tiers.hot.getMetadata(key);
262 if (metadata && !this.isExpired(metadata)) {
263 return true;
264 }
265 }
266
267 // Check warm
268 if (this.config.tiers.warm && (await this.config.tiers.warm.exists(key))) {
269 const metadata = await this.config.tiers.warm.getMetadata(key);
270 if (metadata && !this.isExpired(metadata)) {
271 return true;
272 }
273 }
274
275 // Check cold (source of truth)
276 if (await this.config.tiers.cold.exists(key)) {
277 const metadata = await this.config.tiers.cold.getMetadata(key);
278 if (metadata && !this.isExpired(metadata)) {
279 return true;
280 }
281 }
282
283 return false;
284 }
285
286 /**
287 * Renew TTL for a key.
288 *
289 * @param key - The key to touch
290 * @param ttlMs - Optional new TTL in milliseconds (uses default if not provided)
291 *
292 * @remarks
293 * Updates the TTL and lastAccessed timestamp in all tiers.
294 * Useful for implementing "keep alive" behavior for actively used keys.
295 * Does nothing if no TTL is configured.
296 */
297 async touch(key: string, ttlMs?: number): Promise<void> {
298 const ttl = ttlMs ?? this.config.defaultTTL;
299 if (!ttl) return;
300
301 const newTTL = new Date(Date.now() + ttl);
302
303 for (const tier of [this.config.tiers.hot, this.config.tiers.warm, this.config.tiers.cold]) {
304 if (!tier) continue;
305
306 const metadata = await tier.getMetadata(key);
307 if (metadata) {
308 metadata.ttl = newTTL;
309 metadata.lastAccessed = new Date();
310 await tier.setMetadata(key, metadata);
311 }
312 }
313 }
314
315 /**
316 * Invalidate all keys matching a prefix.
317 *
318 * @param prefix - The prefix to match (e.g., 'user:' matches 'user:123', 'user:456')
319 * @returns Number of keys deleted
320 *
321 * @remarks
322 * Useful for bulk invalidation:
323 * - Site invalidation: `invalidate('site:abc:')`
324 * - User invalidation: `invalidate('user:123:')`
325 * - Global invalidation: `invalidate('')` (deletes everything)
326 *
327 * Deletes from all tiers in parallel for efficiency.
328 */
329 async invalidate(prefix: string): Promise<number> {
330 const keysToDelete = new Set<string>();
331
332 // Collect all keys matching prefix from all tiers
333 if (this.config.tiers.hot) {
334 for await (const key of this.config.tiers.hot.listKeys(prefix)) {
335 keysToDelete.add(key);
336 }
337 }
338
339 if (this.config.tiers.warm) {
340 for await (const key of this.config.tiers.warm.listKeys(prefix)) {
341 keysToDelete.add(key);
342 }
343 }
344
345 for await (const key of this.config.tiers.cold.listKeys(prefix)) {
346 keysToDelete.add(key);
347 }
348
349 // Delete from all tiers in parallel
350 const keys = Array.from(keysToDelete);
351
352 await Promise.all([
353 this.config.tiers.hot?.deleteMany(keys),
354 this.config.tiers.warm?.deleteMany(keys),
355 this.config.tiers.cold.deleteMany(keys),
356 ]);
357
358 return keys.length;
359 }
360
361 /**
362 * List all keys, optionally filtered by prefix.
363 *
364 * @param prefix - Optional prefix to filter keys
365 * @returns Async iterator of keys
366 *
367 * @remarks
368 * Returns keys from the cold tier (source of truth).
369 * Memory-efficient - streams keys rather than loading all into memory.
370 *
371 * @example
372 * ```typescript
373 * for await (const key of storage.listKeys('user:')) {
374 * console.log(key);
375 * }
376 * ```
377 */
378 async *listKeys(prefix?: string): AsyncIterableIterator<string> {
379 // List from cold tier (source of truth)
380 for await (const key of this.config.tiers.cold.listKeys(prefix)) {
381 yield key;
382 }
383 }
384
385 /**
386 * Get aggregated statistics across all tiers.
387 *
388 * @returns Statistics including size, item count, hits, misses, hit rate
389 *
390 * @remarks
391 * Useful for monitoring and capacity planning.
392 * Hit rate is calculated as: hits / (hits + misses).
393 */
394 async getStats(): Promise<AllTierStats> {
395 const [hot, warm, cold] = await Promise.all([
396 this.config.tiers.hot?.getStats(),
397 this.config.tiers.warm?.getStats(),
398 this.config.tiers.cold.getStats(),
399 ]);
400
401 const totalHits = (hot?.hits ?? 0) + (warm?.hits ?? 0) + (cold?.hits ?? 0);
402 const totalMisses = (hot?.misses ?? 0) + (warm?.misses ?? 0) + (cold?.misses ?? 0);
403 const hitRate = totalHits + totalMisses > 0 ? totalHits / (totalHits + totalMisses) : 0;
404
405 return {
406 ...(hot && { hot }),
407 ...(warm && { warm }),
408 cold,
409 totalHits,
410 totalMisses,
411 hitRate,
412 };
413 }
414
415 /**
416 * Clear all data from all tiers.
417 *
418 * @remarks
419 * Use with extreme caution! This will delete all data in the entire storage system.
420 * Cannot be undone.
421 */
422 async clear(): Promise<void> {
423 await Promise.all([
424 this.config.tiers.hot?.clear(),
425 this.config.tiers.warm?.clear(),
426 this.config.tiers.cold.clear(),
427 ]);
428 }
429
430 /**
431 * Clear a specific tier.
432 *
433 * @param tier - Which tier to clear
434 *
435 * @remarks
436 * Useful for:
437 * - Clearing hot tier to test warm/cold performance
438 * - Clearing warm tier to force rebuilding from cold
439 * - Clearing cold tier to start fresh (⚠️ loses source of truth!)
440 */
441 async clearTier(tier: 'hot' | 'warm' | 'cold'): Promise<void> {
442 switch (tier) {
443 case 'hot':
444 await this.config.tiers.hot?.clear();
445 break;
446 case 'warm':
447 await this.config.tiers.warm?.clear();
448 break;
449 case 'cold':
450 await this.config.tiers.cold.clear();
451 break;
452 }
453 }
454
455 /**
456 * Export metadata snapshot for backup or migration.
457 *
458 * @returns Snapshot containing all keys, metadata, and statistics
459 *
460 * @remarks
461 * The snapshot includes metadata but not the actual data (data remains in tiers).
462 * Useful for:
463 * - Backup and restore
464 * - Migration between storage systems
465 * - Auditing and compliance
466 */
467 async export(): Promise<StorageSnapshot> {
468 const keys: string[] = [];
469 const metadata: Record<string, StorageMetadata> = {};
470
471 // Export from cold tier (source of truth)
472 for await (const key of this.config.tiers.cold.listKeys()) {
473 keys.push(key);
474 const meta = await this.config.tiers.cold.getMetadata(key);
475 if (meta) {
476 metadata[key] = meta;
477 }
478 }
479
480 const stats = await this.getStats();
481
482 return {
483 version: 1,
484 exportedAt: new Date(),
485 keys,
486 metadata,
487 stats,
488 };
489 }
490
491 /**
492 * Import metadata snapshot.
493 *
494 * @param snapshot - Snapshot to import
495 *
496 * @remarks
497 * Validates version compatibility before importing.
498 * Only imports metadata - assumes data already exists in cold tier.
499 */
500 async import(snapshot: StorageSnapshot): Promise<void> {
501 if (snapshot.version !== 1) {
502 throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
503 }
504
505 // Import metadata into all configured tiers
506 for (const key of snapshot.keys) {
507 const metadata = snapshot.metadata[key];
508 if (!metadata) continue;
509
510 if (this.config.tiers.hot) {
511 await this.config.tiers.hot.setMetadata(key, metadata);
512 }
513
514 if (this.config.tiers.warm) {
515 await this.config.tiers.warm.setMetadata(key, metadata);
516 }
517
518 await this.config.tiers.cold.setMetadata(key, metadata);
519 }
520 }
521
522 /**
523 * Bootstrap hot tier from warm tier.
524 *
525 * @param limit - Optional limit on number of items to load
526 * @returns Number of items loaded
527 *
528 * @remarks
529 * Loads the most frequently accessed items from warm into hot.
530 * Useful for warming up the cache after a restart.
531 * Items are sorted by: accessCount * lastAccessed timestamp (higher is better).
532 */
533 async bootstrapHot(limit?: number): Promise<number> {
534 if (!this.config.tiers.hot || !this.config.tiers.warm) {
535 return 0;
536 }
537
538 let loaded = 0;
539 const keyMetadata: Array<[string, StorageMetadata]> = [];
540
541 // Load metadata for all keys
542 for await (const key of this.config.tiers.warm.listKeys()) {
543 const metadata = await this.config.tiers.warm.getMetadata(key);
544 if (metadata) {
545 keyMetadata.push([key, metadata]);
546 }
547 }
548
549 // Sort by access count * recency (simple scoring)
550 keyMetadata.sort((a, b) => {
551 const scoreA = a[1].accessCount * a[1].lastAccessed.getTime();
552 const scoreB = b[1].accessCount * b[1].lastAccessed.getTime();
553 return scoreB - scoreA;
554 });
555
556 // Load top N keys into hot tier
557 const keysToLoad = limit ? keyMetadata.slice(0, limit) : keyMetadata;
558
559 for (const [key, metadata] of keysToLoad) {
560 const data = await this.config.tiers.warm.get(key);
561 if (data) {
562 await this.config.tiers.hot.set(key, data, metadata);
563 loaded++;
564 }
565 }
566
567 return loaded;
568 }
569
570 /**
571 * Bootstrap warm tier from cold tier.
572 *
573 * @param options - Optional limit and date filter
574 * @returns Number of items loaded
575 *
576 * @remarks
577 * Loads recent items from cold into warm.
578 * Useful for:
579 * - Initial cache population
580 * - Recovering from warm tier failure
581 * - Migrating to a new warm tier implementation
582 */
583 async bootstrapWarm(options?: { limit?: number; sinceDate?: Date }): Promise<number> {
584 if (!this.config.tiers.warm) {
585 return 0;
586 }
587
588 let loaded = 0;
589
590 for await (const key of this.config.tiers.cold.listKeys()) {
591 const metadata = await this.config.tiers.cold.getMetadata(key);
592 if (!metadata) continue;
593
594 // Skip if too old
595 if (options?.sinceDate && metadata.lastAccessed < options.sinceDate) {
596 continue;
597 }
598
599 const data = await this.config.tiers.cold.get(key);
600 if (data) {
601 await this.config.tiers.warm.set(key, data, metadata);
602 loaded++;
603
604 if (options?.limit && loaded >= options.limit) {
605 break;
606 }
607 }
608 }
609
610 return loaded;
611 }
612
613 /**
614 * Check if data has expired based on TTL.
615 */
616 private isExpired(metadata: StorageMetadata): boolean {
617 if (!metadata.ttl) return false;
618 return Date.now() > metadata.ttl.getTime();
619 }
620
621 /**
622 * Update access statistics for a key.
623 */
624 private async updateAccessStats(key: string, tier: 'hot' | 'warm' | 'cold'): Promise<void> {
625 const tierObj =
626 tier === 'hot'
627 ? this.config.tiers.hot
628 : tier === 'warm'
629 ? this.config.tiers.warm
630 : this.config.tiers.cold;
631
632 if (!tierObj) return;
633
634 const metadata = await tierObj.getMetadata(key);
635 if (metadata) {
636 metadata.lastAccessed = new Date();
637 metadata.accessCount++;
638 await tierObj.setMetadata(key, metadata);
639 }
640 }
641
642 /**
643 * Create metadata for new data.
644 */
645 private createMetadata(key: string, data: Uint8Array, options?: SetOptions): StorageMetadata {
646 const now = new Date();
647 const ttl = options?.ttl ?? this.config.defaultTTL;
648
649 const metadata: StorageMetadata = {
650 key,
651 size: data.byteLength,
652 createdAt: now,
653 lastAccessed: now,
654 accessCount: 0,
655 compressed: this.config.compression ?? false,
656 checksum: calculateChecksum(data),
657 };
658
659 if (ttl) {
660 metadata.ttl = new Date(now.getTime() + ttl);
661 }
662
663 if (options?.metadata) {
664 metadata.customMetadata = options.metadata;
665 }
666
667 return metadata;
668 }
669
670 /**
671 * Deserialize data, handling compression automatically.
672 */
673 private async deserializeData(data: Uint8Array): Promise<unknown> {
674 // Decompress if needed (check for gzip magic bytes)
675 const finalData =
676 this.config.compression && data[0] === 0x1f && data[1] === 0x8b
677 ? await decompress(data)
678 : data;
679
680 return this.deserialize(finalData);
681 }
682}