wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs typescript
at main 11 kB view raw
1import { readFile, writeFile, unlink, readdir, stat, mkdir, rm, rename } from 'node:fs/promises'; 2import { existsSync } from 'node:fs'; 3import { join, dirname } from 'node:path'; 4import type { StorageTier, StorageMetadata, TierStats, TierGetResult } from '../types/index.js'; 5import { encodeKey } from '../utils/path-encoding.js'; 6 7/** 8 * Eviction policy for disk tier when size limit is reached. 9 */ 10export type EvictionPolicy = 'lru' | 'fifo' | 'size'; 11 12/** 13 * Configuration for DiskStorageTier. 14 */ 15export interface DiskStorageTierConfig { 16 /** 17 * Directory path where files will be stored. 18 * 19 * @remarks 20 * Created automatically if it doesn't exist. 21 * Files are stored as: `{directory}/{encoded-key}` 22 * Metadata is stored as: `{directory}/{encoded-key}.meta` 23 */ 24 directory: string; 25 26 /** 27 * Optional maximum size in bytes. 28 * 29 * @remarks 30 * When this limit is reached, files are evicted according to the eviction policy. 31 * If not set, no size limit is enforced (grows unbounded). 32 */ 33 maxSizeBytes?: number; 34 35 /** 36 * Eviction policy when maxSizeBytes is reached. 37 * 38 * @defaultValue 'lru' 39 * 40 * @remarks 41 * - 'lru': Evict least-recently-accessed files (based on metadata.lastAccessed) 42 * - 'fifo': Evict oldest files (based on metadata.createdAt) 43 * - 'size': Evict largest files first 44 */ 45 evictionPolicy?: EvictionPolicy; 46} 47 48/** 49 * Filesystem-based storage tier. 50 * 51 * @remarks 52 * - Stores data files and `.meta` JSON files side-by-side 53 * - Keys are encoded to be filesystem-safe 54 * - Human-readable file structure for debugging 55 * - Optional size-based eviction with configurable policy 56 * - Zero external dependencies (uses Node.js fs APIs) 57 * 58 * File structure: 59 * ``` 60 * cache/ 61 * ├── user%3A123 # Data file (encoded key) 62 * ├── user%3A123.meta # Metadata JSON 63 * ├── site%3Aabc%2Findex.html 64 * └── site%3Aabc%2Findex.html.meta 65 * ``` 66 * 67 * @example 68 * ```typescript 69 * const tier = new DiskStorageTier({ 70 * directory: './cache', 71 * maxSizeBytes: 10 * 1024 * 1024 * 1024, // 10GB 72 * evictionPolicy: 'lru', 73 * }); 74 * 75 * await tier.set('key', data, metadata); 76 * const retrieved = await tier.get('key'); 77 * ``` 78 */ 79export class DiskStorageTier implements StorageTier { 80 private metadataIndex = new Map< 81 string, 82 { size: number; createdAt: Date; lastAccessed: Date } 83 >(); 84 private currentSize = 0; 85 86 constructor(private config: DiskStorageTierConfig) { 87 if (!config.directory) { 88 throw new Error('directory is required'); 89 } 90 if (config.maxSizeBytes !== undefined && config.maxSizeBytes <= 0) { 91 throw new Error('maxSizeBytes must be positive'); 92 } 93 94 void this.ensureDirectory(); 95 void this.rebuildIndex(); 96 } 97 98 private async rebuildIndex(): Promise<void> { 99 if (!existsSync(this.config.directory)) { 100 return; 101 } 102 103 const files = await readdir(this.config.directory); 104 105 for (const file of files) { 106 if (file.endsWith('.meta')) { 107 continue; 108 } 109 110 try { 111 const metaPath = join(this.config.directory, `${file}.meta`); 112 const metaContent = await readFile(metaPath, 'utf-8'); 113 const metadata = JSON.parse(metaContent) as StorageMetadata; 114 const filePath = join(this.config.directory, file); 115 const fileStats = await stat(filePath); 116 117 this.metadataIndex.set(metadata.key, { 118 size: fileStats.size, 119 createdAt: new Date(metadata.createdAt), 120 lastAccessed: new Date(metadata.lastAccessed), 121 }); 122 123 this.currentSize += fileStats.size; 124 } catch { 125 continue; 126 } 127 } 128 } 129 130 async get(key: string): Promise<Uint8Array | null> { 131 const filePath = this.getFilePath(key); 132 133 try { 134 const data = await readFile(filePath); 135 return new Uint8Array(data); 136 } catch (error) { 137 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 138 return null; 139 } 140 throw error; 141 } 142 } 143 144 /** 145 * Retrieve data and metadata together in a single operation. 146 * 147 * @param key - The key to retrieve 148 * @returns The data and metadata, or null if not found 149 * 150 * @remarks 151 * Reads data and metadata files in parallel for better performance. 152 */ 153 async getWithMetadata(key: string): Promise<TierGetResult | null> { 154 const filePath = this.getFilePath(key); 155 const metaPath = this.getMetaPath(key); 156 157 try { 158 // Read data and metadata in parallel 159 const [dataBuffer, metaContent] = await Promise.all([ 160 readFile(filePath), 161 readFile(metaPath, 'utf-8'), 162 ]); 163 164 const metadata = JSON.parse(metaContent) as StorageMetadata; 165 166 // Convert date strings back to Date objects 167 metadata.createdAt = new Date(metadata.createdAt); 168 metadata.lastAccessed = new Date(metadata.lastAccessed); 169 if (metadata.ttl) { 170 metadata.ttl = new Date(metadata.ttl); 171 } 172 173 return { data: new Uint8Array(dataBuffer), metadata }; 174 } catch (error) { 175 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 176 return null; 177 } 178 throw error; 179 } 180 } 181 182 async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> { 183 const filePath = this.getFilePath(key); 184 const metaPath = this.getMetaPath(key); 185 186 const dir = dirname(filePath); 187 if (!existsSync(dir)) { 188 await mkdir(dir, { recursive: true }); 189 } 190 191 const existingEntry = this.metadataIndex.get(key); 192 if (existingEntry) { 193 this.currentSize -= existingEntry.size; 194 } 195 196 if (this.config.maxSizeBytes) { 197 await this.evictIfNeeded(data.byteLength); 198 } 199 200 const tempMetaPath = `${metaPath}.tmp`; 201 await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 202 await writeFile(filePath, data); 203 await rename(tempMetaPath, metaPath); 204 205 this.metadataIndex.set(key, { 206 size: data.byteLength, 207 createdAt: metadata.createdAt, 208 lastAccessed: metadata.lastAccessed, 209 }); 210 this.currentSize += data.byteLength; 211 } 212 213 async delete(key: string): Promise<void> { 214 const filePath = this.getFilePath(key); 215 const metaPath = this.getMetaPath(key); 216 217 const entry = this.metadataIndex.get(key); 218 if (entry) { 219 this.currentSize -= entry.size; 220 this.metadataIndex.delete(key); 221 } 222 223 await Promise.all([ 224 unlink(filePath).catch(() => {}), 225 unlink(metaPath).catch(() => {}), 226 ]); 227 } 228 229 async exists(key: string): Promise<boolean> { 230 const filePath = this.getFilePath(key); 231 return existsSync(filePath); 232 } 233 234 async *listKeys(prefix?: string): AsyncIterableIterator<string> { 235 if (!existsSync(this.config.directory)) { 236 return; 237 } 238 239 const files = await readdir(this.config.directory); 240 241 for (const file of files) { 242 // Skip metadata files 243 if (file.endsWith('.meta')) { 244 continue; 245 } 246 247 // The file name is the encoded key 248 // We need to read metadata to get the original key for prefix matching 249 const metaPath = join(this.config.directory, `${file}.meta`); 250 try { 251 const metaContent = await readFile(metaPath, 'utf-8'); 252 const metadata = JSON.parse(metaContent) as StorageMetadata; 253 const originalKey = metadata.key; 254 255 if (!prefix || originalKey.startsWith(prefix)) { 256 yield originalKey; 257 } 258 } catch { 259 // If metadata is missing or invalid, skip this file 260 continue; 261 } 262 } 263 } 264 265 async deleteMany(keys: string[]): Promise<void> { 266 await Promise.all(keys.map((key) => this.delete(key))); 267 } 268 269 async getMetadata(key: string): Promise<StorageMetadata | null> { 270 const metaPath = this.getMetaPath(key); 271 272 try { 273 const content = await readFile(metaPath, 'utf-8'); 274 const metadata = JSON.parse(content) as StorageMetadata; 275 276 // Convert date strings back to Date objects 277 metadata.createdAt = new Date(metadata.createdAt); 278 metadata.lastAccessed = new Date(metadata.lastAccessed); 279 if (metadata.ttl) { 280 metadata.ttl = new Date(metadata.ttl); 281 } 282 283 return metadata; 284 } catch (error) { 285 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 286 return null; 287 } 288 throw error; 289 } 290 } 291 292 async setMetadata(key: string, metadata: StorageMetadata): Promise<void> { 293 const metaPath = this.getMetaPath(key); 294 295 // Ensure parent directory exists 296 const dir = dirname(metaPath); 297 if (!existsSync(dir)) { 298 await mkdir(dir, { recursive: true }); 299 } 300 301 await writeFile(metaPath, JSON.stringify(metadata, null, 2)); 302 } 303 304 async getStats(): Promise<TierStats> { 305 let bytes = 0; 306 let items = 0; 307 308 if (!existsSync(this.config.directory)) { 309 return { bytes: 0, items: 0 }; 310 } 311 312 const files = await readdir(this.config.directory); 313 314 for (const file of files) { 315 if (file.endsWith('.meta')) { 316 continue; 317 } 318 319 const filePath = join(this.config.directory, file); 320 const stats = await stat(filePath); 321 bytes += stats.size; 322 items++; 323 } 324 325 return { bytes, items }; 326 } 327 328 async clear(): Promise<void> { 329 if (existsSync(this.config.directory)) { 330 await rm(this.config.directory, { recursive: true, force: true }); 331 await this.ensureDirectory(); 332 this.metadataIndex.clear(); 333 this.currentSize = 0; 334 } 335 } 336 337 /** 338 * Get the filesystem path for a key's data file. 339 */ 340 private getFilePath(key: string): string { 341 const encoded = encodeKey(key); 342 return join(this.config.directory, encoded); 343 } 344 345 /** 346 * Get the filesystem path for a key's metadata file. 347 */ 348 private getMetaPath(key: string): string { 349 return `${this.getFilePath(key)}.meta`; 350 } 351 352 private async ensureDirectory(): Promise<void> { 353 await mkdir(this.config.directory, { recursive: true }).catch(() => {}); 354 } 355 356 private async evictIfNeeded(incomingSize: number): Promise<void> { 357 if (!this.config.maxSizeBytes) { 358 return; 359 } 360 361 if (this.currentSize + incomingSize <= this.config.maxSizeBytes) { 362 return; 363 } 364 365 const entries = Array.from(this.metadataIndex.entries()).map(([key, info]) => ({ 366 key, 367 ...info, 368 })); 369 370 const policy = this.config.evictionPolicy ?? 'lru'; 371 entries.sort((a, b) => { 372 switch (policy) { 373 case 'lru': 374 return a.lastAccessed.getTime() - b.lastAccessed.getTime(); 375 case 'fifo': 376 return a.createdAt.getTime() - b.createdAt.getTime(); 377 case 'size': 378 return b.size - a.size; 379 default: 380 return 0; 381 } 382 }); 383 384 for (const entry of entries) { 385 if (this.currentSize + incomingSize <= this.config.maxSizeBytes) { 386 break; 387 } 388 389 await this.delete(entry.key); 390 } 391 } 392}