wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs typescript
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 } 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 136 const metadata = await this.getMetadata(key); 137 if (metadata) { 138 metadata.lastAccessed = new Date(); 139 metadata.accessCount++; 140 await this.setMetadata(key, metadata); 141 142 const entry = this.metadataIndex.get(key); 143 if (entry) { 144 entry.lastAccessed = metadata.lastAccessed; 145 } 146 } 147 148 return new Uint8Array(data); 149 } catch (error) { 150 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 151 return null; 152 } 153 throw error; 154 } 155 } 156 157 async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> { 158 const filePath = this.getFilePath(key); 159 const metaPath = this.getMetaPath(key); 160 161 const dir = dirname(filePath); 162 if (!existsSync(dir)) { 163 await mkdir(dir, { recursive: true }); 164 } 165 166 const existingEntry = this.metadataIndex.get(key); 167 if (existingEntry) { 168 this.currentSize -= existingEntry.size; 169 } 170 171 if (this.config.maxSizeBytes) { 172 await this.evictIfNeeded(data.byteLength); 173 } 174 175 const tempMetaPath = `${metaPath}.tmp`; 176 await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 177 await writeFile(filePath, data); 178 await rename(tempMetaPath, metaPath); 179 180 this.metadataIndex.set(key, { 181 size: data.byteLength, 182 createdAt: metadata.createdAt, 183 lastAccessed: metadata.lastAccessed, 184 }); 185 this.currentSize += data.byteLength; 186 } 187 188 async delete(key: string): Promise<void> { 189 const filePath = this.getFilePath(key); 190 const metaPath = this.getMetaPath(key); 191 192 const entry = this.metadataIndex.get(key); 193 if (entry) { 194 this.currentSize -= entry.size; 195 this.metadataIndex.delete(key); 196 } 197 198 await Promise.all([ 199 unlink(filePath).catch(() => {}), 200 unlink(metaPath).catch(() => {}), 201 ]); 202 } 203 204 async exists(key: string): Promise<boolean> { 205 const filePath = this.getFilePath(key); 206 return existsSync(filePath); 207 } 208 209 async *listKeys(prefix?: string): AsyncIterableIterator<string> { 210 if (!existsSync(this.config.directory)) { 211 return; 212 } 213 214 const files = await readdir(this.config.directory); 215 216 for (const file of files) { 217 // Skip metadata files 218 if (file.endsWith('.meta')) { 219 continue; 220 } 221 222 // The file name is the encoded key 223 // We need to read metadata to get the original key for prefix matching 224 const metaPath = join(this.config.directory, `${file}.meta`); 225 try { 226 const metaContent = await readFile(metaPath, 'utf-8'); 227 const metadata = JSON.parse(metaContent) as StorageMetadata; 228 const originalKey = metadata.key; 229 230 if (!prefix || originalKey.startsWith(prefix)) { 231 yield originalKey; 232 } 233 } catch { 234 // If metadata is missing or invalid, skip this file 235 continue; 236 } 237 } 238 } 239 240 async deleteMany(keys: string[]): Promise<void> { 241 await Promise.all(keys.map((key) => this.delete(key))); 242 } 243 244 async getMetadata(key: string): Promise<StorageMetadata | null> { 245 const metaPath = this.getMetaPath(key); 246 247 try { 248 const content = await readFile(metaPath, 'utf-8'); 249 const metadata = JSON.parse(content) as StorageMetadata; 250 251 // Convert date strings back to Date objects 252 metadata.createdAt = new Date(metadata.createdAt); 253 metadata.lastAccessed = new Date(metadata.lastAccessed); 254 if (metadata.ttl) { 255 metadata.ttl = new Date(metadata.ttl); 256 } 257 258 return metadata; 259 } catch (error) { 260 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 261 return null; 262 } 263 throw error; 264 } 265 } 266 267 async setMetadata(key: string, metadata: StorageMetadata): Promise<void> { 268 const metaPath = this.getMetaPath(key); 269 270 // Ensure parent directory exists 271 const dir = dirname(metaPath); 272 if (!existsSync(dir)) { 273 await mkdir(dir, { recursive: true }); 274 } 275 276 await writeFile(metaPath, JSON.stringify(metadata, null, 2)); 277 } 278 279 async getStats(): Promise<TierStats> { 280 let bytes = 0; 281 let items = 0; 282 283 if (!existsSync(this.config.directory)) { 284 return { bytes: 0, items: 0 }; 285 } 286 287 const files = await readdir(this.config.directory); 288 289 for (const file of files) { 290 if (file.endsWith('.meta')) { 291 continue; 292 } 293 294 const filePath = join(this.config.directory, file); 295 const stats = await stat(filePath); 296 bytes += stats.size; 297 items++; 298 } 299 300 return { bytes, items }; 301 } 302 303 async clear(): Promise<void> { 304 if (existsSync(this.config.directory)) { 305 await rm(this.config.directory, { recursive: true, force: true }); 306 await this.ensureDirectory(); 307 this.metadataIndex.clear(); 308 this.currentSize = 0; 309 } 310 } 311 312 /** 313 * Get the filesystem path for a key's data file. 314 */ 315 private getFilePath(key: string): string { 316 const encoded = encodeKey(key); 317 return join(this.config.directory, encoded); 318 } 319 320 /** 321 * Get the filesystem path for a key's metadata file. 322 */ 323 private getMetaPath(key: string): string { 324 return `${this.getFilePath(key)}.meta`; 325 } 326 327 private async ensureDirectory(): Promise<void> { 328 await mkdir(this.config.directory, { recursive: true }).catch(() => {}); 329 } 330 331 private async evictIfNeeded(incomingSize: number): Promise<void> { 332 if (!this.config.maxSizeBytes) { 333 return; 334 } 335 336 if (this.currentSize + incomingSize <= this.config.maxSizeBytes) { 337 return; 338 } 339 340 const entries = Array.from(this.metadataIndex.entries()).map(([key, info]) => ({ 341 key, 342 ...info, 343 })); 344 345 const policy = this.config.evictionPolicy ?? 'lru'; 346 entries.sort((a, b) => { 347 switch (policy) { 348 case 'lru': 349 return a.lastAccessed.getTime() - b.lastAccessed.getTime(); 350 case 'fifo': 351 return a.createdAt.getTime() - b.createdAt.getTime(); 352 case 'size': 353 return b.size - a.size; 354 default: 355 return 0; 356 } 357 }); 358 359 for (const entry of entries) { 360 if (this.currentSize + incomingSize <= this.config.maxSizeBytes) { 361 break; 362 } 363 364 await this.delete(entry.key); 365 } 366 } 367}