+622
-622
src/TieredStorage.ts
+622
-622
src/TieredStorage.ts
···
1
1
import type {
2
-
TieredStorageConfig,
3
-
SetOptions,
4
-
StorageResult,
5
-
SetResult,
6
-
StorageMetadata,
7
-
StorageTier,
8
-
AllTierStats,
9
-
StorageSnapshot,
10
-
PlacementRule,
2
+
TieredStorageConfig,
3
+
SetOptions,
4
+
StorageResult,
5
+
SetResult,
6
+
StorageMetadata,
7
+
StorageTier,
8
+
AllTierStats,
9
+
StorageSnapshot,
10
+
PlacementRule,
11
11
} from './types/index';
12
12
import { compress, decompress } from './utils/compression.js';
13
13
import { defaultSerialize, defaultDeserialize } from './utils/serialization.js';
···
31
31
* @example
32
32
* ```typescript
33
33
* const storage = new TieredStorage({
34
-
* tiers: {
35
-
* hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), // 100MB
36
-
* warm: new DiskStorageTier({ directory: './cache' }),
37
-
* cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
38
-
* },
39
-
* compression: true,
40
-
* defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days
41
-
* promotionStrategy: 'lazy',
34
+
* tiers: {
35
+
* hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), // 100MB
36
+
* warm: new DiskStorageTier({ directory: './cache' }),
37
+
* cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
38
+
* },
39
+
* compression: true,
40
+
* defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days
41
+
* promotionStrategy: 'lazy',
42
42
* });
43
43
*
44
44
* // Store data (cascades to all tiers)
···
52
52
* ```
53
53
*/
54
54
export class TieredStorage<T = unknown> {
55
-
private serialize: (data: unknown) => Promise<Uint8Array>;
56
-
private deserialize: (data: Uint8Array) => Promise<unknown>;
55
+
private serialize: (data: unknown) => Promise<Uint8Array>;
56
+
private deserialize: (data: Uint8Array) => Promise<unknown>;
57
57
58
-
constructor(private config: TieredStorageConfig) {
59
-
if (!config.tiers.cold) {
60
-
throw new Error('Cold tier is required');
61
-
}
58
+
constructor(private config: TieredStorageConfig) {
59
+
if (!config.tiers.cold) {
60
+
throw new Error('Cold tier is required');
61
+
}
62
62
63
-
this.serialize = config.serialization?.serialize ?? defaultSerialize;
64
-
this.deserialize = config.serialization?.deserialize ?? defaultDeserialize;
65
-
}
63
+
this.serialize = config.serialization?.serialize ?? defaultSerialize;
64
+
this.deserialize = config.serialization?.deserialize ?? defaultDeserialize;
65
+
}
66
66
67
-
/**
68
-
* Retrieve data for a key.
69
-
*
70
-
* @param key - The key to retrieve
71
-
* @returns The data, or null if not found or expired
72
-
*
73
-
* @remarks
74
-
* Checks tiers in order: hot → warm → cold.
75
-
* On cache miss, promotes data to upper tiers based on promotionStrategy.
76
-
* Automatically handles decompression and deserialization.
77
-
* Returns null if key doesn't exist or has expired (TTL).
78
-
*/
79
-
async get(key: string): Promise<T | null> {
80
-
const result = await this.getWithMetadata(key);
81
-
return result ? result.data : null;
82
-
}
67
+
/**
68
+
* Retrieve data for a key.
69
+
*
70
+
* @param key - The key to retrieve
71
+
* @returns The data, or null if not found or expired
72
+
*
73
+
* @remarks
74
+
* Checks tiers in order: hot → warm → cold.
75
+
* On cache miss, promotes data to upper tiers based on promotionStrategy.
76
+
* Automatically handles decompression and deserialization.
77
+
* Returns null if key doesn't exist or has expired (TTL).
78
+
*/
79
+
async get(key: string): Promise<T | null> {
80
+
const result = await this.getWithMetadata(key);
81
+
return result ? result.data : null;
82
+
}
83
83
84
-
/**
85
-
* Retrieve data with metadata and source tier information.
86
-
*
87
-
* @param key - The key to retrieve
88
-
* @returns The data, metadata, and source tier, or null if not found
89
-
*
90
-
* @remarks
91
-
* Use this when you need to know:
92
-
* - Which tier served the data (for observability)
93
-
* - Metadata like access count, TTL, checksum
94
-
* - When the data was created/last accessed
95
-
*/
96
-
async getWithMetadata(key: string): Promise<StorageResult<T> | null> {
97
-
// 1. Check hot tier first
98
-
if (this.config.tiers.hot) {
99
-
const result = await this.getFromTier(this.config.tiers.hot, key);
100
-
if (result) {
101
-
if (this.isExpired(result.metadata)) {
102
-
await this.delete(key);
103
-
return null;
104
-
}
105
-
// Fire-and-forget access stats update (non-critical)
106
-
void this.updateAccessStats(key, 'hot');
107
-
return {
108
-
data: (await this.deserializeData(result.data)) as T,
109
-
metadata: result.metadata,
110
-
source: 'hot',
111
-
};
112
-
}
113
-
}
84
+
/**
85
+
* Retrieve data with metadata and source tier information.
86
+
*
87
+
* @param key - The key to retrieve
88
+
* @returns The data, metadata, and source tier, or null if not found
89
+
*
90
+
* @remarks
91
+
* Use this when you need to know:
92
+
* - Which tier served the data (for observability)
93
+
* - Metadata like access count, TTL, checksum
94
+
* - When the data was created/last accessed
95
+
*/
96
+
async getWithMetadata(key: string): Promise<StorageResult<T> | null> {
97
+
// 1. Check hot tier first
98
+
if (this.config.tiers.hot) {
99
+
const result = await this.getFromTier(this.config.tiers.hot, key);
100
+
if (result) {
101
+
if (this.isExpired(result.metadata)) {
102
+
await this.delete(key);
103
+
return null;
104
+
}
105
+
// Fire-and-forget access stats update (non-critical)
106
+
void this.updateAccessStats(key, 'hot');
107
+
return {
108
+
data: (await this.deserializeData(result.data)) as T,
109
+
metadata: result.metadata,
110
+
source: 'hot',
111
+
};
112
+
}
113
+
}
114
114
115
-
// 2. Check warm tier
116
-
if (this.config.tiers.warm) {
117
-
const result = await this.getFromTier(this.config.tiers.warm, key);
118
-
if (result) {
119
-
if (this.isExpired(result.metadata)) {
120
-
await this.delete(key);
121
-
return null;
122
-
}
123
-
// Eager promotion to hot tier (awaited - guaranteed to complete)
124
-
if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') {
125
-
await this.config.tiers.hot.set(key, result.data, result.metadata);
126
-
}
127
-
// Fire-and-forget access stats update (non-critical)
128
-
void this.updateAccessStats(key, 'warm');
129
-
return {
130
-
data: (await this.deserializeData(result.data)) as T,
131
-
metadata: result.metadata,
132
-
source: 'warm',
133
-
};
134
-
}
135
-
}
115
+
// 2. Check warm tier
116
+
if (this.config.tiers.warm) {
117
+
const result = await this.getFromTier(this.config.tiers.warm, key);
118
+
if (result) {
119
+
if (this.isExpired(result.metadata)) {
120
+
await this.delete(key);
121
+
return null;
122
+
}
123
+
// Eager promotion to hot tier (awaited - guaranteed to complete)
124
+
if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') {
125
+
await this.config.tiers.hot.set(key, result.data, result.metadata);
126
+
}
127
+
// Fire-and-forget access stats update (non-critical)
128
+
void this.updateAccessStats(key, 'warm');
129
+
return {
130
+
data: (await this.deserializeData(result.data)) as T,
131
+
metadata: result.metadata,
132
+
source: 'warm',
133
+
};
134
+
}
135
+
}
136
136
137
-
// 3. Check cold tier (source of truth)
138
-
const result = await this.getFromTier(this.config.tiers.cold, key);
139
-
if (result) {
140
-
if (this.isExpired(result.metadata)) {
141
-
await this.delete(key);
142
-
return null;
143
-
}
137
+
// 3. Check cold tier (source of truth)
138
+
const result = await this.getFromTier(this.config.tiers.cold, key);
139
+
if (result) {
140
+
if (this.isExpired(result.metadata)) {
141
+
await this.delete(key);
142
+
return null;
143
+
}
144
144
145
-
// Promote to warm and hot (if configured)
146
-
// Eager promotion is awaited to guarantee completion
147
-
if (this.config.promotionStrategy === 'eager') {
148
-
const promotions: Promise<void>[] = [];
149
-
if (this.config.tiers.warm) {
150
-
promotions.push(this.config.tiers.warm.set(key, result.data, result.metadata));
151
-
}
152
-
if (this.config.tiers.hot) {
153
-
promotions.push(this.config.tiers.hot.set(key, result.data, result.metadata));
154
-
}
155
-
await Promise.all(promotions);
156
-
}
145
+
// Promote to warm and hot (if configured)
146
+
// Eager promotion is awaited to guarantee completion
147
+
if (this.config.promotionStrategy === 'eager') {
148
+
const promotions: Promise<void>[] = [];
149
+
if (this.config.tiers.warm) {
150
+
promotions.push(this.config.tiers.warm.set(key, result.data, result.metadata));
151
+
}
152
+
if (this.config.tiers.hot) {
153
+
promotions.push(this.config.tiers.hot.set(key, result.data, result.metadata));
154
+
}
155
+
await Promise.all(promotions);
156
+
}
157
157
158
-
// Fire-and-forget access stats update (non-critical)
159
-
void this.updateAccessStats(key, 'cold');
160
-
return {
161
-
data: (await this.deserializeData(result.data)) as T,
162
-
metadata: result.metadata,
163
-
source: 'cold',
164
-
};
165
-
}
158
+
// Fire-and-forget access stats update (non-critical)
159
+
void this.updateAccessStats(key, 'cold');
160
+
return {
161
+
data: (await this.deserializeData(result.data)) as T,
162
+
metadata: result.metadata,
163
+
source: 'cold',
164
+
};
165
+
}
166
166
167
-
return null;
168
-
}
167
+
return null;
168
+
}
169
169
170
-
/**
171
-
* Get data and metadata from a tier using the most efficient method.
172
-
*
173
-
* @remarks
174
-
* Uses the tier's getWithMetadata if available, otherwise falls back
175
-
* to separate get() and getMetadata() calls.
176
-
*/
177
-
private async getFromTier(
178
-
tier: StorageTier,
179
-
key: string
180
-
): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null> {
181
-
// Use optimized combined method if available
182
-
if (tier.getWithMetadata) {
183
-
return tier.getWithMetadata(key);
184
-
}
170
+
/**
171
+
* Get data and metadata from a tier using the most efficient method.
172
+
*
173
+
* @remarks
174
+
* Uses the tier's getWithMetadata if available, otherwise falls back
175
+
* to separate get() and getMetadata() calls.
176
+
*/
177
+
private async getFromTier(
178
+
tier: StorageTier,
179
+
key: string
180
+
): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null> {
181
+
// Use optimized combined method if available
182
+
if (tier.getWithMetadata) {
183
+
return tier.getWithMetadata(key);
184
+
}
185
185
186
-
// Fallback: separate calls
187
-
const data = await tier.get(key);
188
-
if (!data) {
189
-
return null;
190
-
}
191
-
const metadata = await tier.getMetadata(key);
192
-
if (!metadata) {
193
-
return null;
194
-
}
195
-
return { data, metadata };
196
-
}
186
+
// Fallback: separate calls
187
+
const data = await tier.get(key);
188
+
if (!data) {
189
+
return null;
190
+
}
191
+
const metadata = await tier.getMetadata(key);
192
+
if (!metadata) {
193
+
return null;
194
+
}
195
+
return { data, metadata };
196
+
}
197
197
198
-
/**
199
-
* Store data with optional configuration.
200
-
*
201
-
* @param key - The key to store under
202
-
* @param data - The data to store
203
-
* @param options - Optional configuration (TTL, metadata, tier skipping)
204
-
* @returns Information about what was stored and where
205
-
*
206
-
* @remarks
207
-
* Data cascades down through tiers:
208
-
* - If written to hot, also written to warm and cold
209
-
* - If written to warm (hot skipped), also written to cold
210
-
* - Cold is always written (source of truth)
211
-
*
212
-
* Use `skipTiers` to control placement. For example:
213
-
* - Large files: `skipTiers: ['hot']` to avoid memory bloat
214
-
* - Critical small files: Write to all tiers for fastest access
215
-
*
216
-
* Automatically handles serialization and optional compression.
217
-
*/
218
-
async set(key: string, data: T, options?: SetOptions): Promise<SetResult> {
219
-
// 1. Serialize data
220
-
const serialized = await this.serialize(data);
198
+
/**
199
+
* Store data with optional configuration.
200
+
*
201
+
* @param key - The key to store under
202
+
* @param data - The data to store
203
+
* @param options - Optional configuration (TTL, metadata, tier skipping)
204
+
* @returns Information about what was stored and where
205
+
*
206
+
* @remarks
207
+
* Data cascades down through tiers:
208
+
* - If written to hot, also written to warm and cold
209
+
* - If written to warm (hot skipped), also written to cold
210
+
* - Cold is always written (source of truth)
211
+
*
212
+
* Use `skipTiers` to control placement. For example:
213
+
* - Large files: `skipTiers: ['hot']` to avoid memory bloat
214
+
* - Critical small files: Write to all tiers for fastest access
215
+
*
216
+
* Automatically handles serialization and optional compression.
217
+
*/
218
+
async set(key: string, data: T, options?: SetOptions): Promise<SetResult> {
219
+
// 1. Serialize data
220
+
const serialized = await this.serialize(data);
221
221
222
-
// 2. Optionally compress
223
-
const finalData = this.config.compression ? await compress(serialized) : serialized;
222
+
// 2. Optionally compress
223
+
const finalData = this.config.compression ? await compress(serialized) : serialized;
224
224
225
-
// 3. Create metadata
226
-
const metadata = this.createMetadata(key, finalData, options);
225
+
// 3. Create metadata
226
+
const metadata = this.createMetadata(key, finalData, options);
227
227
228
-
// 4. Determine which tiers to write to
229
-
const allowedTiers = this.getTiersForKey(key, options?.skipTiers);
228
+
// 4. Determine which tiers to write to
229
+
const allowedTiers = this.getTiersForKey(key, options?.skipTiers);
230
230
231
-
// 5. Write to tiers
232
-
const tiersWritten: ('hot' | 'warm' | 'cold')[] = [];
231
+
// 5. Write to tiers
232
+
const tiersWritten: ('hot' | 'warm' | 'cold')[] = [];
233
233
234
-
if (this.config.tiers.hot && allowedTiers.includes('hot')) {
235
-
await this.config.tiers.hot.set(key, finalData, metadata);
236
-
tiersWritten.push('hot');
237
-
}
234
+
if (this.config.tiers.hot && allowedTiers.includes('hot')) {
235
+
await this.config.tiers.hot.set(key, finalData, metadata);
236
+
tiersWritten.push('hot');
237
+
}
238
238
239
-
if (this.config.tiers.warm && allowedTiers.includes('warm')) {
240
-
await this.config.tiers.warm.set(key, finalData, metadata);
241
-
tiersWritten.push('warm');
242
-
}
239
+
if (this.config.tiers.warm && allowedTiers.includes('warm')) {
240
+
await this.config.tiers.warm.set(key, finalData, metadata);
241
+
tiersWritten.push('warm');
242
+
}
243
243
244
-
// Always write to cold (source of truth)
245
-
await this.config.tiers.cold.set(key, finalData, metadata);
246
-
tiersWritten.push('cold');
244
+
// Always write to cold (source of truth)
245
+
await this.config.tiers.cold.set(key, finalData, metadata);
246
+
tiersWritten.push('cold');
247
247
248
-
return { key, metadata, tiersWritten };
249
-
}
248
+
return { key, metadata, tiersWritten };
249
+
}
250
250
251
-
/**
252
-
* Determine which tiers a key should be written to.
253
-
*
254
-
* @param key - The key being stored
255
-
* @param skipTiers - Explicit tiers to skip (overrides placement rules)
256
-
* @returns Array of tiers to write to
257
-
*
258
-
* @remarks
259
-
* Priority: skipTiers option > placementRules > all configured tiers
260
-
*/
261
-
private getTiersForKey(
262
-
key: string,
263
-
skipTiers?: ('hot' | 'warm')[]
264
-
): ('hot' | 'warm' | 'cold')[] {
265
-
// If explicit skipTiers provided, use that
266
-
if (skipTiers && skipTiers.length > 0) {
267
-
const allTiers: ('hot' | 'warm' | 'cold')[] = ['hot', 'warm', 'cold'];
268
-
return allTiers.filter((t) => !skipTiers.includes(t as 'hot' | 'warm'));
269
-
}
251
+
/**
252
+
* Determine which tiers a key should be written to.
253
+
*
254
+
* @param key - The key being stored
255
+
* @param skipTiers - Explicit tiers to skip (overrides placement rules)
256
+
* @returns Array of tiers to write to
257
+
*
258
+
* @remarks
259
+
* Priority: skipTiers option > placementRules > all configured tiers
260
+
*/
261
+
private getTiersForKey(
262
+
key: string,
263
+
skipTiers?: ('hot' | 'warm')[]
264
+
): ('hot' | 'warm' | 'cold')[] {
265
+
// If explicit skipTiers provided, use that
266
+
if (skipTiers && skipTiers.length > 0) {
267
+
const allTiers: ('hot' | 'warm' | 'cold')[] = ['hot', 'warm', 'cold'];
268
+
return allTiers.filter((t) => !skipTiers.includes(t as 'hot' | 'warm'));
269
+
}
270
270
271
-
// Check placement rules
272
-
if (this.config.placementRules) {
273
-
for (const rule of this.config.placementRules) {
274
-
if (matchGlob(rule.pattern, key)) {
275
-
// Ensure cold is always included
276
-
if (!rule.tiers.includes('cold')) {
277
-
return [...rule.tiers, 'cold'];
278
-
}
279
-
return rule.tiers;
280
-
}
281
-
}
282
-
}
271
+
// Check placement rules
272
+
if (this.config.placementRules) {
273
+
for (const rule of this.config.placementRules) {
274
+
if (matchGlob(rule.pattern, key)) {
275
+
// Ensure cold is always included
276
+
if (!rule.tiers.includes('cold')) {
277
+
return [...rule.tiers, 'cold'];
278
+
}
279
+
return rule.tiers;
280
+
}
281
+
}
282
+
}
283
283
284
-
// Default: write to all configured tiers
285
-
return ['hot', 'warm', 'cold'];
286
-
}
284
+
// Default: write to all configured tiers
285
+
return ['hot', 'warm', 'cold'];
286
+
}
287
287
288
-
/**
289
-
* Delete data from all tiers.
290
-
*
291
-
* @param key - The key to delete
292
-
*
293
-
* @remarks
294
-
* Deletes from all configured tiers in parallel.
295
-
* Does not throw if the key doesn't exist.
296
-
*/
297
-
async delete(key: string): Promise<void> {
298
-
await Promise.all([
299
-
this.config.tiers.hot?.delete(key),
300
-
this.config.tiers.warm?.delete(key),
301
-
this.config.tiers.cold.delete(key),
302
-
]);
303
-
}
288
+
/**
289
+
* Delete data from all tiers.
290
+
*
291
+
* @param key - The key to delete
292
+
*
293
+
* @remarks
294
+
* Deletes from all configured tiers in parallel.
295
+
* Does not throw if the key doesn't exist.
296
+
*/
297
+
async delete(key: string): Promise<void> {
298
+
await Promise.all([
299
+
this.config.tiers.hot?.delete(key),
300
+
this.config.tiers.warm?.delete(key),
301
+
this.config.tiers.cold.delete(key),
302
+
]);
303
+
}
304
304
305
-
/**
306
-
* Check if a key exists in any tier.
307
-
*
308
-
* @param key - The key to check
309
-
* @returns true if the key exists and hasn't expired
310
-
*
311
-
* @remarks
312
-
* Checks tiers in order: hot → warm → cold.
313
-
* Returns false if key exists but has expired.
314
-
*/
315
-
async exists(key: string): Promise<boolean> {
316
-
// Check hot first (fastest)
317
-
if (this.config.tiers.hot && (await this.config.tiers.hot.exists(key))) {
318
-
const metadata = await this.config.tiers.hot.getMetadata(key);
319
-
if (metadata && !this.isExpired(metadata)) {
320
-
return true;
321
-
}
322
-
}
305
+
/**
306
+
* Check if a key exists in any tier.
307
+
*
308
+
* @param key - The key to check
309
+
* @returns true if the key exists and hasn't expired
310
+
*
311
+
* @remarks
312
+
* Checks tiers in order: hot → warm → cold.
313
+
* Returns false if key exists but has expired.
314
+
*/
315
+
async exists(key: string): Promise<boolean> {
316
+
// Check hot first (fastest)
317
+
if (this.config.tiers.hot && (await this.config.tiers.hot.exists(key))) {
318
+
const metadata = await this.config.tiers.hot.getMetadata(key);
319
+
if (metadata && !this.isExpired(metadata)) {
320
+
return true;
321
+
}
322
+
}
323
323
324
-
// Check warm
325
-
if (this.config.tiers.warm && (await this.config.tiers.warm.exists(key))) {
326
-
const metadata = await this.config.tiers.warm.getMetadata(key);
327
-
if (metadata && !this.isExpired(metadata)) {
328
-
return true;
329
-
}
330
-
}
324
+
// Check warm
325
+
if (this.config.tiers.warm && (await this.config.tiers.warm.exists(key))) {
326
+
const metadata = await this.config.tiers.warm.getMetadata(key);
327
+
if (metadata && !this.isExpired(metadata)) {
328
+
return true;
329
+
}
330
+
}
331
331
332
-
// Check cold (source of truth)
333
-
if (await this.config.tiers.cold.exists(key)) {
334
-
const metadata = await this.config.tiers.cold.getMetadata(key);
335
-
if (metadata && !this.isExpired(metadata)) {
336
-
return true;
337
-
}
338
-
}
332
+
// Check cold (source of truth)
333
+
if (await this.config.tiers.cold.exists(key)) {
334
+
const metadata = await this.config.tiers.cold.getMetadata(key);
335
+
if (metadata && !this.isExpired(metadata)) {
336
+
return true;
337
+
}
338
+
}
339
339
340
-
return false;
341
-
}
340
+
return false;
341
+
}
342
342
343
-
/**
344
-
* Renew TTL for a key.
345
-
*
346
-
* @param key - The key to touch
347
-
* @param ttlMs - Optional new TTL in milliseconds (uses default if not provided)
348
-
*
349
-
* @remarks
350
-
* Updates the TTL and lastAccessed timestamp in all tiers.
351
-
* Useful for implementing "keep alive" behavior for actively used keys.
352
-
* Does nothing if no TTL is configured.
353
-
*/
354
-
async touch(key: string, ttlMs?: number): Promise<void> {
355
-
const ttl = ttlMs ?? this.config.defaultTTL;
356
-
if (!ttl) return;
343
+
/**
344
+
* Renew TTL for a key.
345
+
*
346
+
* @param key - The key to touch
347
+
* @param ttlMs - Optional new TTL in milliseconds (uses default if not provided)
348
+
*
349
+
* @remarks
350
+
* Updates the TTL and lastAccessed timestamp in all tiers.
351
+
* Useful for implementing "keep alive" behavior for actively used keys.
352
+
* Does nothing if no TTL is configured.
353
+
*/
354
+
async touch(key: string, ttlMs?: number): Promise<void> {
355
+
const ttl = ttlMs ?? this.config.defaultTTL;
356
+
if (!ttl) return;
357
357
358
-
const newTTL = new Date(Date.now() + ttl);
358
+
const newTTL = new Date(Date.now() + ttl);
359
359
360
-
for (const tier of [this.config.tiers.hot, this.config.tiers.warm, this.config.tiers.cold]) {
361
-
if (!tier) continue;
360
+
for (const tier of [this.config.tiers.hot, this.config.tiers.warm, this.config.tiers.cold]) {
361
+
if (!tier) continue;
362
362
363
-
const metadata = await tier.getMetadata(key);
364
-
if (metadata) {
365
-
metadata.ttl = newTTL;
366
-
metadata.lastAccessed = new Date();
367
-
await tier.setMetadata(key, metadata);
368
-
}
369
-
}
370
-
}
363
+
const metadata = await tier.getMetadata(key);
364
+
if (metadata) {
365
+
metadata.ttl = newTTL;
366
+
metadata.lastAccessed = new Date();
367
+
await tier.setMetadata(key, metadata);
368
+
}
369
+
}
370
+
}
371
371
372
-
/**
373
-
* Invalidate all keys matching a prefix.
374
-
*
375
-
* @param prefix - The prefix to match (e.g., 'user:' matches 'user:123', 'user:456')
376
-
* @returns Number of keys deleted
377
-
*
378
-
* @remarks
379
-
* Useful for bulk invalidation:
380
-
* - Site invalidation: `invalidate('site:abc:')`
381
-
* - User invalidation: `invalidate('user:123:')`
382
-
* - Global invalidation: `invalidate('')` (deletes everything)
383
-
*
384
-
* Deletes from all tiers in parallel for efficiency.
385
-
*/
386
-
async invalidate(prefix: string): Promise<number> {
387
-
const keysToDelete = new Set<string>();
372
+
/**
373
+
* Invalidate all keys matching a prefix.
374
+
*
375
+
* @param prefix - The prefix to match (e.g., 'user:' matches 'user:123', 'user:456')
376
+
* @returns Number of keys deleted
377
+
*
378
+
* @remarks
379
+
* Useful for bulk invalidation:
380
+
* - Site invalidation: `invalidate('site:abc:')`
381
+
* - User invalidation: `invalidate('user:123:')`
382
+
* - Global invalidation: `invalidate('')` (deletes everything)
383
+
*
384
+
* Deletes from all tiers in parallel for efficiency.
385
+
*/
386
+
async invalidate(prefix: string): Promise<number> {
387
+
const keysToDelete = new Set<string>();
388
388
389
-
// Collect all keys matching prefix from all tiers
390
-
if (this.config.tiers.hot) {
391
-
for await (const key of this.config.tiers.hot.listKeys(prefix)) {
392
-
keysToDelete.add(key);
393
-
}
394
-
}
389
+
// Collect all keys matching prefix from all tiers
390
+
if (this.config.tiers.hot) {
391
+
for await (const key of this.config.tiers.hot.listKeys(prefix)) {
392
+
keysToDelete.add(key);
393
+
}
394
+
}
395
395
396
-
if (this.config.tiers.warm) {
397
-
for await (const key of this.config.tiers.warm.listKeys(prefix)) {
398
-
keysToDelete.add(key);
399
-
}
400
-
}
396
+
if (this.config.tiers.warm) {
397
+
for await (const key of this.config.tiers.warm.listKeys(prefix)) {
398
+
keysToDelete.add(key);
399
+
}
400
+
}
401
401
402
-
for await (const key of this.config.tiers.cold.listKeys(prefix)) {
403
-
keysToDelete.add(key);
404
-
}
402
+
for await (const key of this.config.tiers.cold.listKeys(prefix)) {
403
+
keysToDelete.add(key);
404
+
}
405
405
406
-
// Delete from all tiers in parallel
407
-
const keys = Array.from(keysToDelete);
406
+
// Delete from all tiers in parallel
407
+
const keys = Array.from(keysToDelete);
408
408
409
-
await Promise.all([
410
-
this.config.tiers.hot?.deleteMany(keys),
411
-
this.config.tiers.warm?.deleteMany(keys),
412
-
this.config.tiers.cold.deleteMany(keys),
413
-
]);
409
+
await Promise.all([
410
+
this.config.tiers.hot?.deleteMany(keys),
411
+
this.config.tiers.warm?.deleteMany(keys),
412
+
this.config.tiers.cold.deleteMany(keys),
413
+
]);
414
414
415
-
return keys.length;
416
-
}
415
+
return keys.length;
416
+
}
417
417
418
-
/**
419
-
* List all keys, optionally filtered by prefix.
420
-
*
421
-
* @param prefix - Optional prefix to filter keys
422
-
* @returns Async iterator of keys
423
-
*
424
-
* @remarks
425
-
* Returns keys from the cold tier (source of truth).
426
-
* Memory-efficient - streams keys rather than loading all into memory.
427
-
*
428
-
* @example
429
-
* ```typescript
430
-
* for await (const key of storage.listKeys('user:')) {
431
-
* console.log(key);
432
-
* }
433
-
* ```
434
-
*/
435
-
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
436
-
// List from cold tier (source of truth)
437
-
for await (const key of this.config.tiers.cold.listKeys(prefix)) {
438
-
yield key;
439
-
}
440
-
}
418
+
/**
419
+
* List all keys, optionally filtered by prefix.
420
+
*
421
+
* @param prefix - Optional prefix to filter keys
422
+
* @returns Async iterator of keys
423
+
*
424
+
* @remarks
425
+
* Returns keys from the cold tier (source of truth).
426
+
* Memory-efficient - streams keys rather than loading all into memory.
427
+
*
428
+
* @example
429
+
* ```typescript
430
+
* for await (const key of storage.listKeys('user:')) {
431
+
* console.log(key);
432
+
* }
433
+
* ```
434
+
*/
435
+
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
436
+
// List from cold tier (source of truth)
437
+
for await (const key of this.config.tiers.cold.listKeys(prefix)) {
438
+
yield key;
439
+
}
440
+
}
441
441
442
-
/**
443
-
* Get aggregated statistics across all tiers.
444
-
*
445
-
* @returns Statistics including size, item count, hits, misses, hit rate
446
-
*
447
-
* @remarks
448
-
* Useful for monitoring and capacity planning.
449
-
* Hit rate is calculated as: hits / (hits + misses).
450
-
*/
451
-
async getStats(): Promise<AllTierStats> {
452
-
const [hot, warm, cold] = await Promise.all([
453
-
this.config.tiers.hot?.getStats(),
454
-
this.config.tiers.warm?.getStats(),
455
-
this.config.tiers.cold.getStats(),
456
-
]);
442
+
/**
443
+
* Get aggregated statistics across all tiers.
444
+
*
445
+
* @returns Statistics including size, item count, hits, misses, hit rate
446
+
*
447
+
* @remarks
448
+
* Useful for monitoring and capacity planning.
449
+
* Hit rate is calculated as: hits / (hits + misses).
450
+
*/
451
+
async getStats(): Promise<AllTierStats> {
452
+
const [hot, warm, cold] = await Promise.all([
453
+
this.config.tiers.hot?.getStats(),
454
+
this.config.tiers.warm?.getStats(),
455
+
this.config.tiers.cold.getStats(),
456
+
]);
457
457
458
-
const totalHits = (hot?.hits ?? 0) + (warm?.hits ?? 0) + (cold?.hits ?? 0);
459
-
const totalMisses = (hot?.misses ?? 0) + (warm?.misses ?? 0) + (cold?.misses ?? 0);
460
-
const hitRate = totalHits + totalMisses > 0 ? totalHits / (totalHits + totalMisses) : 0;
458
+
const totalHits = (hot?.hits ?? 0) + (warm?.hits ?? 0) + (cold?.hits ?? 0);
459
+
const totalMisses = (hot?.misses ?? 0) + (warm?.misses ?? 0) + (cold?.misses ?? 0);
460
+
const hitRate = totalHits + totalMisses > 0 ? totalHits / (totalHits + totalMisses) : 0;
461
461
462
-
return {
463
-
...(hot && { hot }),
464
-
...(warm && { warm }),
465
-
cold,
466
-
totalHits,
467
-
totalMisses,
468
-
hitRate,
469
-
};
470
-
}
462
+
return {
463
+
...(hot && { hot }),
464
+
...(warm && { warm }),
465
+
cold,
466
+
totalHits,
467
+
totalMisses,
468
+
hitRate,
469
+
};
470
+
}
471
471
472
-
/**
473
-
* Clear all data from all tiers.
474
-
*
475
-
* @remarks
476
-
* Use with extreme caution! This will delete all data in the entire storage system.
477
-
* Cannot be undone.
478
-
*/
479
-
async clear(): Promise<void> {
480
-
await Promise.all([
481
-
this.config.tiers.hot?.clear(),
482
-
this.config.tiers.warm?.clear(),
483
-
this.config.tiers.cold.clear(),
484
-
]);
485
-
}
472
+
/**
473
+
* Clear all data from all tiers.
474
+
*
475
+
* @remarks
476
+
* Use with extreme caution! This will delete all data in the entire storage system.
477
+
* Cannot be undone.
478
+
*/
479
+
async clear(): Promise<void> {
480
+
await Promise.all([
481
+
this.config.tiers.hot?.clear(),
482
+
this.config.tiers.warm?.clear(),
483
+
this.config.tiers.cold.clear(),
484
+
]);
485
+
}
486
486
487
-
/**
488
-
* Clear a specific tier.
489
-
*
490
-
* @param tier - Which tier to clear
491
-
*
492
-
* @remarks
493
-
* Useful for:
494
-
* - Clearing hot tier to test warm/cold performance
495
-
* - Clearing warm tier to force rebuilding from cold
496
-
* - Clearing cold tier to start fresh (⚠️ loses source of truth!)
497
-
*/
498
-
async clearTier(tier: 'hot' | 'warm' | 'cold'): Promise<void> {
499
-
switch (tier) {
500
-
case 'hot':
501
-
await this.config.tiers.hot?.clear();
502
-
break;
503
-
case 'warm':
504
-
await this.config.tiers.warm?.clear();
505
-
break;
506
-
case 'cold':
507
-
await this.config.tiers.cold.clear();
508
-
break;
509
-
}
510
-
}
487
+
/**
488
+
* Clear a specific tier.
489
+
*
490
+
* @param tier - Which tier to clear
491
+
*
492
+
* @remarks
493
+
* Useful for:
494
+
* - Clearing hot tier to test warm/cold performance
495
+
* - Clearing warm tier to force rebuilding from cold
496
+
* - Clearing cold tier to start fresh (⚠️ loses source of truth!)
497
+
*/
498
+
async clearTier(tier: 'hot' | 'warm' | 'cold'): Promise<void> {
499
+
switch (tier) {
500
+
case 'hot':
501
+
await this.config.tiers.hot?.clear();
502
+
break;
503
+
case 'warm':
504
+
await this.config.tiers.warm?.clear();
505
+
break;
506
+
case 'cold':
507
+
await this.config.tiers.cold.clear();
508
+
break;
509
+
}
510
+
}
511
511
512
-
/**
513
-
* Export metadata snapshot for backup or migration.
514
-
*
515
-
* @returns Snapshot containing all keys, metadata, and statistics
516
-
*
517
-
* @remarks
518
-
* The snapshot includes metadata but not the actual data (data remains in tiers).
519
-
* Useful for:
520
-
* - Backup and restore
521
-
* - Migration between storage systems
522
-
* - Auditing and compliance
523
-
*/
524
-
async export(): Promise<StorageSnapshot> {
525
-
const keys: string[] = [];
526
-
const metadata: Record<string, StorageMetadata> = {};
512
+
/**
513
+
* Export metadata snapshot for backup or migration.
514
+
*
515
+
* @returns Snapshot containing all keys, metadata, and statistics
516
+
*
517
+
* @remarks
518
+
* The snapshot includes metadata but not the actual data (data remains in tiers).
519
+
* Useful for:
520
+
* - Backup and restore
521
+
* - Migration between storage systems
522
+
* - Auditing and compliance
523
+
*/
524
+
async export(): Promise<StorageSnapshot> {
525
+
const keys: string[] = [];
526
+
const metadata: Record<string, StorageMetadata> = {};
527
527
528
-
// Export from cold tier (source of truth)
529
-
for await (const key of this.config.tiers.cold.listKeys()) {
530
-
keys.push(key);
531
-
const meta = await this.config.tiers.cold.getMetadata(key);
532
-
if (meta) {
533
-
metadata[key] = meta;
534
-
}
535
-
}
528
+
// Export from cold tier (source of truth)
529
+
for await (const key of this.config.tiers.cold.listKeys()) {
530
+
keys.push(key);
531
+
const meta = await this.config.tiers.cold.getMetadata(key);
532
+
if (meta) {
533
+
metadata[key] = meta;
534
+
}
535
+
}
536
536
537
-
const stats = await this.getStats();
537
+
const stats = await this.getStats();
538
538
539
-
return {
540
-
version: 1,
541
-
exportedAt: new Date(),
542
-
keys,
543
-
metadata,
544
-
stats,
545
-
};
546
-
}
539
+
return {
540
+
version: 1,
541
+
exportedAt: new Date(),
542
+
keys,
543
+
metadata,
544
+
stats,
545
+
};
546
+
}
547
547
548
-
/**
549
-
* Import metadata snapshot.
550
-
*
551
-
* @param snapshot - Snapshot to import
552
-
*
553
-
* @remarks
554
-
* Validates version compatibility before importing.
555
-
* Only imports metadata - assumes data already exists in cold tier.
556
-
*/
557
-
async import(snapshot: StorageSnapshot): Promise<void> {
558
-
if (snapshot.version !== 1) {
559
-
throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
560
-
}
548
+
/**
549
+
* Import metadata snapshot.
550
+
*
551
+
* @param snapshot - Snapshot to import
552
+
*
553
+
* @remarks
554
+
* Validates version compatibility before importing.
555
+
* Only imports metadata - assumes data already exists in cold tier.
556
+
*/
557
+
async import(snapshot: StorageSnapshot): Promise<void> {
558
+
if (snapshot.version !== 1) {
559
+
throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
560
+
}
561
561
562
-
// Import metadata into all configured tiers
563
-
for (const key of snapshot.keys) {
564
-
const metadata = snapshot.metadata[key];
565
-
if (!metadata) continue;
562
+
// Import metadata into all configured tiers
563
+
for (const key of snapshot.keys) {
564
+
const metadata = snapshot.metadata[key];
565
+
if (!metadata) continue;
566
566
567
-
if (this.config.tiers.hot) {
568
-
await this.config.tiers.hot.setMetadata(key, metadata);
569
-
}
567
+
if (this.config.tiers.hot) {
568
+
await this.config.tiers.hot.setMetadata(key, metadata);
569
+
}
570
570
571
-
if (this.config.tiers.warm) {
572
-
await this.config.tiers.warm.setMetadata(key, metadata);
573
-
}
571
+
if (this.config.tiers.warm) {
572
+
await this.config.tiers.warm.setMetadata(key, metadata);
573
+
}
574
574
575
-
await this.config.tiers.cold.setMetadata(key, metadata);
576
-
}
577
-
}
575
+
await this.config.tiers.cold.setMetadata(key, metadata);
576
+
}
577
+
}
578
578
579
-
/**
580
-
* Bootstrap hot tier from warm tier.
581
-
*
582
-
* @param limit - Optional limit on number of items to load
583
-
* @returns Number of items loaded
584
-
*
585
-
* @remarks
586
-
* Loads the most frequently accessed items from warm into hot.
587
-
* Useful for warming up the cache after a restart.
588
-
* Items are sorted by: accessCount * lastAccessed timestamp (higher is better).
589
-
*/
590
-
async bootstrapHot(limit?: number): Promise<number> {
591
-
if (!this.config.tiers.hot || !this.config.tiers.warm) {
592
-
return 0;
593
-
}
579
+
/**
580
+
* Bootstrap hot tier from warm tier.
581
+
*
582
+
* @param limit - Optional limit on number of items to load
583
+
* @returns Number of items loaded
584
+
*
585
+
* @remarks
586
+
* Loads the most frequently accessed items from warm into hot.
587
+
* Useful for warming up the cache after a restart.
588
+
* Items are sorted by: accessCount * lastAccessed timestamp (higher is better).
589
+
*/
590
+
async bootstrapHot(limit?: number): Promise<number> {
591
+
if (!this.config.tiers.hot || !this.config.tiers.warm) {
592
+
return 0;
593
+
}
594
594
595
-
let loaded = 0;
596
-
const keyMetadata: Array<[string, StorageMetadata]> = [];
595
+
let loaded = 0;
596
+
const keyMetadata: Array<[string, StorageMetadata]> = [];
597
597
598
-
// Load metadata for all keys
599
-
for await (const key of this.config.tiers.warm.listKeys()) {
600
-
const metadata = await this.config.tiers.warm.getMetadata(key);
601
-
if (metadata) {
602
-
keyMetadata.push([key, metadata]);
603
-
}
604
-
}
598
+
// Load metadata for all keys
599
+
for await (const key of this.config.tiers.warm.listKeys()) {
600
+
const metadata = await this.config.tiers.warm.getMetadata(key);
601
+
if (metadata) {
602
+
keyMetadata.push([key, metadata]);
603
+
}
604
+
}
605
605
606
-
// Sort by access count * recency (simple scoring)
607
-
keyMetadata.sort((a, b) => {
608
-
const scoreA = a[1].accessCount * a[1].lastAccessed.getTime();
609
-
const scoreB = b[1].accessCount * b[1].lastAccessed.getTime();
610
-
return scoreB - scoreA;
611
-
});
606
+
// Sort by access count * recency (simple scoring)
607
+
keyMetadata.sort((a, b) => {
608
+
const scoreA = a[1].accessCount * a[1].lastAccessed.getTime();
609
+
const scoreB = b[1].accessCount * b[1].lastAccessed.getTime();
610
+
return scoreB - scoreA;
611
+
});
612
612
613
-
// Load top N keys into hot tier
614
-
const keysToLoad = limit ? keyMetadata.slice(0, limit) : keyMetadata;
613
+
// Load top N keys into hot tier
614
+
const keysToLoad = limit ? keyMetadata.slice(0, limit) : keyMetadata;
615
615
616
-
for (const [key, metadata] of keysToLoad) {
617
-
const data = await this.config.tiers.warm.get(key);
618
-
if (data) {
619
-
await this.config.tiers.hot.set(key, data, metadata);
620
-
loaded++;
621
-
}
622
-
}
616
+
for (const [key, metadata] of keysToLoad) {
617
+
const data = await this.config.tiers.warm.get(key);
618
+
if (data) {
619
+
await this.config.tiers.hot.set(key, data, metadata);
620
+
loaded++;
621
+
}
622
+
}
623
623
624
-
return loaded;
625
-
}
624
+
return loaded;
625
+
}
626
626
627
-
/**
628
-
* Bootstrap warm tier from cold tier.
629
-
*
630
-
* @param options - Optional limit and date filter
631
-
* @returns Number of items loaded
632
-
*
633
-
* @remarks
634
-
* Loads recent items from cold into warm.
635
-
* Useful for:
636
-
* - Initial cache population
637
-
* - Recovering from warm tier failure
638
-
* - Migrating to a new warm tier implementation
639
-
*/
640
-
async bootstrapWarm(options?: { limit?: number; sinceDate?: Date }): Promise<number> {
641
-
if (!this.config.tiers.warm) {
642
-
return 0;
643
-
}
627
+
/**
628
+
* Bootstrap warm tier from cold tier.
629
+
*
630
+
* @param options - Optional limit and date filter
631
+
* @returns Number of items loaded
632
+
*
633
+
* @remarks
634
+
* Loads recent items from cold into warm.
635
+
* Useful for:
636
+
* - Initial cache population
637
+
* - Recovering from warm tier failure
638
+
* - Migrating to a new warm tier implementation
639
+
*/
640
+
async bootstrapWarm(options?: { limit?: number; sinceDate?: Date }): Promise<number> {
641
+
if (!this.config.tiers.warm) {
642
+
return 0;
643
+
}
644
644
645
-
let loaded = 0;
645
+
let loaded = 0;
646
646
647
-
for await (const key of this.config.tiers.cold.listKeys()) {
648
-
const metadata = await this.config.tiers.cold.getMetadata(key);
649
-
if (!metadata) continue;
647
+
for await (const key of this.config.tiers.cold.listKeys()) {
648
+
const metadata = await this.config.tiers.cold.getMetadata(key);
649
+
if (!metadata) continue;
650
650
651
-
// Skip if too old
652
-
if (options?.sinceDate && metadata.lastAccessed < options.sinceDate) {
653
-
continue;
654
-
}
651
+
// Skip if too old
652
+
if (options?.sinceDate && metadata.lastAccessed < options.sinceDate) {
653
+
continue;
654
+
}
655
655
656
-
const data = await this.config.tiers.cold.get(key);
657
-
if (data) {
658
-
await this.config.tiers.warm.set(key, data, metadata);
659
-
loaded++;
656
+
const data = await this.config.tiers.cold.get(key);
657
+
if (data) {
658
+
await this.config.tiers.warm.set(key, data, metadata);
659
+
loaded++;
660
660
661
-
if (options?.limit && loaded >= options.limit) {
662
-
break;
663
-
}
664
-
}
665
-
}
661
+
if (options?.limit && loaded >= options.limit) {
662
+
break;
663
+
}
664
+
}
665
+
}
666
666
667
-
return loaded;
668
-
}
667
+
return loaded;
668
+
}
669
669
670
-
/**
671
-
* Check if data has expired based on TTL.
672
-
*/
673
-
private isExpired(metadata: StorageMetadata): boolean {
674
-
if (!metadata.ttl) return false;
675
-
return Date.now() > metadata.ttl.getTime();
676
-
}
670
+
/**
671
+
* Check if data has expired based on TTL.
672
+
*/
673
+
private isExpired(metadata: StorageMetadata): boolean {
674
+
if (!metadata.ttl) return false;
675
+
return Date.now() > metadata.ttl.getTime();
676
+
}
677
677
678
-
/**
679
-
* Update access statistics for a key.
680
-
*/
681
-
private async updateAccessStats(key: string, tier: 'hot' | 'warm' | 'cold'): Promise<void> {
682
-
const tierObj =
683
-
tier === 'hot'
684
-
? this.config.tiers.hot
685
-
: tier === 'warm'
686
-
? this.config.tiers.warm
687
-
: this.config.tiers.cold;
678
+
/**
679
+
* Update access statistics for a key.
680
+
*/
681
+
private async updateAccessStats(key: string, tier: 'hot' | 'warm' | 'cold'): Promise<void> {
682
+
const tierObj =
683
+
tier === 'hot'
684
+
? this.config.tiers.hot
685
+
: tier === 'warm'
686
+
? this.config.tiers.warm
687
+
: this.config.tiers.cold;
688
688
689
-
if (!tierObj) return;
689
+
if (!tierObj) return;
690
690
691
-
const metadata = await tierObj.getMetadata(key);
692
-
if (metadata) {
693
-
metadata.lastAccessed = new Date();
694
-
metadata.accessCount++;
695
-
await tierObj.setMetadata(key, metadata);
696
-
}
697
-
}
691
+
const metadata = await tierObj.getMetadata(key);
692
+
if (metadata) {
693
+
metadata.lastAccessed = new Date();
694
+
metadata.accessCount++;
695
+
await tierObj.setMetadata(key, metadata);
696
+
}
697
+
}
698
698
699
-
/**
700
-
* Create metadata for new data.
701
-
*/
702
-
private createMetadata(key: string, data: Uint8Array, options?: SetOptions): StorageMetadata {
703
-
const now = new Date();
704
-
const ttl = options?.ttl ?? this.config.defaultTTL;
699
+
/**
700
+
* Create metadata for new data.
701
+
*/
702
+
private createMetadata(key: string, data: Uint8Array, options?: SetOptions): StorageMetadata {
703
+
const now = new Date();
704
+
const ttl = options?.ttl ?? this.config.defaultTTL;
705
705
706
-
const metadata: StorageMetadata = {
707
-
key,
708
-
size: data.byteLength,
709
-
createdAt: now,
710
-
lastAccessed: now,
711
-
accessCount: 0,
712
-
compressed: this.config.compression ?? false,
713
-
checksum: calculateChecksum(data),
714
-
};
706
+
const metadata: StorageMetadata = {
707
+
key,
708
+
size: data.byteLength,
709
+
createdAt: now,
710
+
lastAccessed: now,
711
+
accessCount: 0,
712
+
compressed: this.config.compression ?? false,
713
+
checksum: calculateChecksum(data),
714
+
};
715
715
716
-
if (ttl) {
717
-
metadata.ttl = new Date(now.getTime() + ttl);
718
-
}
716
+
if (ttl) {
717
+
metadata.ttl = new Date(now.getTime() + ttl);
718
+
}
719
719
720
-
if (options?.metadata) {
721
-
metadata.customMetadata = options.metadata;
722
-
}
720
+
if (options?.metadata) {
721
+
metadata.customMetadata = options.metadata;
722
+
}
723
723
724
-
return metadata;
725
-
}
724
+
return metadata;
725
+
}
726
726
727
-
/**
728
-
* Deserialize data, handling compression automatically.
729
-
*/
730
-
private async deserializeData(data: Uint8Array): Promise<unknown> {
731
-
// Decompress if needed (check for gzip magic bytes)
732
-
const finalData =
733
-
this.config.compression && data[0] === 0x1f && data[1] === 0x8b
734
-
? await decompress(data)
735
-
: data;
727
+
/**
728
+
* Deserialize data, handling compression automatically.
729
+
*/
730
+
private async deserializeData(data: Uint8Array): Promise<unknown> {
731
+
// Decompress if needed (check for gzip magic bytes)
732
+
const finalData =
733
+
this.config.compression && data[0] === 0x1f && data[1] === 0x8b
734
+
? await decompress(data)
735
+
: data;
736
736
737
-
return this.deserialize(finalData);
738
-
}
737
+
return this.deserialize(finalData);
738
+
}
739
739
}
+11
-11
src/index.ts
+11
-11
src/index.ts
···
17
17
18
18
// Types
19
19
export type {
20
-
StorageTier,
21
-
StorageMetadata,
22
-
TierStats,
23
-
TierGetResult,
24
-
AllTierStats,
25
-
TieredStorageConfig,
26
-
PlacementRule,
27
-
SetOptions,
28
-
StorageResult,
29
-
SetResult,
30
-
StorageSnapshot,
20
+
StorageTier,
21
+
StorageMetadata,
22
+
TierStats,
23
+
TierGetResult,
24
+
AllTierStats,
25
+
TieredStorageConfig,
26
+
PlacementRule,
27
+
SetOptions,
28
+
StorageResult,
29
+
SetResult,
30
+
StorageSnapshot,
31
31
} from './types/index.js';
32
32
33
33
// Utilities
+358
-293
src/tiers/DiskStorageTier.ts
+358
-293
src/tiers/DiskStorageTier.ts
···
13
13
* Configuration for DiskStorageTier.
14
14
*/
15
15
export 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;
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
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;
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
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;
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
46
}
47
47
48
48
/**
···
58
58
* File structure:
59
59
* ```
60
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
61
+
* ├── user%3A123/
62
+
* │ ├── profile # Data file (encoded key)
63
+
* │ └── profile.meta # Metadata JSON
64
+
* └── did%3Aplc%3Aabc/
65
+
* └── site/
66
+
* ├── index.html
67
+
* └── index.html.meta
65
68
* ```
66
69
*
67
70
* @example
68
71
* ```typescript
69
72
* const tier = new DiskStorageTier({
70
-
* directory: './cache',
71
-
* maxSizeBytes: 10 * 1024 * 1024 * 1024, // 10GB
72
-
* evictionPolicy: 'lru',
73
+
* directory: './cache',
74
+
* maxSizeBytes: 10 * 1024 * 1024 * 1024, // 10GB
75
+
* evictionPolicy: 'lru',
73
76
* });
74
77
*
75
78
* await tier.set('key', data, metadata);
···
77
80
* ```
78
81
*/
79
82
export class DiskStorageTier implements StorageTier {
80
-
private metadataIndex = new Map<
81
-
string,
82
-
{ size: number; createdAt: Date; lastAccessed: Date }
83
-
>();
84
-
private currentSize = 0;
83
+
private metadataIndex = new Map<
84
+
string,
85
+
{ size: number; createdAt: Date; lastAccessed: Date }
86
+
>();
87
+
private currentSize = 0;
85
88
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
-
}
89
+
constructor(private config: DiskStorageTierConfig) {
90
+
if (!config.directory) {
91
+
throw new Error('directory is required');
92
+
}
93
+
if (config.maxSizeBytes !== undefined && config.maxSizeBytes <= 0) {
94
+
throw new Error('maxSizeBytes must be positive');
95
+
}
93
96
94
-
void this.ensureDirectory();
95
-
void this.rebuildIndex();
96
-
}
97
+
void this.ensureDirectory();
98
+
void this.rebuildIndex();
99
+
}
97
100
98
-
private async rebuildIndex(): Promise<void> {
99
-
if (!existsSync(this.config.directory)) {
100
-
return;
101
-
}
101
+
private async rebuildIndex(): Promise<void> {
102
+
if (!existsSync(this.config.directory)) {
103
+
return;
104
+
}
102
105
103
-
const files = await readdir(this.config.directory);
106
+
await this.rebuildIndexRecursive(this.config.directory);
107
+
}
104
108
105
-
for (const file of files) {
106
-
if (file.endsWith('.meta')) {
107
-
continue;
108
-
}
109
+
/**
110
+
* Recursively rebuild index from a directory and its subdirectories.
111
+
*/
112
+
private async rebuildIndexRecursive(dir: string): Promise<void> {
113
+
const entries = await readdir(dir, { withFileTypes: true });
109
114
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);
115
+
for (const entry of entries) {
116
+
const fullPath = join(dir, entry.name);
116
117
117
-
this.metadataIndex.set(metadata.key, {
118
-
size: fileStats.size,
119
-
createdAt: new Date(metadata.createdAt),
120
-
lastAccessed: new Date(metadata.lastAccessed),
121
-
});
118
+
if (entry.isDirectory()) {
119
+
await this.rebuildIndexRecursive(fullPath);
120
+
} else if (!entry.name.endsWith('.meta')) {
121
+
try {
122
+
const metaPath = `${fullPath}.meta`;
123
+
const metaContent = await readFile(metaPath, 'utf-8');
124
+
const metadata = JSON.parse(metaContent) as StorageMetadata;
125
+
const fileStats = await stat(fullPath);
122
126
123
-
this.currentSize += fileStats.size;
124
-
} catch {
125
-
continue;
126
-
}
127
-
}
128
-
}
127
+
this.metadataIndex.set(metadata.key, {
128
+
size: fileStats.size,
129
+
createdAt: new Date(metadata.createdAt),
130
+
lastAccessed: new Date(metadata.lastAccessed),
131
+
});
129
132
130
-
async get(key: string): Promise<Uint8Array | null> {
131
-
const filePath = this.getFilePath(key);
133
+
this.currentSize += fileStats.size;
134
+
} catch {
135
+
continue;
136
+
}
137
+
}
138
+
}
139
+
}
132
140
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
-
}
141
+
async get(key: string): Promise<Uint8Array | null> {
142
+
const filePath = this.getFilePath(key);
143
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);
144
+
try {
145
+
const data = await readFile(filePath);
146
+
return new Uint8Array(data);
147
+
} catch (error) {
148
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
149
+
return null;
150
+
}
151
+
throw error;
152
+
}
153
+
}
156
154
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
-
]);
155
+
/**
156
+
* Retrieve data and metadata together in a single operation.
157
+
*
158
+
* @param key - The key to retrieve
159
+
* @returns The data and metadata, or null if not found
160
+
*
161
+
* @remarks
162
+
* Reads data and metadata files in parallel for better performance.
163
+
*/
164
+
async getWithMetadata(key: string): Promise<TierGetResult | null> {
165
+
const filePath = this.getFilePath(key);
166
+
const metaPath = this.getMetaPath(key);
167
+
168
+
try {
169
+
// Read data and metadata in parallel
170
+
const [dataBuffer, metaContent] = await Promise.all([
171
+
readFile(filePath),
172
+
readFile(metaPath, 'utf-8'),
173
+
]);
174
+
175
+
const metadata = JSON.parse(metaContent) as StorageMetadata;
176
+
177
+
// Convert date strings back to Date objects
178
+
metadata.createdAt = new Date(metadata.createdAt);
179
+
metadata.lastAccessed = new Date(metadata.lastAccessed);
180
+
if (metadata.ttl) {
181
+
metadata.ttl = new Date(metadata.ttl);
182
+
}
183
+
184
+
return { data: new Uint8Array(dataBuffer), metadata };
185
+
} catch (error) {
186
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
187
+
return null;
188
+
}
189
+
throw error;
190
+
}
191
+
}
192
+
193
+
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
194
+
const filePath = this.getFilePath(key);
195
+
const metaPath = this.getMetaPath(key);
196
+
197
+
const dir = dirname(filePath);
198
+
if (!existsSync(dir)) {
199
+
await mkdir(dir, { recursive: true });
200
+
}
163
201
164
-
const metadata = JSON.parse(metaContent) as StorageMetadata;
202
+
const existingEntry = this.metadataIndex.get(key);
203
+
if (existingEntry) {
204
+
this.currentSize -= existingEntry.size;
205
+
}
165
206
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
-
}
207
+
if (this.config.maxSizeBytes) {
208
+
await this.evictIfNeeded(data.byteLength);
209
+
}
172
210
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
-
}
211
+
const tempMetaPath = `${metaPath}.tmp`;
212
+
await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
213
+
await writeFile(filePath, data);
214
+
await rename(tempMetaPath, metaPath);
181
215
182
-
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
183
-
const filePath = this.getFilePath(key);
184
-
const metaPath = this.getMetaPath(key);
216
+
this.metadataIndex.set(key, {
217
+
size: data.byteLength,
218
+
createdAt: metadata.createdAt,
219
+
lastAccessed: metadata.lastAccessed,
220
+
});
221
+
this.currentSize += data.byteLength;
222
+
}
185
223
186
-
const dir = dirname(filePath);
187
-
if (!existsSync(dir)) {
188
-
await mkdir(dir, { recursive: true });
189
-
}
224
+
async delete(key: string): Promise<void> {
225
+
const filePath = this.getFilePath(key);
226
+
const metaPath = this.getMetaPath(key);
190
227
191
-
const existingEntry = this.metadataIndex.get(key);
192
-
if (existingEntry) {
193
-
this.currentSize -= existingEntry.size;
194
-
}
228
+
const entry = this.metadataIndex.get(key);
229
+
if (entry) {
230
+
this.currentSize -= entry.size;
231
+
this.metadataIndex.delete(key);
232
+
}
195
233
196
-
if (this.config.maxSizeBytes) {
197
-
await this.evictIfNeeded(data.byteLength);
198
-
}
234
+
await Promise.all([
235
+
unlink(filePath).catch(() => {}),
236
+
unlink(metaPath).catch(() => {}),
237
+
]);
199
238
200
-
const tempMetaPath = `${metaPath}.tmp`;
201
-
await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
202
-
await writeFile(filePath, data);
203
-
await rename(tempMetaPath, metaPath);
239
+
// Clean up empty parent directories
240
+
await this.cleanupEmptyDirectories(dirname(filePath));
241
+
}
204
242
205
-
this.metadataIndex.set(key, {
206
-
size: data.byteLength,
207
-
createdAt: metadata.createdAt,
208
-
lastAccessed: metadata.lastAccessed,
209
-
});
210
-
this.currentSize += data.byteLength;
211
-
}
243
+
async exists(key: string): Promise<boolean> {
244
+
const filePath = this.getFilePath(key);
245
+
return existsSync(filePath);
246
+
}
212
247
213
-
async delete(key: string): Promise<void> {
214
-
const filePath = this.getFilePath(key);
215
-
const metaPath = this.getMetaPath(key);
248
+
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
249
+
if (!existsSync(this.config.directory)) {
250
+
return;
251
+
}
216
252
217
-
const entry = this.metadataIndex.get(key);
218
-
if (entry) {
219
-
this.currentSize -= entry.size;
220
-
this.metadataIndex.delete(key);
221
-
}
253
+
// Recursively list all files in directory tree
254
+
for await (const key of this.listKeysRecursive(this.config.directory, prefix)) {
255
+
yield key;
256
+
}
257
+
}
222
258
223
-
await Promise.all([
224
-
unlink(filePath).catch(() => {}),
225
-
unlink(metaPath).catch(() => {}),
226
-
]);
227
-
}
259
+
/**
260
+
* Recursively list keys from a directory and its subdirectories.
261
+
*/
262
+
private async *listKeysRecursive(dir: string, prefix?: string): AsyncIterableIterator<string> {
263
+
const entries = await readdir(dir, { withFileTypes: true });
228
264
229
-
async exists(key: string): Promise<boolean> {
230
-
const filePath = this.getFilePath(key);
231
-
return existsSync(filePath);
232
-
}
265
+
for (const entry of entries) {
266
+
const fullPath = join(dir, entry.name);
233
267
234
-
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
235
-
if (!existsSync(this.config.directory)) {
236
-
return;
237
-
}
268
+
if (entry.isDirectory()) {
269
+
// Recurse into subdirectory
270
+
for await (const key of this.listKeysRecursive(fullPath, prefix)) {
271
+
yield key;
272
+
}
273
+
} else if (!entry.name.endsWith('.meta')) {
274
+
// Data file - read metadata to get original key
275
+
const metaPath = `${fullPath}.meta`;
276
+
try {
277
+
const metaContent = await readFile(metaPath, 'utf-8');
278
+
const metadata = JSON.parse(metaContent) as StorageMetadata;
279
+
const originalKey = metadata.key;
238
280
239
-
const files = await readdir(this.config.directory);
281
+
if (!prefix || originalKey.startsWith(prefix)) {
282
+
yield originalKey;
283
+
}
284
+
} catch {
285
+
// If metadata is missing or invalid, skip this file
286
+
continue;
287
+
}
288
+
}
289
+
}
290
+
}
240
291
241
-
for (const file of files) {
242
-
// Skip metadata files
243
-
if (file.endsWith('.meta')) {
244
-
continue;
245
-
}
292
+
async deleteMany(keys: string[]): Promise<void> {
293
+
await Promise.all(keys.map((key) => this.delete(key)));
294
+
}
246
295
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;
296
+
async getMetadata(key: string): Promise<StorageMetadata | null> {
297
+
const metaPath = this.getMetaPath(key);
254
298
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
-
}
299
+
try {
300
+
const content = await readFile(metaPath, 'utf-8');
301
+
const metadata = JSON.parse(content) as StorageMetadata;
264
302
265
-
async deleteMany(keys: string[]): Promise<void> {
266
-
await Promise.all(keys.map((key) => this.delete(key)));
267
-
}
303
+
// Convert date strings back to Date objects
304
+
metadata.createdAt = new Date(metadata.createdAt);
305
+
metadata.lastAccessed = new Date(metadata.lastAccessed);
306
+
if (metadata.ttl) {
307
+
metadata.ttl = new Date(metadata.ttl);
308
+
}
268
309
269
-
async getMetadata(key: string): Promise<StorageMetadata | null> {
270
-
const metaPath = this.getMetaPath(key);
310
+
return metadata;
311
+
} catch (error) {
312
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
313
+
return null;
314
+
}
315
+
throw error;
316
+
}
317
+
}
271
318
272
-
try {
273
-
const content = await readFile(metaPath, 'utf-8');
274
-
const metadata = JSON.parse(content) as StorageMetadata;
319
+
async setMetadata(key: string, metadata: StorageMetadata): Promise<void> {
320
+
const metaPath = this.getMetaPath(key);
275
321
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
-
}
322
+
// Ensure parent directory exists
323
+
const dir = dirname(metaPath);
324
+
if (!existsSync(dir)) {
325
+
await mkdir(dir, { recursive: true });
326
+
}
282
327
283
-
return metadata;
284
-
} catch (error) {
285
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
286
-
return null;
287
-
}
288
-
throw error;
289
-
}
290
-
}
328
+
await writeFile(metaPath, JSON.stringify(metadata, null, 2));
329
+
}
291
330
292
-
async setMetadata(key: string, metadata: StorageMetadata): Promise<void> {
293
-
const metaPath = this.getMetaPath(key);
331
+
async getStats(): Promise<TierStats> {
332
+
if (!existsSync(this.config.directory)) {
333
+
return { bytes: 0, items: 0 };
334
+
}
294
335
295
-
// Ensure parent directory exists
296
-
const dir = dirname(metaPath);
297
-
if (!existsSync(dir)) {
298
-
await mkdir(dir, { recursive: true });
299
-
}
336
+
return this.getStatsRecursive(this.config.directory);
337
+
}
300
338
301
-
await writeFile(metaPath, JSON.stringify(metadata, null, 2));
302
-
}
339
+
/**
340
+
* Recursively collect stats from a directory and its subdirectories.
341
+
*/
342
+
private async getStatsRecursive(dir: string): Promise<TierStats> {
343
+
let bytes = 0;
344
+
let items = 0;
303
345
304
-
async getStats(): Promise<TierStats> {
305
-
let bytes = 0;
306
-
let items = 0;
346
+
const entries = await readdir(dir, { withFileTypes: true });
307
347
308
-
if (!existsSync(this.config.directory)) {
309
-
return { bytes: 0, items: 0 };
310
-
}
348
+
for (const entry of entries) {
349
+
const fullPath = join(dir, entry.name);
311
350
312
-
const files = await readdir(this.config.directory);
351
+
if (entry.isDirectory()) {
352
+
const subStats = await this.getStatsRecursive(fullPath);
353
+
bytes += subStats.bytes;
354
+
items += subStats.items;
355
+
} else if (!entry.name.endsWith('.meta')) {
356
+
const fileStats = await stat(fullPath);
357
+
bytes += fileStats.size;
358
+
items++;
359
+
}
360
+
}
313
361
314
-
for (const file of files) {
315
-
if (file.endsWith('.meta')) {
316
-
continue;
317
-
}
362
+
return { bytes, items };
363
+
}
318
364
319
-
const filePath = join(this.config.directory, file);
320
-
const stats = await stat(filePath);
321
-
bytes += stats.size;
322
-
items++;
323
-
}
365
+
async clear(): Promise<void> {
366
+
if (existsSync(this.config.directory)) {
367
+
await rm(this.config.directory, { recursive: true, force: true });
368
+
await this.ensureDirectory();
369
+
this.metadataIndex.clear();
370
+
this.currentSize = 0;
371
+
}
372
+
}
324
373
325
-
return { bytes, items };
326
-
}
374
+
/**
375
+
* Clean up empty parent directories after file deletion.
376
+
*
377
+
* @param dirPath - Directory path to start cleanup from
378
+
*
379
+
* @remarks
380
+
* Recursively removes empty directories up to (but not including) the base directory.
381
+
* This prevents directory bloat when files with nested paths are deleted.
382
+
*/
383
+
private async cleanupEmptyDirectories(dirPath: string): Promise<void> {
384
+
// Don't remove the base directory
385
+
if (dirPath === this.config.directory || !dirPath.startsWith(this.config.directory)) {
386
+
return;
387
+
}
327
388
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
-
}
389
+
try {
390
+
const entries = await readdir(dirPath);
391
+
// If directory is empty, remove it and recurse to parent
392
+
if (entries.length === 0) {
393
+
await rm(dirPath, { recursive: false });
394
+
await this.cleanupEmptyDirectories(dirname(dirPath));
395
+
}
396
+
} catch {
397
+
// Directory doesn't exist or can't be read - that's fine
398
+
return;
399
+
}
400
+
}
336
401
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
-
}
402
+
/**
403
+
* Get the filesystem path for a key's data file.
404
+
*/
405
+
private getFilePath(key: string): string {
406
+
const encoded = encodeKey(key);
407
+
return join(this.config.directory, encoded);
408
+
}
344
409
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
-
}
410
+
/**
411
+
* Get the filesystem path for a key's metadata file.
412
+
*/
413
+
private getMetaPath(key: string): string {
414
+
return `${this.getFilePath(key)}.meta`;
415
+
}
351
416
352
-
private async ensureDirectory(): Promise<void> {
353
-
await mkdir(this.config.directory, { recursive: true }).catch(() => {});
354
-
}
417
+
private async ensureDirectory(): Promise<void> {
418
+
await mkdir(this.config.directory, { recursive: true }).catch(() => {});
419
+
}
355
420
356
-
private async evictIfNeeded(incomingSize: number): Promise<void> {
357
-
if (!this.config.maxSizeBytes) {
358
-
return;
359
-
}
421
+
private async evictIfNeeded(incomingSize: number): Promise<void> {
422
+
if (!this.config.maxSizeBytes) {
423
+
return;
424
+
}
360
425
361
-
if (this.currentSize + incomingSize <= this.config.maxSizeBytes) {
362
-
return;
363
-
}
426
+
if (this.currentSize + incomingSize <= this.config.maxSizeBytes) {
427
+
return;
428
+
}
364
429
365
-
const entries = Array.from(this.metadataIndex.entries()).map(([key, info]) => ({
366
-
key,
367
-
...info,
368
-
}));
430
+
const entries = Array.from(this.metadataIndex.entries()).map(([key, info]) => ({
431
+
key,
432
+
...info,
433
+
}));
369
434
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
-
});
435
+
const policy = this.config.evictionPolicy ?? 'lru';
436
+
entries.sort((a, b) => {
437
+
switch (policy) {
438
+
case 'lru':
439
+
return a.lastAccessed.getTime() - b.lastAccessed.getTime();
440
+
case 'fifo':
441
+
return a.createdAt.getTime() - b.createdAt.getTime();
442
+
case 'size':
443
+
return b.size - a.size;
444
+
default:
445
+
return 0;
446
+
}
447
+
});
383
448
384
-
for (const entry of entries) {
385
-
if (this.currentSize + incomingSize <= this.config.maxSizeBytes) {
386
-
break;
387
-
}
449
+
for (const entry of entries) {
450
+
if (this.currentSize + incomingSize <= this.config.maxSizeBytes) {
451
+
break;
452
+
}
388
453
389
-
await this.delete(entry.key);
390
-
}
391
-
}
454
+
await this.delete(entry.key);
455
+
}
456
+
}
392
457
}
+155
-155
src/tiers/MemoryStorageTier.ts
+155
-155
src/tiers/MemoryStorageTier.ts
···
2
2
import type { StorageTier, StorageMetadata, TierStats, TierGetResult } from '../types/index.js';
3
3
4
4
interface CacheEntry {
5
-
data: Uint8Array;
6
-
metadata: StorageMetadata;
7
-
size: number;
5
+
data: Uint8Array;
6
+
metadata: StorageMetadata;
7
+
size: number;
8
8
}
9
9
10
10
/**
11
11
* Configuration for MemoryStorageTier.
12
12
*/
13
13
export interface MemoryStorageTierConfig {
14
-
/**
15
-
* Maximum total size in bytes.
16
-
*
17
-
* @remarks
18
-
* When this limit is reached, least-recently-used entries are evicted.
19
-
*/
20
-
maxSizeBytes: number;
14
+
/**
15
+
* Maximum total size in bytes.
16
+
*
17
+
* @remarks
18
+
* When this limit is reached, least-recently-used entries are evicted.
19
+
*/
20
+
maxSizeBytes: number;
21
21
22
-
/**
23
-
* Maximum number of items.
24
-
*
25
-
* @remarks
26
-
* When this limit is reached, least-recently-used entries are evicted.
27
-
* Useful for limiting memory usage when items have variable sizes.
28
-
*/
29
-
maxItems?: number;
22
+
/**
23
+
* Maximum number of items.
24
+
*
25
+
* @remarks
26
+
* When this limit is reached, least-recently-used entries are evicted.
27
+
* Useful for limiting memory usage when items have variable sizes.
28
+
*/
29
+
maxItems?: number;
30
30
}
31
31
32
32
/**
···
42
42
* @example
43
43
* ```typescript
44
44
* const tier = new MemoryStorageTier({
45
-
* maxSizeBytes: 100 * 1024 * 1024, // 100MB
46
-
* maxItems: 1000,
45
+
* maxSizeBytes: 100 * 1024 * 1024, // 100MB
46
+
* maxItems: 1000,
47
47
* });
48
48
*
49
49
* await tier.set('key', data, metadata);
···
51
51
* ```
52
52
*/
53
53
export class MemoryStorageTier implements StorageTier {
54
-
private cache: ReturnType<typeof lru<CacheEntry>>;
55
-
private currentSize = 0;
56
-
private stats = {
57
-
hits: 0,
58
-
misses: 0,
59
-
evictions: 0,
60
-
};
54
+
private cache: ReturnType<typeof lru<CacheEntry>>;
55
+
private currentSize = 0;
56
+
private stats = {
57
+
hits: 0,
58
+
misses: 0,
59
+
evictions: 0,
60
+
};
61
61
62
-
constructor(private config: MemoryStorageTierConfig) {
63
-
if (config.maxSizeBytes <= 0) {
64
-
throw new Error('maxSizeBytes must be positive');
65
-
}
66
-
if (config.maxItems !== undefined && config.maxItems <= 0) {
67
-
throw new Error('maxItems must be positive');
68
-
}
62
+
constructor(private config: MemoryStorageTierConfig) {
63
+
if (config.maxSizeBytes <= 0) {
64
+
throw new Error('maxSizeBytes must be positive');
65
+
}
66
+
if (config.maxItems !== undefined && config.maxItems <= 0) {
67
+
throw new Error('maxItems must be positive');
68
+
}
69
69
70
-
// Initialize TinyLRU with max items (we'll handle size limits separately)
71
-
const maxItems = config.maxItems ?? 10000; // Default to 10k items if not specified
72
-
this.cache = lru<CacheEntry>(maxItems);
73
-
}
70
+
// Initialize TinyLRU with max items (we'll handle size limits separately)
71
+
const maxItems = config.maxItems ?? 10000; // Default to 10k items if not specified
72
+
this.cache = lru<CacheEntry>(maxItems);
73
+
}
74
74
75
-
async get(key: string): Promise<Uint8Array | null> {
76
-
const entry = this.cache.get(key);
75
+
async get(key: string): Promise<Uint8Array | null> {
76
+
const entry = this.cache.get(key);
77
77
78
-
if (!entry) {
79
-
this.stats.misses++;
80
-
return null;
81
-
}
78
+
if (!entry) {
79
+
this.stats.misses++;
80
+
return null;
81
+
}
82
82
83
-
this.stats.hits++;
84
-
return entry.data;
85
-
}
83
+
this.stats.hits++;
84
+
return entry.data;
85
+
}
86
86
87
-
/**
88
-
* Retrieve data and metadata together in a single cache lookup.
89
-
*
90
-
* @param key - The key to retrieve
91
-
* @returns The data and metadata, or null if not found
92
-
*/
93
-
async getWithMetadata(key: string): Promise<TierGetResult | null> {
94
-
const entry = this.cache.get(key);
87
+
/**
88
+
* Retrieve data and metadata together in a single cache lookup.
89
+
*
90
+
* @param key - The key to retrieve
91
+
* @returns The data and metadata, or null if not found
92
+
*/
93
+
async getWithMetadata(key: string): Promise<TierGetResult | null> {
94
+
const entry = this.cache.get(key);
95
95
96
-
if (!entry) {
97
-
this.stats.misses++;
98
-
return null;
99
-
}
96
+
if (!entry) {
97
+
this.stats.misses++;
98
+
return null;
99
+
}
100
100
101
-
this.stats.hits++;
102
-
return { data: entry.data, metadata: entry.metadata };
103
-
}
101
+
this.stats.hits++;
102
+
return { data: entry.data, metadata: entry.metadata };
103
+
}
104
104
105
-
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
106
-
const size = data.byteLength;
105
+
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
106
+
const size = data.byteLength;
107
107
108
-
// Check existing entry for size accounting
109
-
const existing = this.cache.get(key);
110
-
if (existing) {
111
-
this.currentSize -= existing.size;
112
-
}
108
+
// Check existing entry for size accounting
109
+
const existing = this.cache.get(key);
110
+
if (existing) {
111
+
this.currentSize -= existing.size;
112
+
}
113
113
114
-
// Evict entries until we have space for the new entry
115
-
await this.evictIfNeeded(size);
114
+
// Evict entries until we have space for the new entry
115
+
await this.evictIfNeeded(size);
116
116
117
-
// Add new entry
118
-
const entry: CacheEntry = { data, metadata, size };
119
-
this.cache.set(key, entry);
120
-
this.currentSize += size;
121
-
}
117
+
// Add new entry
118
+
const entry: CacheEntry = { data, metadata, size };
119
+
this.cache.set(key, entry);
120
+
this.currentSize += size;
121
+
}
122
122
123
-
async delete(key: string): Promise<void> {
124
-
const entry = this.cache.get(key);
125
-
if (entry) {
126
-
this.cache.delete(key);
127
-
this.currentSize -= entry.size;
128
-
}
129
-
}
123
+
async delete(key: string): Promise<void> {
124
+
const entry = this.cache.get(key);
125
+
if (entry) {
126
+
this.cache.delete(key);
127
+
this.currentSize -= entry.size;
128
+
}
129
+
}
130
130
131
-
async exists(key: string): Promise<boolean> {
132
-
return this.cache.has(key);
133
-
}
131
+
async exists(key: string): Promise<boolean> {
132
+
return this.cache.has(key);
133
+
}
134
134
135
-
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
136
-
// TinyLRU doesn't expose keys(), so we need to track them separately
137
-
// For now, we'll use the cache's internal structure
138
-
const keys = this.cache.keys();
139
-
for (const key of keys) {
140
-
if (!prefix || key.startsWith(prefix)) {
141
-
yield key;
142
-
}
143
-
}
144
-
}
135
+
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
136
+
// TinyLRU doesn't expose keys(), so we need to track them separately
137
+
// For now, we'll use the cache's internal structure
138
+
const keys = this.cache.keys();
139
+
for (const key of keys) {
140
+
if (!prefix || key.startsWith(prefix)) {
141
+
yield key;
142
+
}
143
+
}
144
+
}
145
145
146
-
async deleteMany(keys: string[]): Promise<void> {
147
-
for (const key of keys) {
148
-
await this.delete(key);
149
-
}
150
-
}
146
+
async deleteMany(keys: string[]): Promise<void> {
147
+
for (const key of keys) {
148
+
await this.delete(key);
149
+
}
150
+
}
151
151
152
-
async getMetadata(key: string): Promise<StorageMetadata | null> {
153
-
const entry = this.cache.get(key);
154
-
return entry ? entry.metadata : null;
155
-
}
152
+
async getMetadata(key: string): Promise<StorageMetadata | null> {
153
+
const entry = this.cache.get(key);
154
+
return entry ? entry.metadata : null;
155
+
}
156
156
157
-
async setMetadata(key: string, metadata: StorageMetadata): Promise<void> {
158
-
const entry = this.cache.get(key);
159
-
if (entry) {
160
-
// Update metadata in place
161
-
entry.metadata = metadata;
162
-
// Re-set to mark as recently used
163
-
this.cache.set(key, entry);
164
-
}
165
-
}
157
+
async setMetadata(key: string, metadata: StorageMetadata): Promise<void> {
158
+
const entry = this.cache.get(key);
159
+
if (entry) {
160
+
// Update metadata in place
161
+
entry.metadata = metadata;
162
+
// Re-set to mark as recently used
163
+
this.cache.set(key, entry);
164
+
}
165
+
}
166
166
167
-
async getStats(): Promise<TierStats> {
168
-
return {
169
-
bytes: this.currentSize,
170
-
items: this.cache.size,
171
-
hits: this.stats.hits,
172
-
misses: this.stats.misses,
173
-
evictions: this.stats.evictions,
174
-
};
175
-
}
167
+
async getStats(): Promise<TierStats> {
168
+
return {
169
+
bytes: this.currentSize,
170
+
items: this.cache.size,
171
+
hits: this.stats.hits,
172
+
misses: this.stats.misses,
173
+
evictions: this.stats.evictions,
174
+
};
175
+
}
176
176
177
-
async clear(): Promise<void> {
178
-
this.cache.clear();
179
-
this.currentSize = 0;
180
-
}
177
+
async clear(): Promise<void> {
178
+
this.cache.clear();
179
+
this.currentSize = 0;
180
+
}
181
181
182
-
/**
183
-
* Evict least-recently-used entries until there's space for new data.
184
-
*
185
-
* @param incomingSize - Size of data being added
186
-
*
187
-
* @remarks
188
-
* TinyLRU handles count-based eviction automatically.
189
-
* This method handles size-based eviction by using TinyLRU's built-in evict() method,
190
-
* which properly removes the LRU item without updating access order.
191
-
*/
192
-
private async evictIfNeeded(incomingSize: number): Promise<void> {
193
-
// Keep evicting until we have enough space
194
-
while (this.currentSize + incomingSize > this.config.maxSizeBytes && this.cache.size > 0) {
195
-
// Get the LRU key (first in the list) without accessing it
196
-
const keys = this.cache.keys();
197
-
if (keys.length === 0) break;
182
+
/**
183
+
* Evict least-recently-used entries until there's space for new data.
184
+
*
185
+
* @param incomingSize - Size of data being added
186
+
*
187
+
* @remarks
188
+
* TinyLRU handles count-based eviction automatically.
189
+
* This method handles size-based eviction by using TinyLRU's built-in evict() method,
190
+
* which properly removes the LRU item without updating access order.
191
+
*/
192
+
private async evictIfNeeded(incomingSize: number): Promise<void> {
193
+
// Keep evicting until we have enough space
194
+
while (this.currentSize + incomingSize > this.config.maxSizeBytes && this.cache.size > 0) {
195
+
// Get the LRU key (first in the list) without accessing it
196
+
const keys = this.cache.keys();
197
+
if (keys.length === 0) break;
198
198
199
-
const lruKey = keys[0];
200
-
if (!lruKey) break;
199
+
const lruKey = keys[0];
200
+
if (!lruKey) break;
201
201
202
-
// Access the entry directly from internal items without triggering LRU update
203
-
// TinyLRU exposes items as a public property for this purpose
204
-
const entry = (this.cache as any).items[lruKey] as CacheEntry | undefined;
205
-
if (!entry) break;
202
+
// Access the entry directly from internal items without triggering LRU update
203
+
// TinyLRU exposes items as a public property for this purpose
204
+
const entry = (this.cache as any).items[lruKey] as CacheEntry | undefined;
205
+
if (!entry) break;
206
206
207
-
// Use TinyLRU's built-in evict() which properly removes the LRU item
208
-
this.cache.evict();
209
-
this.currentSize -= entry.size;
210
-
this.stats.evictions++;
211
-
}
212
-
}
207
+
// Use TinyLRU's built-in evict() which properly removes the LRU item
208
+
this.cache.evict();
209
+
this.currentSize -= entry.size;
210
+
this.stats.evictions++;
211
+
}
212
+
}
213
213
}
+515
-515
src/tiers/S3StorageTier.ts
+515
-515
src/tiers/S3StorageTier.ts
···
1
1
import {
2
-
S3Client,
3
-
PutObjectCommand,
4
-
GetObjectCommand,
5
-
DeleteObjectCommand,
6
-
HeadObjectCommand,
7
-
ListObjectsV2Command,
8
-
DeleteObjectsCommand,
9
-
CopyObjectCommand,
10
-
type S3ClientConfig,
2
+
S3Client,
3
+
PutObjectCommand,
4
+
GetObjectCommand,
5
+
DeleteObjectCommand,
6
+
HeadObjectCommand,
7
+
ListObjectsV2Command,
8
+
DeleteObjectsCommand,
9
+
CopyObjectCommand,
10
+
type S3ClientConfig,
11
11
} from '@aws-sdk/client-s3';
12
12
import type { Readable } from 'node:stream';
13
13
import type { StorageTier, StorageMetadata, TierStats, TierGetResult } from '../types/index.js';
···
16
16
* Configuration for S3StorageTier.
17
17
*/
18
18
export interface S3StorageTierConfig {
19
-
/**
20
-
* S3 bucket name.
21
-
*/
22
-
bucket: string;
19
+
/**
20
+
* S3 bucket name.
21
+
*/
22
+
bucket: string;
23
23
24
-
/**
25
-
* AWS region.
26
-
*/
27
-
region: string;
24
+
/**
25
+
* AWS region.
26
+
*/
27
+
region: string;
28
28
29
-
/**
30
-
* Optional S3-compatible endpoint (for R2, Minio, etc.).
31
-
*
32
-
* @example 'https://s3.us-east-1.amazonaws.com'
33
-
* @example 'https://account-id.r2.cloudflarestorage.com'
34
-
*/
35
-
endpoint?: string;
29
+
/**
30
+
* Optional S3-compatible endpoint (for R2, Minio, etc.).
31
+
*
32
+
* @example 'https://s3.us-east-1.amazonaws.com'
33
+
* @example 'https://account-id.r2.cloudflarestorage.com'
34
+
*/
35
+
endpoint?: string;
36
36
37
-
/**
38
-
* Optional AWS credentials.
39
-
*
40
-
* @remarks
41
-
* If not provided, uses the default AWS credential chain
42
-
* (environment variables, ~/.aws/credentials, IAM roles, etc.)
43
-
*/
44
-
credentials?: {
45
-
accessKeyId: string;
46
-
secretAccessKey: string;
47
-
};
37
+
/**
38
+
* Optional AWS credentials.
39
+
*
40
+
* @remarks
41
+
* If not provided, uses the default AWS credential chain
42
+
* (environment variables, ~/.aws/credentials, IAM roles, etc.)
43
+
*/
44
+
credentials?: {
45
+
accessKeyId: string;
46
+
secretAccessKey: string;
47
+
};
48
48
49
-
/**
50
-
* Optional key prefix for namespacing.
51
-
*
52
-
* @remarks
53
-
* All keys will be prefixed with this value.
54
-
* Useful for multi-tenant scenarios or organizing data.
55
-
*
56
-
* @example 'tiered-storage/'
57
-
*/
58
-
prefix?: string;
49
+
/**
50
+
* Optional key prefix for namespacing.
51
+
*
52
+
* @remarks
53
+
* All keys will be prefixed with this value.
54
+
* Useful for multi-tenant scenarios or organizing data.
55
+
*
56
+
* @example 'tiered-storage/'
57
+
*/
58
+
prefix?: string;
59
59
60
-
/**
61
-
* Force path-style addressing for S3-compatible services.
62
-
*
63
-
* @defaultValue true
64
-
*
65
-
* @remarks
66
-
* Most S3-compatible services (MinIO, R2, etc.) require path-style URLs.
67
-
* AWS S3 uses virtual-hosted-style by default, but path-style also works.
68
-
*
69
-
* - true: `https://endpoint.com/bucket/key` (path-style)
70
-
* - false: `https://bucket.endpoint.com/key` (virtual-hosted-style)
71
-
*/
72
-
forcePathStyle?: boolean;
60
+
/**
61
+
* Force path-style addressing for S3-compatible services.
62
+
*
63
+
* @defaultValue true
64
+
*
65
+
* @remarks
66
+
* Most S3-compatible services (MinIO, R2, etc.) require path-style URLs.
67
+
* AWS S3 uses virtual-hosted-style by default, but path-style also works.
68
+
*
69
+
* - true: `https://endpoint.com/bucket/key` (path-style)
70
+
* - false: `https://bucket.endpoint.com/key` (virtual-hosted-style)
71
+
*/
72
+
forcePathStyle?: boolean;
73
73
74
-
/**
75
-
* Optional separate bucket for storing metadata.
76
-
*
77
-
* @remarks
78
-
* **RECOMMENDED for production use!**
79
-
*
80
-
* By default, metadata is stored in S3 object metadata fields. However, updating
81
-
* metadata requires copying the entire object, which is slow and expensive for large files.
82
-
*
83
-
* When `metadataBucket` is specified, metadata is stored as separate JSON objects
84
-
* in this bucket. This allows fast, cheap metadata updates without copying data.
85
-
*
86
-
* **Benefits:**
87
-
* - Fast metadata updates (no object copying)
88
-
* - Much cheaper for large objects
89
-
* - No impact on data object performance
90
-
*
91
-
* **Trade-offs:**
92
-
* - Requires managing two buckets
93
-
* - Metadata and data could become out of sync if not handled carefully
94
-
* - Additional S3 API calls for metadata operations
95
-
*
96
-
* @example
97
-
* ```typescript
98
-
* const tier = new S3StorageTier({
99
-
* bucket: 'my-data-bucket',
100
-
* metadataBucket: 'my-metadata-bucket', // Separate bucket for metadata
101
-
* region: 'us-east-1',
102
-
* });
103
-
* ```
104
-
*/
105
-
metadataBucket?: string;
74
+
/**
75
+
* Optional separate bucket for storing metadata.
76
+
*
77
+
* @remarks
78
+
* **RECOMMENDED for production use!**
79
+
*
80
+
* By default, metadata is stored in S3 object metadata fields. However, updating
81
+
* metadata requires copying the entire object, which is slow and expensive for large files.
82
+
*
83
+
* When `metadataBucket` is specified, metadata is stored as separate JSON objects
84
+
* in this bucket. This allows fast, cheap metadata updates without copying data.
85
+
*
86
+
* **Benefits:**
87
+
* - Fast metadata updates (no object copying)
88
+
* - Much cheaper for large objects
89
+
* - No impact on data object performance
90
+
*
91
+
* **Trade-offs:**
92
+
* - Requires managing two buckets
93
+
* - Metadata and data could become out of sync if not handled carefully
94
+
* - Additional S3 API calls for metadata operations
95
+
*
96
+
* @example
97
+
* ```typescript
98
+
* const tier = new S3StorageTier({
99
+
* bucket: 'my-data-bucket',
100
+
* metadataBucket: 'my-metadata-bucket', // Separate bucket for metadata
101
+
* region: 'us-east-1',
102
+
* });
103
+
* ```
104
+
*/
105
+
metadataBucket?: string;
106
106
}
107
107
108
108
/**
···
122
122
* @example
123
123
* ```typescript
124
124
* const tier = new S3StorageTier({
125
-
* bucket: 'my-bucket',
126
-
* region: 'us-east-1',
127
-
* credentials: {
128
-
* accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
129
-
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
130
-
* },
131
-
* prefix: 'cache/',
125
+
* bucket: 'my-bucket',
126
+
* region: 'us-east-1',
127
+
* credentials: {
128
+
* accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
129
+
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
130
+
* },
131
+
* prefix: 'cache/',
132
132
* });
133
133
* ```
134
134
*
135
135
* @example Cloudflare R2
136
136
* ```typescript
137
137
* const tier = new S3StorageTier({
138
-
* bucket: 'my-bucket',
139
-
* region: 'auto',
140
-
* endpoint: 'https://account-id.r2.cloudflarestorage.com',
141
-
* credentials: {
142
-
* accessKeyId: process.env.R2_ACCESS_KEY_ID!,
143
-
* secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
144
-
* },
138
+
* bucket: 'my-bucket',
139
+
* region: 'auto',
140
+
* endpoint: 'https://account-id.r2.cloudflarestorage.com',
141
+
* credentials: {
142
+
* accessKeyId: process.env.R2_ACCESS_KEY_ID!,
143
+
* secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
144
+
* },
145
145
* });
146
146
* ```
147
147
*/
148
148
export class S3StorageTier implements StorageTier {
149
-
private client: S3Client;
150
-
private prefix: string;
151
-
private metadataBucket?: string;
149
+
private client: S3Client;
150
+
private prefix: string;
151
+
private metadataBucket?: string;
152
152
153
-
constructor(private config: S3StorageTierConfig) {
154
-
const clientConfig: S3ClientConfig = {
155
-
region: config.region,
156
-
// Most S3-compatible services need path-style URLs
157
-
forcePathStyle: config.forcePathStyle ?? true,
158
-
...(config.endpoint && { endpoint: config.endpoint }),
159
-
...(config.credentials && { credentials: config.credentials }),
160
-
};
153
+
constructor(private config: S3StorageTierConfig) {
154
+
const clientConfig: S3ClientConfig = {
155
+
region: config.region,
156
+
// Most S3-compatible services need path-style URLs
157
+
forcePathStyle: config.forcePathStyle ?? true,
158
+
...(config.endpoint && { endpoint: config.endpoint }),
159
+
...(config.credentials && { credentials: config.credentials }),
160
+
};
161
161
162
-
this.client = new S3Client(clientConfig);
163
-
this.prefix = config.prefix ?? '';
164
-
if (config.metadataBucket) {
165
-
this.metadataBucket = config.metadataBucket;
166
-
}
167
-
}
162
+
this.client = new S3Client(clientConfig);
163
+
this.prefix = config.prefix ?? '';
164
+
if (config.metadataBucket) {
165
+
this.metadataBucket = config.metadataBucket;
166
+
}
167
+
}
168
168
169
-
async get(key: string): Promise<Uint8Array | null> {
170
-
try {
171
-
const command = new GetObjectCommand({
172
-
Bucket: this.config.bucket,
173
-
Key: this.getS3Key(key),
174
-
});
169
+
async get(key: string): Promise<Uint8Array | null> {
170
+
try {
171
+
const command = new GetObjectCommand({
172
+
Bucket: this.config.bucket,
173
+
Key: this.getS3Key(key),
174
+
});
175
175
176
-
const response = await this.client.send(command);
176
+
const response = await this.client.send(command);
177
177
178
-
if (!response.Body) {
179
-
return null;
180
-
}
178
+
if (!response.Body) {
179
+
return null;
180
+
}
181
181
182
-
return await this.streamToUint8Array(response.Body as Readable);
183
-
} catch (error) {
184
-
if (this.isNoSuchKeyError(error)) {
185
-
return null;
186
-
}
187
-
throw error;
188
-
}
189
-
}
182
+
return await this.streamToUint8Array(response.Body as Readable);
183
+
} catch (error) {
184
+
if (this.isNoSuchKeyError(error)) {
185
+
return null;
186
+
}
187
+
throw error;
188
+
}
189
+
}
190
190
191
-
/**
192
-
* Retrieve data and metadata together in a single operation.
193
-
*
194
-
* @param key - The key to retrieve
195
-
* @returns The data and metadata, or null if not found
196
-
*
197
-
* @remarks
198
-
* When using a separate metadata bucket, fetches data and metadata in parallel.
199
-
* Otherwise, uses the data object's embedded metadata.
200
-
*/
201
-
async getWithMetadata(key: string): Promise<TierGetResult | null> {
202
-
const s3Key = this.getS3Key(key);
191
+
/**
192
+
* Retrieve data and metadata together in a single operation.
193
+
*
194
+
* @param key - The key to retrieve
195
+
* @returns The data and metadata, or null if not found
196
+
*
197
+
* @remarks
198
+
* When using a separate metadata bucket, fetches data and metadata in parallel.
199
+
* Otherwise, uses the data object's embedded metadata.
200
+
*/
201
+
async getWithMetadata(key: string): Promise<TierGetResult | null> {
202
+
const s3Key = this.getS3Key(key);
203
203
204
-
try {
205
-
if (this.metadataBucket) {
206
-
// Fetch data and metadata in parallel
207
-
const [dataResponse, metadataResponse] = await Promise.all([
208
-
this.client.send(new GetObjectCommand({
209
-
Bucket: this.config.bucket,
210
-
Key: s3Key,
211
-
})),
212
-
this.client.send(new GetObjectCommand({
213
-
Bucket: this.metadataBucket,
214
-
Key: s3Key + '.meta',
215
-
})),
216
-
]);
204
+
try {
205
+
if (this.metadataBucket) {
206
+
// Fetch data and metadata in parallel
207
+
const [dataResponse, metadataResponse] = await Promise.all([
208
+
this.client.send(new GetObjectCommand({
209
+
Bucket: this.config.bucket,
210
+
Key: s3Key,
211
+
})),
212
+
this.client.send(new GetObjectCommand({
213
+
Bucket: this.metadataBucket,
214
+
Key: s3Key + '.meta',
215
+
})),
216
+
]);
217
217
218
-
if (!dataResponse.Body || !metadataResponse.Body) {
219
-
return null;
220
-
}
218
+
if (!dataResponse.Body || !metadataResponse.Body) {
219
+
return null;
220
+
}
221
221
222
-
const [data, metaBuffer] = await Promise.all([
223
-
this.streamToUint8Array(dataResponse.Body as Readable),
224
-
this.streamToUint8Array(metadataResponse.Body as Readable),
225
-
]);
222
+
const [data, metaBuffer] = await Promise.all([
223
+
this.streamToUint8Array(dataResponse.Body as Readable),
224
+
this.streamToUint8Array(metadataResponse.Body as Readable),
225
+
]);
226
226
227
-
const json = new TextDecoder().decode(metaBuffer);
228
-
const metadata = JSON.parse(json) as StorageMetadata;
229
-
metadata.createdAt = new Date(metadata.createdAt);
230
-
metadata.lastAccessed = new Date(metadata.lastAccessed);
231
-
if (metadata.ttl) {
232
-
metadata.ttl = new Date(metadata.ttl);
233
-
}
227
+
const json = new TextDecoder().decode(metaBuffer);
228
+
const metadata = JSON.parse(json) as StorageMetadata;
229
+
metadata.createdAt = new Date(metadata.createdAt);
230
+
metadata.lastAccessed = new Date(metadata.lastAccessed);
231
+
if (metadata.ttl) {
232
+
metadata.ttl = new Date(metadata.ttl);
233
+
}
234
234
235
-
return { data, metadata };
236
-
} else {
237
-
// Get data with embedded metadata from response headers
238
-
const response = await this.client.send(new GetObjectCommand({
239
-
Bucket: this.config.bucket,
240
-
Key: s3Key,
241
-
}));
235
+
return { data, metadata };
236
+
} else {
237
+
// Get data with embedded metadata from response headers
238
+
const response = await this.client.send(new GetObjectCommand({
239
+
Bucket: this.config.bucket,
240
+
Key: s3Key,
241
+
}));
242
242
243
-
if (!response.Body || !response.Metadata) {
244
-
return null;
245
-
}
243
+
if (!response.Body || !response.Metadata) {
244
+
return null;
245
+
}
246
246
247
-
const data = await this.streamToUint8Array(response.Body as Readable);
248
-
const metadata = this.s3ToMetadata(response.Metadata);
247
+
const data = await this.streamToUint8Array(response.Body as Readable);
248
+
const metadata = this.s3ToMetadata(response.Metadata);
249
249
250
-
return { data, metadata };
251
-
}
252
-
} catch (error) {
253
-
if (this.isNoSuchKeyError(error)) {
254
-
return null;
255
-
}
256
-
throw error;
257
-
}
258
-
}
250
+
return { data, metadata };
251
+
}
252
+
} catch (error) {
253
+
if (this.isNoSuchKeyError(error)) {
254
+
return null;
255
+
}
256
+
throw error;
257
+
}
258
+
}
259
259
260
-
private async streamToUint8Array(stream: Readable): Promise<Uint8Array> {
261
-
const chunks: Uint8Array[] = [];
260
+
private async streamToUint8Array(stream: Readable): Promise<Uint8Array> {
261
+
const chunks: Uint8Array[] = [];
262
262
263
-
for await (const chunk of stream) {
264
-
if (Buffer.isBuffer(chunk)) {
265
-
chunks.push(new Uint8Array(chunk));
266
-
} else if (chunk instanceof Uint8Array) {
267
-
chunks.push(chunk);
268
-
} else {
269
-
throw new Error('Unexpected chunk type in S3 stream');
270
-
}
271
-
}
263
+
for await (const chunk of stream) {
264
+
if (Buffer.isBuffer(chunk)) {
265
+
chunks.push(new Uint8Array(chunk));
266
+
} else if (chunk instanceof Uint8Array) {
267
+
chunks.push(chunk);
268
+
} else {
269
+
throw new Error('Unexpected chunk type in S3 stream');
270
+
}
271
+
}
272
272
273
-
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
274
-
const result = new Uint8Array(totalLength);
275
-
let offset = 0;
276
-
for (const chunk of chunks) {
277
-
result.set(chunk, offset);
278
-
offset += chunk.length;
279
-
}
273
+
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
274
+
const result = new Uint8Array(totalLength);
275
+
let offset = 0;
276
+
for (const chunk of chunks) {
277
+
result.set(chunk, offset);
278
+
offset += chunk.length;
279
+
}
280
280
281
-
return result;
282
-
}
281
+
return result;
282
+
}
283
283
284
-
private isNoSuchKeyError(error: unknown): boolean {
285
-
return (
286
-
typeof error === 'object' &&
287
-
error !== null &&
288
-
'name' in error &&
289
-
(error.name === 'NoSuchKey' || error.name === 'NotFound')
290
-
);
291
-
}
284
+
private isNoSuchKeyError(error: unknown): boolean {
285
+
return (
286
+
typeof error === 'object' &&
287
+
error !== null &&
288
+
'name' in error &&
289
+
(error.name === 'NoSuchKey' || error.name === 'NotFound')
290
+
);
291
+
}
292
292
293
-
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
294
-
const s3Key = this.getS3Key(key);
293
+
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
294
+
const s3Key = this.getS3Key(key);
295
295
296
-
if (this.metadataBucket) {
297
-
const dataCommand = new PutObjectCommand({
298
-
Bucket: this.config.bucket,
299
-
Key: s3Key,
300
-
Body: data,
301
-
ContentLength: data.byteLength,
302
-
});
296
+
if (this.metadataBucket) {
297
+
const dataCommand = new PutObjectCommand({
298
+
Bucket: this.config.bucket,
299
+
Key: s3Key,
300
+
Body: data,
301
+
ContentLength: data.byteLength,
302
+
});
303
303
304
-
const metadataJson = JSON.stringify(metadata);
305
-
const metadataBuffer = new TextEncoder().encode(metadataJson);
306
-
const metadataCommand = new PutObjectCommand({
307
-
Bucket: this.metadataBucket,
308
-
Key: s3Key + '.meta',
309
-
Body: metadataBuffer,
310
-
ContentType: 'application/json',
311
-
});
304
+
const metadataJson = JSON.stringify(metadata);
305
+
const metadataBuffer = new TextEncoder().encode(metadataJson);
306
+
const metadataCommand = new PutObjectCommand({
307
+
Bucket: this.metadataBucket,
308
+
Key: s3Key + '.meta',
309
+
Body: metadataBuffer,
310
+
ContentType: 'application/json',
311
+
});
312
312
313
-
await Promise.all([
314
-
this.client.send(dataCommand),
315
-
this.client.send(metadataCommand),
316
-
]);
317
-
} else {
318
-
const command = new PutObjectCommand({
319
-
Bucket: this.config.bucket,
320
-
Key: s3Key,
321
-
Body: data,
322
-
ContentLength: data.byteLength,
323
-
Metadata: this.metadataToS3(metadata),
324
-
});
313
+
await Promise.all([
314
+
this.client.send(dataCommand),
315
+
this.client.send(metadataCommand),
316
+
]);
317
+
} else {
318
+
const command = new PutObjectCommand({
319
+
Bucket: this.config.bucket,
320
+
Key: s3Key,
321
+
Body: data,
322
+
ContentLength: data.byteLength,
323
+
Metadata: this.metadataToS3(metadata),
324
+
});
325
325
326
-
await this.client.send(command);
327
-
}
328
-
}
326
+
await this.client.send(command);
327
+
}
328
+
}
329
329
330
-
async delete(key: string): Promise<void> {
331
-
const s3Key = this.getS3Key(key);
330
+
async delete(key: string): Promise<void> {
331
+
const s3Key = this.getS3Key(key);
332
332
333
-
try {
334
-
const dataCommand = new DeleteObjectCommand({
335
-
Bucket: this.config.bucket,
336
-
Key: s3Key,
337
-
});
333
+
try {
334
+
const dataCommand = new DeleteObjectCommand({
335
+
Bucket: this.config.bucket,
336
+
Key: s3Key,
337
+
});
338
338
339
-
if (this.metadataBucket) {
340
-
const metadataCommand = new DeleteObjectCommand({
341
-
Bucket: this.metadataBucket,
342
-
Key: s3Key + '.meta',
343
-
});
339
+
if (this.metadataBucket) {
340
+
const metadataCommand = new DeleteObjectCommand({
341
+
Bucket: this.metadataBucket,
342
+
Key: s3Key + '.meta',
343
+
});
344
344
345
-
await Promise.all([
346
-
this.client.send(dataCommand),
347
-
this.client.send(metadataCommand).catch((error) => {
348
-
if (!this.isNoSuchKeyError(error)) throw error;
349
-
}),
350
-
]);
351
-
} else {
352
-
await this.client.send(dataCommand);
353
-
}
354
-
} catch (error) {
355
-
if (!this.isNoSuchKeyError(error)) {
356
-
throw error;
357
-
}
358
-
}
359
-
}
345
+
await Promise.all([
346
+
this.client.send(dataCommand),
347
+
this.client.send(metadataCommand).catch((error) => {
348
+
if (!this.isNoSuchKeyError(error)) throw error;
349
+
}),
350
+
]);
351
+
} else {
352
+
await this.client.send(dataCommand);
353
+
}
354
+
} catch (error) {
355
+
if (!this.isNoSuchKeyError(error)) {
356
+
throw error;
357
+
}
358
+
}
359
+
}
360
360
361
-
async exists(key: string): Promise<boolean> {
362
-
try {
363
-
const command = new HeadObjectCommand({
364
-
Bucket: this.config.bucket,
365
-
Key: this.getS3Key(key),
366
-
});
361
+
async exists(key: string): Promise<boolean> {
362
+
try {
363
+
const command = new HeadObjectCommand({
364
+
Bucket: this.config.bucket,
365
+
Key: this.getS3Key(key),
366
+
});
367
367
368
-
await this.client.send(command);
369
-
return true;
370
-
} catch (error) {
371
-
if (this.isNoSuchKeyError(error)) {
372
-
return false;
373
-
}
374
-
throw error;
375
-
}
376
-
}
368
+
await this.client.send(command);
369
+
return true;
370
+
} catch (error) {
371
+
if (this.isNoSuchKeyError(error)) {
372
+
return false;
373
+
}
374
+
throw error;
375
+
}
376
+
}
377
377
378
-
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
379
-
const s3Prefix = prefix ? this.getS3Key(prefix) : this.prefix;
380
-
let continuationToken: string | undefined;
378
+
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
379
+
const s3Prefix = prefix ? this.getS3Key(prefix) : this.prefix;
380
+
let continuationToken: string | undefined;
381
381
382
-
do {
383
-
const command = new ListObjectsV2Command({
384
-
Bucket: this.config.bucket,
385
-
Prefix: s3Prefix,
386
-
ContinuationToken: continuationToken,
387
-
});
382
+
do {
383
+
const command = new ListObjectsV2Command({
384
+
Bucket: this.config.bucket,
385
+
Prefix: s3Prefix,
386
+
ContinuationToken: continuationToken,
387
+
});
388
388
389
-
const response = await this.client.send(command);
389
+
const response = await this.client.send(command);
390
390
391
-
if (response.Contents) {
392
-
for (const object of response.Contents) {
393
-
if (object.Key) {
394
-
// Remove prefix to get original key
395
-
const key = this.removePrefix(object.Key);
396
-
yield key;
397
-
}
398
-
}
399
-
}
391
+
if (response.Contents) {
392
+
for (const object of response.Contents) {
393
+
if (object.Key) {
394
+
// Remove prefix to get original key
395
+
const key = this.removePrefix(object.Key);
396
+
yield key;
397
+
}
398
+
}
399
+
}
400
400
401
-
continuationToken = response.NextContinuationToken;
402
-
} while (continuationToken);
403
-
}
401
+
continuationToken = response.NextContinuationToken;
402
+
} while (continuationToken);
403
+
}
404
404
405
-
async deleteMany(keys: string[]): Promise<void> {
406
-
if (keys.length === 0) return;
405
+
async deleteMany(keys: string[]): Promise<void> {
406
+
if (keys.length === 0) return;
407
407
408
-
const batchSize = 1000;
408
+
const batchSize = 1000;
409
409
410
-
for (let i = 0; i < keys.length; i += batchSize) {
411
-
const batch = keys.slice(i, i + batchSize);
410
+
for (let i = 0; i < keys.length; i += batchSize) {
411
+
const batch = keys.slice(i, i + batchSize);
412
412
413
-
const dataCommand = new DeleteObjectsCommand({
414
-
Bucket: this.config.bucket,
415
-
Delete: {
416
-
Objects: batch.map((key) => ({ Key: this.getS3Key(key) })),
417
-
},
418
-
});
413
+
const dataCommand = new DeleteObjectsCommand({
414
+
Bucket: this.config.bucket,
415
+
Delete: {
416
+
Objects: batch.map((key) => ({ Key: this.getS3Key(key) })),
417
+
},
418
+
});
419
419
420
-
if (this.metadataBucket) {
421
-
const metadataCommand = new DeleteObjectsCommand({
422
-
Bucket: this.metadataBucket,
423
-
Delete: {
424
-
Objects: batch.map((key) => ({ Key: this.getS3Key(key) + '.meta' })),
425
-
},
426
-
});
420
+
if (this.metadataBucket) {
421
+
const metadataCommand = new DeleteObjectsCommand({
422
+
Bucket: this.metadataBucket,
423
+
Delete: {
424
+
Objects: batch.map((key) => ({ Key: this.getS3Key(key) + '.meta' })),
425
+
},
426
+
});
427
427
428
-
await Promise.all([
429
-
this.client.send(dataCommand),
430
-
this.client.send(metadataCommand).catch(() => {}),
431
-
]);
432
-
} else {
433
-
await this.client.send(dataCommand);
434
-
}
435
-
}
436
-
}
428
+
await Promise.all([
429
+
this.client.send(dataCommand),
430
+
this.client.send(metadataCommand).catch(() => {}),
431
+
]);
432
+
} else {
433
+
await this.client.send(dataCommand);
434
+
}
435
+
}
436
+
}
437
437
438
-
async getMetadata(key: string): Promise<StorageMetadata | null> {
439
-
if (this.metadataBucket) {
440
-
try {
441
-
const command = new GetObjectCommand({
442
-
Bucket: this.metadataBucket,
443
-
Key: this.getS3Key(key) + '.meta',
444
-
});
438
+
async getMetadata(key: string): Promise<StorageMetadata | null> {
439
+
if (this.metadataBucket) {
440
+
try {
441
+
const command = new GetObjectCommand({
442
+
Bucket: this.metadataBucket,
443
+
Key: this.getS3Key(key) + '.meta',
444
+
});
445
445
446
-
const response = await this.client.send(command);
446
+
const response = await this.client.send(command);
447
447
448
-
if (!response.Body) {
449
-
return null;
450
-
}
448
+
if (!response.Body) {
449
+
return null;
450
+
}
451
451
452
-
const buffer = await this.streamToUint8Array(response.Body as Readable);
453
-
const json = new TextDecoder().decode(buffer);
454
-
const metadata = JSON.parse(json) as StorageMetadata;
452
+
const buffer = await this.streamToUint8Array(response.Body as Readable);
453
+
const json = new TextDecoder().decode(buffer);
454
+
const metadata = JSON.parse(json) as StorageMetadata;
455
455
456
-
metadata.createdAt = new Date(metadata.createdAt);
457
-
metadata.lastAccessed = new Date(metadata.lastAccessed);
458
-
if (metadata.ttl) {
459
-
metadata.ttl = new Date(metadata.ttl);
460
-
}
456
+
metadata.createdAt = new Date(metadata.createdAt);
457
+
metadata.lastAccessed = new Date(metadata.lastAccessed);
458
+
if (metadata.ttl) {
459
+
metadata.ttl = new Date(metadata.ttl);
460
+
}
461
461
462
-
return metadata;
463
-
} catch (error) {
464
-
if (this.isNoSuchKeyError(error)) {
465
-
return null;
466
-
}
467
-
throw error;
468
-
}
469
-
}
462
+
return metadata;
463
+
} catch (error) {
464
+
if (this.isNoSuchKeyError(error)) {
465
+
return null;
466
+
}
467
+
throw error;
468
+
}
469
+
}
470
470
471
-
try {
472
-
const command = new HeadObjectCommand({
473
-
Bucket: this.config.bucket,
474
-
Key: this.getS3Key(key),
475
-
});
471
+
try {
472
+
const command = new HeadObjectCommand({
473
+
Bucket: this.config.bucket,
474
+
Key: this.getS3Key(key),
475
+
});
476
476
477
-
const response = await this.client.send(command);
477
+
const response = await this.client.send(command);
478
478
479
-
if (!response.Metadata) {
480
-
return null;
481
-
}
479
+
if (!response.Metadata) {
480
+
return null;
481
+
}
482
482
483
-
return this.s3ToMetadata(response.Metadata);
484
-
} catch (error) {
485
-
if (this.isNoSuchKeyError(error)) {
486
-
return null;
487
-
}
488
-
throw error;
489
-
}
490
-
}
483
+
return this.s3ToMetadata(response.Metadata);
484
+
} catch (error) {
485
+
if (this.isNoSuchKeyError(error)) {
486
+
return null;
487
+
}
488
+
throw error;
489
+
}
490
+
}
491
491
492
-
async setMetadata(key: string, metadata: StorageMetadata): Promise<void> {
493
-
if (this.metadataBucket) {
494
-
const metadataJson = JSON.stringify(metadata);
495
-
const buffer = new TextEncoder().encode(metadataJson);
492
+
async setMetadata(key: string, metadata: StorageMetadata): Promise<void> {
493
+
if (this.metadataBucket) {
494
+
const metadataJson = JSON.stringify(metadata);
495
+
const buffer = new TextEncoder().encode(metadataJson);
496
496
497
-
const command = new PutObjectCommand({
498
-
Bucket: this.metadataBucket,
499
-
Key: this.getS3Key(key) + '.meta',
500
-
Body: buffer,
501
-
ContentType: 'application/json',
502
-
});
497
+
const command = new PutObjectCommand({
498
+
Bucket: this.metadataBucket,
499
+
Key: this.getS3Key(key) + '.meta',
500
+
Body: buffer,
501
+
ContentType: 'application/json',
502
+
});
503
503
504
-
await this.client.send(command);
505
-
return;
506
-
}
504
+
await this.client.send(command);
505
+
return;
506
+
}
507
507
508
-
const s3Key = this.getS3Key(key);
509
-
const command = new CopyObjectCommand({
510
-
Bucket: this.config.bucket,
511
-
Key: s3Key,
512
-
CopySource: `${this.config.bucket}/${s3Key}`,
513
-
Metadata: this.metadataToS3(metadata),
514
-
MetadataDirective: 'REPLACE',
515
-
});
508
+
const s3Key = this.getS3Key(key);
509
+
const command = new CopyObjectCommand({
510
+
Bucket: this.config.bucket,
511
+
Key: s3Key,
512
+
CopySource: `${this.config.bucket}/${s3Key}`,
513
+
Metadata: this.metadataToS3(metadata),
514
+
MetadataDirective: 'REPLACE',
515
+
});
516
516
517
-
await this.client.send(command);
518
-
}
517
+
await this.client.send(command);
518
+
}
519
519
520
-
async getStats(): Promise<TierStats> {
521
-
let bytes = 0;
522
-
let items = 0;
520
+
async getStats(): Promise<TierStats> {
521
+
let bytes = 0;
522
+
let items = 0;
523
523
524
-
// List all objects and sum up sizes
525
-
let continuationToken: string | undefined;
524
+
// List all objects and sum up sizes
525
+
let continuationToken: string | undefined;
526
526
527
-
do {
528
-
const command = new ListObjectsV2Command({
529
-
Bucket: this.config.bucket,
530
-
Prefix: this.prefix,
531
-
ContinuationToken: continuationToken,
532
-
});
527
+
do {
528
+
const command = new ListObjectsV2Command({
529
+
Bucket: this.config.bucket,
530
+
Prefix: this.prefix,
531
+
ContinuationToken: continuationToken,
532
+
});
533
533
534
-
const response = await this.client.send(command);
534
+
const response = await this.client.send(command);
535
535
536
-
if (response.Contents) {
537
-
for (const object of response.Contents) {
538
-
items++;
539
-
bytes += object.Size ?? 0;
540
-
}
541
-
}
536
+
if (response.Contents) {
537
+
for (const object of response.Contents) {
538
+
items++;
539
+
bytes += object.Size ?? 0;
540
+
}
541
+
}
542
542
543
-
continuationToken = response.NextContinuationToken;
544
-
} while (continuationToken);
543
+
continuationToken = response.NextContinuationToken;
544
+
} while (continuationToken);
545
545
546
-
return { bytes, items };
547
-
}
546
+
return { bytes, items };
547
+
}
548
548
549
-
async clear(): Promise<void> {
550
-
// List and delete all objects with the prefix
551
-
const keys: string[] = [];
549
+
async clear(): Promise<void> {
550
+
// List and delete all objects with the prefix
551
+
const keys: string[] = [];
552
552
553
-
for await (const key of this.listKeys()) {
554
-
keys.push(key);
555
-
}
553
+
for await (const key of this.listKeys()) {
554
+
keys.push(key);
555
+
}
556
556
557
-
await this.deleteMany(keys);
558
-
}
557
+
await this.deleteMany(keys);
558
+
}
559
559
560
-
/**
561
-
* Get the full S3 key including prefix.
562
-
*/
563
-
private getS3Key(key: string): string {
564
-
return this.prefix + key;
565
-
}
560
+
/**
561
+
* Get the full S3 key including prefix.
562
+
*/
563
+
private getS3Key(key: string): string {
564
+
return this.prefix + key;
565
+
}
566
566
567
-
/**
568
-
* Remove the prefix from an S3 key to get the original key.
569
-
*/
570
-
private removePrefix(s3Key: string): string {
571
-
if (this.prefix && s3Key.startsWith(this.prefix)) {
572
-
return s3Key.slice(this.prefix.length);
573
-
}
574
-
return s3Key;
575
-
}
567
+
/**
568
+
* Remove the prefix from an S3 key to get the original key.
569
+
*/
570
+
private removePrefix(s3Key: string): string {
571
+
if (this.prefix && s3Key.startsWith(this.prefix)) {
572
+
return s3Key.slice(this.prefix.length);
573
+
}
574
+
return s3Key;
575
+
}
576
576
577
-
/**
578
-
* Convert StorageMetadata to S3 metadata format.
579
-
*
580
-
* @remarks
581
-
* S3 metadata keys must be lowercase and values must be strings.
582
-
* We serialize complex values as JSON.
583
-
*/
584
-
private metadataToS3(metadata: StorageMetadata): Record<string, string> {
585
-
return {
586
-
key: metadata.key,
587
-
size: metadata.size.toString(),
588
-
createdat: metadata.createdAt.toISOString(),
589
-
lastaccessed: metadata.lastAccessed.toISOString(),
590
-
accesscount: metadata.accessCount.toString(),
591
-
compressed: metadata.compressed.toString(),
592
-
checksum: metadata.checksum,
593
-
...(metadata.ttl && { ttl: metadata.ttl.toISOString() }),
594
-
...(metadata.mimeType && { mimetype: metadata.mimeType }),
595
-
...(metadata.encoding && { encoding: metadata.encoding }),
596
-
...(metadata.customMetadata && { custom: JSON.stringify(metadata.customMetadata) }),
597
-
};
598
-
}
577
+
/**
578
+
* Convert StorageMetadata to S3 metadata format.
579
+
*
580
+
* @remarks
581
+
* S3 metadata keys must be lowercase and values must be strings.
582
+
* We serialize complex values as JSON.
583
+
*/
584
+
private metadataToS3(metadata: StorageMetadata): Record<string, string> {
585
+
return {
586
+
key: metadata.key,
587
+
size: metadata.size.toString(),
588
+
createdat: metadata.createdAt.toISOString(),
589
+
lastaccessed: metadata.lastAccessed.toISOString(),
590
+
accesscount: metadata.accessCount.toString(),
591
+
compressed: metadata.compressed.toString(),
592
+
checksum: metadata.checksum,
593
+
...(metadata.ttl && { ttl: metadata.ttl.toISOString() }),
594
+
...(metadata.mimeType && { mimetype: metadata.mimeType }),
595
+
...(metadata.encoding && { encoding: metadata.encoding }),
596
+
...(metadata.customMetadata && { custom: JSON.stringify(metadata.customMetadata) }),
597
+
};
598
+
}
599
599
600
-
/**
601
-
* Convert S3 metadata to StorageMetadata format.
602
-
*/
603
-
private s3ToMetadata(s3Metadata: Record<string, string>): StorageMetadata {
604
-
const metadata: StorageMetadata = {
605
-
key: s3Metadata.key ?? '',
606
-
size: parseInt(s3Metadata.size ?? '0', 10),
607
-
createdAt: new Date(s3Metadata.createdat ?? Date.now()),
608
-
lastAccessed: new Date(s3Metadata.lastaccessed ?? Date.now()),
609
-
accessCount: parseInt(s3Metadata.accesscount ?? '0', 10),
610
-
compressed: s3Metadata.compressed === 'true',
611
-
checksum: s3Metadata.checksum ?? '',
612
-
};
600
+
/**
601
+
* Convert S3 metadata to StorageMetadata format.
602
+
*/
603
+
private s3ToMetadata(s3Metadata: Record<string, string>): StorageMetadata {
604
+
const metadata: StorageMetadata = {
605
+
key: s3Metadata.key ?? '',
606
+
size: parseInt(s3Metadata.size ?? '0', 10),
607
+
createdAt: new Date(s3Metadata.createdat ?? Date.now()),
608
+
lastAccessed: new Date(s3Metadata.lastaccessed ?? Date.now()),
609
+
accessCount: parseInt(s3Metadata.accesscount ?? '0', 10),
610
+
compressed: s3Metadata.compressed === 'true',
611
+
checksum: s3Metadata.checksum ?? '',
612
+
};
613
613
614
-
if (s3Metadata.ttl) {
615
-
metadata.ttl = new Date(s3Metadata.ttl);
616
-
}
614
+
if (s3Metadata.ttl) {
615
+
metadata.ttl = new Date(s3Metadata.ttl);
616
+
}
617
617
618
-
if (s3Metadata.mimetype) {
619
-
metadata.mimeType = s3Metadata.mimetype;
620
-
}
618
+
if (s3Metadata.mimetype) {
619
+
metadata.mimeType = s3Metadata.mimetype;
620
+
}
621
621
622
-
if (s3Metadata.encoding) {
623
-
metadata.encoding = s3Metadata.encoding;
624
-
}
622
+
if (s3Metadata.encoding) {
623
+
metadata.encoding = s3Metadata.encoding;
624
+
}
625
625
626
-
if (s3Metadata.custom) {
627
-
try {
628
-
metadata.customMetadata = JSON.parse(s3Metadata.custom);
629
-
} catch {
630
-
// Ignore invalid JSON
631
-
}
632
-
}
626
+
if (s3Metadata.custom) {
627
+
try {
628
+
metadata.customMetadata = JSON.parse(s3Metadata.custom);
629
+
} catch {
630
+
// Ignore invalid JSON
631
+
}
632
+
}
633
633
634
-
return metadata;
635
-
}
634
+
return metadata;
635
+
}
636
636
}
+299
-299
src/types/index.ts
+299
-299
src/types/index.ts
···
9
9
* - Content type information for HTTP serving
10
10
*/
11
11
export interface StorageMetadata {
12
-
/** Original key used to store the data (human-readable) */
13
-
key: string;
12
+
/** Original key used to store the data (human-readable) */
13
+
key: string;
14
14
15
-
/** Size of the data in bytes (uncompressed size) */
16
-
size: number;
15
+
/** Size of the data in bytes (uncompressed size) */
16
+
size: number;
17
17
18
-
/** Timestamp when the data was first created */
19
-
createdAt: Date;
18
+
/** Timestamp when the data was first created */
19
+
createdAt: Date;
20
20
21
-
/** Timestamp when the data was last accessed */
22
-
lastAccessed: Date;
21
+
/** Timestamp when the data was last accessed */
22
+
lastAccessed: Date;
23
23
24
-
/** Number of times this data has been accessed */
25
-
accessCount: number;
24
+
/** Number of times this data has been accessed */
25
+
accessCount: number;
26
26
27
-
/** Optional expiration timestamp. Data expires when current time > ttl */
28
-
ttl?: Date;
27
+
/** Optional expiration timestamp. Data expires when current time > ttl */
28
+
ttl?: Date;
29
29
30
-
/** Whether the data is compressed (e.g., with gzip) */
31
-
compressed: boolean;
30
+
/** Whether the data is compressed (e.g., with gzip) */
31
+
compressed: boolean;
32
32
33
-
/** SHA256 checksum of the data for integrity verification */
34
-
checksum: string;
33
+
/** SHA256 checksum of the data for integrity verification */
34
+
checksum: string;
35
35
36
-
/** Optional MIME type (e.g., 'text/html', 'application/json') */
37
-
mimeType?: string;
36
+
/** Optional MIME type (e.g., 'text/html', 'application/json') */
37
+
mimeType?: string;
38
38
39
-
/** Optional encoding (e.g., 'gzip', 'base64') */
40
-
encoding?: string;
39
+
/** Optional encoding (e.g., 'gzip', 'base64') */
40
+
encoding?: string;
41
41
42
-
/** User-defined metadata fields */
43
-
customMetadata?: Record<string, string>;
42
+
/** User-defined metadata fields */
43
+
customMetadata?: Record<string, string>;
44
44
}
45
45
46
46
/**
···
50
50
* Used for monitoring cache performance and capacity planning.
51
51
*/
52
52
export interface TierStats {
53
-
/** Total bytes stored in this tier */
54
-
bytes: number;
53
+
/** Total bytes stored in this tier */
54
+
bytes: number;
55
55
56
-
/** Total number of items stored in this tier */
57
-
items: number;
56
+
/** Total number of items stored in this tier */
57
+
items: number;
58
58
59
-
/** Number of cache hits (only tracked if tier implements hit tracking) */
60
-
hits?: number;
59
+
/** Number of cache hits (only tracked if tier implements hit tracking) */
60
+
hits?: number;
61
61
62
-
/** Number of cache misses (only tracked if tier implements miss tracking) */
63
-
misses?: number;
62
+
/** Number of cache misses (only tracked if tier implements miss tracking) */
63
+
misses?: number;
64
64
65
-
/** Number of evictions due to size/count limits (only tracked if tier implements eviction) */
66
-
evictions?: number;
65
+
/** Number of evictions due to size/count limits (only tracked if tier implements eviction) */
66
+
evictions?: number;
67
67
}
68
68
69
69
/**
···
73
73
* Provides a complete view of cache performance across the entire storage hierarchy.
74
74
*/
75
75
export interface AllTierStats {
76
-
/** Statistics for hot tier (if configured) */
77
-
hot?: TierStats;
76
+
/** Statistics for hot tier (if configured) */
77
+
hot?: TierStats;
78
78
79
-
/** Statistics for warm tier (if configured) */
80
-
warm?: TierStats;
79
+
/** Statistics for warm tier (if configured) */
80
+
warm?: TierStats;
81
81
82
-
/** Statistics for cold tier (always present) */
83
-
cold: TierStats;
82
+
/** Statistics for cold tier (always present) */
83
+
cold: TierStats;
84
84
85
-
/** Total hits across all tiers */
86
-
totalHits: number;
85
+
/** Total hits across all tiers */
86
+
totalHits: number;
87
87
88
-
/** Total misses across all tiers */
89
-
totalMisses: number;
88
+
/** Total misses across all tiers */
89
+
totalMisses: number;
90
90
91
-
/** Hit rate as a percentage (0-1) */
92
-
hitRate: number;
91
+
/** Hit rate as a percentage (0-1) */
92
+
hitRate: number;
93
93
}
94
94
95
95
/**
···
103
103
* @example
104
104
* ```typescript
105
105
* class RedisStorageTier implements StorageTier {
106
-
* constructor(private client: RedisClient) {}
106
+
* constructor(private client: RedisClient) {}
107
107
*
108
-
* async get(key: string): Promise<Uint8Array | null> {
109
-
* const buffer = await this.client.getBuffer(key);
110
-
* return buffer ? new Uint8Array(buffer) : null;
111
-
* }
108
+
* async get(key: string): Promise<Uint8Array | null> {
109
+
* const buffer = await this.client.getBuffer(key);
110
+
* return buffer ? new Uint8Array(buffer) : null;
111
+
* }
112
112
*
113
-
* // ... implement other methods
113
+
* // ... implement other methods
114
114
* }
115
115
* ```
116
116
*/
···
118
118
* Result from a combined get+metadata operation on a tier.
119
119
*/
120
120
export interface TierGetResult {
121
-
/** The retrieved data */
122
-
data: Uint8Array;
123
-
/** Metadata associated with the data */
124
-
metadata: StorageMetadata;
121
+
/** The retrieved data */
122
+
data: Uint8Array;
123
+
/** Metadata associated with the data */
124
+
metadata: StorageMetadata;
125
125
}
126
126
127
127
export interface StorageTier {
128
-
/**
129
-
* Retrieve data for a key.
130
-
*
131
-
* @param key - The key to retrieve
132
-
* @returns The data as a Uint8Array, or null if not found
133
-
*/
134
-
get(key: string): Promise<Uint8Array | null>;
128
+
/**
129
+
* Retrieve data for a key.
130
+
*
131
+
* @param key - The key to retrieve
132
+
* @returns The data as a Uint8Array, or null if not found
133
+
*/
134
+
get(key: string): Promise<Uint8Array | null>;
135
135
136
-
/**
137
-
* Retrieve data and metadata together in a single operation.
138
-
*
139
-
* @param key - The key to retrieve
140
-
* @returns The data and metadata, or null if not found
141
-
*
142
-
* @remarks
143
-
* This is more efficient than calling get() and getMetadata() separately,
144
-
* especially for disk and network-based tiers.
145
-
*/
146
-
getWithMetadata?(key: string): Promise<TierGetResult | null>;
136
+
/**
137
+
* Retrieve data and metadata together in a single operation.
138
+
*
139
+
* @param key - The key to retrieve
140
+
* @returns The data and metadata, or null if not found
141
+
*
142
+
* @remarks
143
+
* This is more efficient than calling get() and getMetadata() separately,
144
+
* especially for disk and network-based tiers.
145
+
*/
146
+
getWithMetadata?(key: string): Promise<TierGetResult | null>;
147
147
148
-
/**
149
-
* Store data with associated metadata.
150
-
*
151
-
* @param key - The key to store under
152
-
* @param data - The data to store (as Uint8Array)
153
-
* @param metadata - Metadata to store alongside the data
154
-
*
155
-
* @remarks
156
-
* If the key already exists, it should be overwritten.
157
-
*/
158
-
set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void>;
148
+
/**
149
+
* Store data with associated metadata.
150
+
*
151
+
* @param key - The key to store under
152
+
* @param data - The data to store (as Uint8Array)
153
+
* @param metadata - Metadata to store alongside the data
154
+
*
155
+
* @remarks
156
+
* If the key already exists, it should be overwritten.
157
+
*/
158
+
set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void>;
159
159
160
-
/**
161
-
* Delete data for a key.
162
-
*
163
-
* @param key - The key to delete
164
-
*
165
-
* @remarks
166
-
* Should not throw if the key doesn't exist.
167
-
*/
168
-
delete(key: string): Promise<void>;
160
+
/**
161
+
* Delete data for a key.
162
+
*
163
+
* @param key - The key to delete
164
+
*
165
+
* @remarks
166
+
* Should not throw if the key doesn't exist.
167
+
*/
168
+
delete(key: string): Promise<void>;
169
169
170
-
/**
171
-
* Check if a key exists in this tier.
172
-
*
173
-
* @param key - The key to check
174
-
* @returns true if the key exists, false otherwise
175
-
*/
176
-
exists(key: string): Promise<boolean>;
170
+
/**
171
+
* Check if a key exists in this tier.
172
+
*
173
+
* @param key - The key to check
174
+
* @returns true if the key exists, false otherwise
175
+
*/
176
+
exists(key: string): Promise<boolean>;
177
177
178
-
/**
179
-
* List all keys in this tier, optionally filtered by prefix.
180
-
*
181
-
* @param prefix - Optional prefix to filter keys (e.g., 'user:' matches 'user:123', 'user:456')
182
-
* @returns An async iterator of keys
183
-
*
184
-
* @remarks
185
-
* This should be memory-efficient and stream keys rather than loading all into memory.
186
-
* Useful for prefix-based invalidation and cache warming.
187
-
*
188
-
* @example
189
-
* ```typescript
190
-
* for await (const key of tier.listKeys('site:')) {
191
-
* console.log(key); // 'site:abc', 'site:xyz', etc.
192
-
* }
193
-
* ```
194
-
*/
195
-
listKeys(prefix?: string): AsyncIterableIterator<string>;
178
+
/**
179
+
* List all keys in this tier, optionally filtered by prefix.
180
+
*
181
+
* @param prefix - Optional prefix to filter keys (e.g., 'user:' matches 'user:123', 'user:456')
182
+
* @returns An async iterator of keys
183
+
*
184
+
* @remarks
185
+
* This should be memory-efficient and stream keys rather than loading all into memory.
186
+
* Useful for prefix-based invalidation and cache warming.
187
+
*
188
+
* @example
189
+
* ```typescript
190
+
* for await (const key of tier.listKeys('site:')) {
191
+
* console.log(key); // 'site:abc', 'site:xyz', etc.
192
+
* }
193
+
* ```
194
+
*/
195
+
listKeys(prefix?: string): AsyncIterableIterator<string>;
196
196
197
-
/**
198
-
* Delete multiple keys in a single operation.
199
-
*
200
-
* @param keys - Array of keys to delete
201
-
*
202
-
* @remarks
203
-
* This is more efficient than calling delete() in a loop.
204
-
* Implementations should batch deletions where possible.
205
-
*/
206
-
deleteMany(keys: string[]): Promise<void>;
197
+
/**
198
+
* Delete multiple keys in a single operation.
199
+
*
200
+
* @param keys - Array of keys to delete
201
+
*
202
+
* @remarks
203
+
* This is more efficient than calling delete() in a loop.
204
+
* Implementations should batch deletions where possible.
205
+
*/
206
+
deleteMany(keys: string[]): Promise<void>;
207
207
208
-
/**
209
-
* Retrieve metadata for a key without fetching the data.
210
-
*
211
-
* @param key - The key to get metadata for
212
-
* @returns The metadata, or null if not found
213
-
*
214
-
* @remarks
215
-
* This is useful for checking TTL, access counts, etc. without loading large data.
216
-
*/
217
-
getMetadata(key: string): Promise<StorageMetadata | null>;
208
+
/**
209
+
* Retrieve metadata for a key without fetching the data.
210
+
*
211
+
* @param key - The key to get metadata for
212
+
* @returns The metadata, or null if not found
213
+
*
214
+
* @remarks
215
+
* This is useful for checking TTL, access counts, etc. without loading large data.
216
+
*/
217
+
getMetadata(key: string): Promise<StorageMetadata | null>;
218
218
219
-
/**
220
-
* Update metadata for a key without modifying the data.
221
-
*
222
-
* @param key - The key to update metadata for
223
-
* @param metadata - The new metadata
224
-
*
225
-
* @remarks
226
-
* Useful for updating TTL (via touch()) or access counts.
227
-
*/
228
-
setMetadata(key: string, metadata: StorageMetadata): Promise<void>;
219
+
/**
220
+
* Update metadata for a key without modifying the data.
221
+
*
222
+
* @param key - The key to update metadata for
223
+
* @param metadata - The new metadata
224
+
*
225
+
* @remarks
226
+
* Useful for updating TTL (via touch()) or access counts.
227
+
*/
228
+
setMetadata(key: string, metadata: StorageMetadata): Promise<void>;
229
229
230
-
/**
231
-
* Get statistics about this tier.
232
-
*
233
-
* @returns Statistics including size, item count, hits, misses, etc.
234
-
*/
235
-
getStats(): Promise<TierStats>;
230
+
/**
231
+
* Get statistics about this tier.
232
+
*
233
+
* @returns Statistics including size, item count, hits, misses, etc.
234
+
*/
235
+
getStats(): Promise<TierStats>;
236
236
237
-
/**
238
-
* Clear all data from this tier.
239
-
*
240
-
* @remarks
241
-
* Use with caution! This will delete all data in the tier.
242
-
*/
243
-
clear(): Promise<void>;
237
+
/**
238
+
* Clear all data from this tier.
239
+
*
240
+
* @remarks
241
+
* Use with caution! This will delete all data in the tier.
242
+
*/
243
+
clear(): Promise<void>;
244
244
}
245
245
246
246
/**
···
254
254
* @example
255
255
* ```typescript
256
256
* placementRules: [
257
-
* { pattern: 'index.html', tiers: ['hot', 'warm', 'cold'] },
258
-
* { pattern: '*.html', tiers: ['warm', 'cold'] },
259
-
* { pattern: 'assets/**', tiers: ['warm', 'cold'] },
260
-
* { pattern: '**', tiers: ['warm', 'cold'] }, // default
257
+
* { pattern: 'index.html', tiers: ['hot', 'warm', 'cold'] },
258
+
* { pattern: '*.html', tiers: ['warm', 'cold'] },
259
+
* { pattern: 'assets/**', tiers: ['warm', 'cold'] },
260
+
* { pattern: '**', tiers: ['warm', 'cold'] }, // default
261
261
* ]
262
262
* ```
263
263
*/
264
264
export interface PlacementRule {
265
-
/**
266
-
* Glob pattern to match against keys.
267
-
*
268
-
* @remarks
269
-
* Supports basic globs:
270
-
* - `*` matches any characters except `/`
271
-
* - `**` matches any characters including `/`
272
-
* - Exact matches work too: `index.html`
273
-
*/
274
-
pattern: string;
265
+
/**
266
+
* Glob pattern to match against keys.
267
+
*
268
+
* @remarks
269
+
* Supports basic globs:
270
+
* - `*` matches any characters except `/`
271
+
* - `**` matches any characters including `/`
272
+
* - Exact matches work too: `index.html`
273
+
*/
274
+
pattern: string;
275
275
276
-
/**
277
-
* Which tiers to write to for matching keys.
278
-
*
279
-
* @remarks
280
-
* Cold is always included (source of truth).
281
-
* Use `['hot', 'warm', 'cold']` for critical files.
282
-
* Use `['warm', 'cold']` for large files.
283
-
* Use `['cold']` for archival only.
284
-
*/
285
-
tiers: ('hot' | 'warm' | 'cold')[];
276
+
/**
277
+
* Which tiers to write to for matching keys.
278
+
*
279
+
* @remarks
280
+
* Cold is always included (source of truth).
281
+
* Use `['hot', 'warm', 'cold']` for critical files.
282
+
* Use `['warm', 'cold']` for large files.
283
+
* Use `['cold']` for archival only.
284
+
*/
285
+
tiers: ('hot' | 'warm' | 'cold')[];
286
286
}
287
287
288
288
/**
···
299
299
* Data flows down on writes (hot → warm → cold) and bubbles up on reads (cold → warm → hot).
300
300
*/
301
301
export interface TieredStorageConfig {
302
-
/** Storage tier configuration */
303
-
tiers: {
304
-
/** Optional hot tier - fastest, smallest capacity (e.g., in-memory, Redis) */
305
-
hot?: StorageTier;
302
+
/** Storage tier configuration */
303
+
tiers: {
304
+
/** Optional hot tier - fastest, smallest capacity (e.g., in-memory, Redis) */
305
+
hot?: StorageTier;
306
306
307
-
/** Optional warm tier - medium speed, medium capacity (e.g., disk, SQLite, Postgres) */
308
-
warm?: StorageTier;
307
+
/** Optional warm tier - medium speed, medium capacity (e.g., disk, SQLite, Postgres) */
308
+
warm?: StorageTier;
309
309
310
-
/** Required cold tier - slowest, largest capacity (e.g., S3, R2, object storage) */
311
-
cold: StorageTier;
312
-
};
310
+
/** Required cold tier - slowest, largest capacity (e.g., S3, R2, object storage) */
311
+
cold: StorageTier;
312
+
};
313
313
314
-
/** Rules for automatic tier placement based on key patterns. First match wins. */
315
-
placementRules?: PlacementRule[];
314
+
/** Rules for automatic tier placement based on key patterns. First match wins. */
315
+
placementRules?: PlacementRule[];
316
316
317
-
/**
318
-
* Whether to automatically compress data before storing.
319
-
*
320
-
* @defaultValue false
321
-
*
322
-
* @remarks
323
-
* Uses gzip compression. Compression is transparent - data is automatically
324
-
* decompressed on retrieval. The `compressed` flag in metadata indicates compression state.
325
-
*/
326
-
compression?: boolean;
317
+
/**
318
+
* Whether to automatically compress data before storing.
319
+
*
320
+
* @defaultValue false
321
+
*
322
+
* @remarks
323
+
* Uses gzip compression. Compression is transparent - data is automatically
324
+
* decompressed on retrieval. The `compressed` flag in metadata indicates compression state.
325
+
*/
326
+
compression?: boolean;
327
327
328
-
/**
329
-
* Default TTL (time-to-live) in milliseconds.
330
-
*
331
-
* @remarks
332
-
* Data will expire after this duration. Can be overridden per-key via SetOptions.
333
-
* If not set, data never expires.
334
-
*/
335
-
defaultTTL?: number;
328
+
/**
329
+
* Default TTL (time-to-live) in milliseconds.
330
+
*
331
+
* @remarks
332
+
* Data will expire after this duration. Can be overridden per-key via SetOptions.
333
+
* If not set, data never expires.
334
+
*/
335
+
defaultTTL?: number;
336
336
337
-
/**
338
-
* Strategy for promoting data to upper tiers on cache miss.
339
-
*
340
-
* @defaultValue 'lazy'
341
-
*
342
-
* @remarks
343
-
* - 'eager': Immediately promote data to all upper tiers on read
344
-
* - 'lazy': Don't automatically promote; rely on explicit promotion or next write
345
-
*
346
-
* Eager promotion increases hot tier hit rate but adds write overhead.
347
-
* Lazy promotion reduces writes but may serve from lower tiers more often.
348
-
*/
349
-
promotionStrategy?: 'eager' | 'lazy';
337
+
/**
338
+
* Strategy for promoting data to upper tiers on cache miss.
339
+
*
340
+
* @defaultValue 'lazy'
341
+
*
342
+
* @remarks
343
+
* - 'eager': Immediately promote data to all upper tiers on read
344
+
* - 'lazy': Don't automatically promote; rely on explicit promotion or next write
345
+
*
346
+
* Eager promotion increases hot tier hit rate but adds write overhead.
347
+
* Lazy promotion reduces writes but may serve from lower tiers more often.
348
+
*/
349
+
promotionStrategy?: 'eager' | 'lazy';
350
350
351
-
/**
352
-
* Custom serialization/deserialization functions.
353
-
*
354
-
* @remarks
355
-
* By default, JSON serialization is used. Provide custom functions for:
356
-
* - Non-JSON types (e.g., Buffer, custom classes)
357
-
* - Performance optimization (e.g., msgpack, protobuf)
358
-
* - Encryption (serialize includes encryption, deserialize includes decryption)
359
-
*/
360
-
serialization?: {
361
-
/** Convert data to Uint8Array for storage */
362
-
serialize: (data: unknown) => Promise<Uint8Array>;
351
+
/**
352
+
* Custom serialization/deserialization functions.
353
+
*
354
+
* @remarks
355
+
* By default, JSON serialization is used. Provide custom functions for:
356
+
* - Non-JSON types (e.g., Buffer, custom classes)
357
+
* - Performance optimization (e.g., msgpack, protobuf)
358
+
* - Encryption (serialize includes encryption, deserialize includes decryption)
359
+
*/
360
+
serialization?: {
361
+
/** Convert data to Uint8Array for storage */
362
+
serialize: (data: unknown) => Promise<Uint8Array>;
363
363
364
-
/** Convert Uint8Array back to original data */
365
-
deserialize: (data: Uint8Array) => Promise<unknown>;
366
-
};
364
+
/** Convert Uint8Array back to original data */
365
+
deserialize: (data: Uint8Array) => Promise<unknown>;
366
+
};
367
367
}
368
368
369
369
/**
···
373
373
* These options allow fine-grained control over where and how data is stored.
374
374
*/
375
375
export interface SetOptions {
376
-
/**
377
-
* Custom TTL in milliseconds for this specific key.
378
-
*
379
-
* @remarks
380
-
* Overrides the default TTL from TieredStorageConfig.
381
-
* Data will expire after this duration from the current time.
382
-
*/
383
-
ttl?: number;
376
+
/**
377
+
* Custom TTL in milliseconds for this specific key.
378
+
*
379
+
* @remarks
380
+
* Overrides the default TTL from TieredStorageConfig.
381
+
* Data will expire after this duration from the current time.
382
+
*/
383
+
ttl?: number;
384
384
385
-
/**
386
-
* Custom metadata to attach to this key.
387
-
*
388
-
* @remarks
389
-
* Merged with system-generated metadata (size, checksum, timestamps).
390
-
* Useful for storing application-specific information like content-type, encoding, etc.
391
-
*/
392
-
metadata?: Record<string, string>;
385
+
/**
386
+
* Custom metadata to attach to this key.
387
+
*
388
+
* @remarks
389
+
* Merged with system-generated metadata (size, checksum, timestamps).
390
+
* Useful for storing application-specific information like content-type, encoding, etc.
391
+
*/
392
+
metadata?: Record<string, string>;
393
393
394
-
/**
395
-
* Skip writing to specific tiers.
396
-
*
397
-
* @remarks
398
-
* Useful for controlling which tiers receive data. For example:
399
-
* - Large files: `skipTiers: ['hot']` to avoid filling memory
400
-
* - Small critical files: Write to hot only for fastest access
401
-
*
402
-
* Note: Cold tier can never be skipped (it's the source of truth).
403
-
*
404
-
* @example
405
-
* ```typescript
406
-
* // Store large file only in warm and cold (skip memory)
407
-
* await storage.set('large-video.mp4', videoData, { skipTiers: ['hot'] });
408
-
*
409
-
* // Store index.html in all tiers for fast access
410
-
* await storage.set('index.html', htmlData); // No skipping
411
-
* ```
412
-
*/
413
-
skipTiers?: ('hot' | 'warm')[];
394
+
/**
395
+
* Skip writing to specific tiers.
396
+
*
397
+
* @remarks
398
+
* Useful for controlling which tiers receive data. For example:
399
+
* - Large files: `skipTiers: ['hot']` to avoid filling memory
400
+
* - Small critical files: Write to hot only for fastest access
401
+
*
402
+
* Note: Cold tier can never be skipped (it's the source of truth).
403
+
*
404
+
* @example
405
+
* ```typescript
406
+
* // Store large file only in warm and cold (skip memory)
407
+
* await storage.set('large-video.mp4', videoData, { skipTiers: ['hot'] });
408
+
*
409
+
* // Store index.html in all tiers for fast access
410
+
* await storage.set('index.html', htmlData); // No skipping
411
+
* ```
412
+
*/
413
+
skipTiers?: ('hot' | 'warm')[];
414
414
}
415
415
416
416
/**
···
422
422
* Includes both the data and information about where it was served from.
423
423
*/
424
424
export interface StorageResult<T> {
425
-
/** The retrieved data */
426
-
data: T;
425
+
/** The retrieved data */
426
+
data: T;
427
427
428
-
/** Metadata associated with the data */
429
-
metadata: StorageMetadata;
428
+
/** Metadata associated with the data */
429
+
metadata: StorageMetadata;
430
430
431
-
/** Which tier the data was served from */
432
-
source: 'hot' | 'warm' | 'cold';
431
+
/** Which tier the data was served from */
432
+
source: 'hot' | 'warm' | 'cold';
433
433
}
434
434
435
435
/**
···
439
439
* Indicates which tiers successfully received the data.
440
440
*/
441
441
export interface SetResult {
442
-
/** The key that was set */
443
-
key: string;
442
+
/** The key that was set */
443
+
key: string;
444
444
445
-
/** Metadata that was stored with the data */
446
-
metadata: StorageMetadata;
445
+
/** Metadata that was stored with the data */
446
+
metadata: StorageMetadata;
447
447
448
-
/** Which tiers received the data */
449
-
tiersWritten: ('hot' | 'warm' | 'cold')[];
448
+
/** Which tiers received the data */
449
+
tiersWritten: ('hot' | 'warm' | 'cold')[];
450
450
}
451
451
452
452
/**
···
457
457
* The snapshot includes metadata but not the actual data (data remains in tiers).
458
458
*/
459
459
export interface StorageSnapshot {
460
-
/** Snapshot format version (for compatibility) */
461
-
version: number;
460
+
/** Snapshot format version (for compatibility) */
461
+
version: number;
462
462
463
-
/** When this snapshot was created */
464
-
exportedAt: Date;
463
+
/** When this snapshot was created */
464
+
exportedAt: Date;
465
465
466
-
/** All keys present in cold tier (source of truth) */
467
-
keys: string[];
466
+
/** All keys present in cold tier (source of truth) */
467
+
keys: string[];
468
468
469
-
/** Metadata for each key */
470
-
metadata: Record<string, StorageMetadata>;
469
+
/** Metadata for each key */
470
+
metadata: Record<string, StorageMetadata>;
471
471
472
-
/** Statistics at time of export */
473
-
stats: AllTierStats;
472
+
/** Statistics at time of export */
473
+
stats: AllTierStats;
474
474
}
+15
-15
src/utils/checksum.ts
+15
-15
src/utils/checksum.ts
···
18
18
* ```
19
19
*/
20
20
export function calculateChecksum(data: Uint8Array): string {
21
-
const hash = createHash('sha256');
22
-
hash.update(data);
23
-
return hash.digest('hex');
21
+
const hash = createHash('sha256');
22
+
hash.update(data);
23
+
return hash.digest('hex');
24
24
}
25
25
26
26
/**
···
37
37
* ```typescript
38
38
* const isValid = verifyChecksum(data, metadata.checksum);
39
39
* if (!isValid) {
40
-
* throw new Error('Data corruption detected');
40
+
* throw new Error('Data corruption detected');
41
41
* }
42
42
* ```
43
43
*/
44
44
export function verifyChecksum(data: Uint8Array, expectedChecksum: string): boolean {
45
-
const actualChecksum = calculateChecksum(data);
45
+
const actualChecksum = calculateChecksum(data);
46
46
47
-
// Use constant-time comparison to prevent timing attacks
48
-
try {
49
-
return timingSafeEqual(
50
-
Buffer.from(actualChecksum, 'hex'),
51
-
Buffer.from(expectedChecksum, 'hex')
52
-
);
53
-
} catch {
54
-
// If checksums have different lengths, timingSafeEqual throws
55
-
return false;
56
-
}
47
+
// Use constant-time comparison to prevent timing attacks
48
+
try {
49
+
return timingSafeEqual(
50
+
Buffer.from(actualChecksum, 'hex'),
51
+
Buffer.from(expectedChecksum, 'hex')
52
+
);
53
+
} catch {
54
+
// If checksums have different lengths, timingSafeEqual throws
55
+
return false;
56
+
}
57
57
}
+13
-13
src/utils/compression.ts
+13
-13
src/utils/compression.ts
···
22
22
* ```
23
23
*/
24
24
export async function compress(data: Uint8Array): Promise<Uint8Array> {
25
-
const buffer = Buffer.from(data);
26
-
const compressed = await gzipAsync(buffer);
27
-
return new Uint8Array(compressed);
25
+
const buffer = Buffer.from(data);
26
+
const compressed = await gzipAsync(buffer);
27
+
return new Uint8Array(compressed);
28
28
}
29
29
30
30
/**
···
44
44
* ```
45
45
*/
46
46
export async function decompress(data: Uint8Array): Promise<Uint8Array> {
47
-
// Validate gzip magic bytes
48
-
if (data.length < 2 || data[0] !== 0x1f || data[1] !== 0x8b) {
49
-
throw new Error('Invalid gzip data: missing magic bytes');
50
-
}
47
+
// Validate gzip magic bytes
48
+
if (data.length < 2 || data[0] !== 0x1f || data[1] !== 0x8b) {
49
+
throw new Error('Invalid gzip data: missing magic bytes');
50
+
}
51
51
52
-
const buffer = Buffer.from(data);
53
-
const decompressed = await gunzipAsync(buffer);
54
-
return new Uint8Array(decompressed);
52
+
const buffer = Buffer.from(data);
53
+
const decompressed = await gunzipAsync(buffer);
54
+
return new Uint8Array(decompressed);
55
55
}
56
56
57
57
/**
···
67
67
* @example
68
68
* ```typescript
69
69
* if (isGzipped(data)) {
70
-
* console.log('Already compressed, skipping compression');
70
+
* console.log('Already compressed, skipping compression');
71
71
* } else {
72
-
* data = await compress(data);
72
+
* data = await compress(data);
73
73
* }
74
74
* ```
75
75
*/
76
76
export function isGzipped(data: Uint8Array): boolean {
77
-
return data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b;
77
+
return data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b;
78
78
}
+23
-23
src/utils/glob.ts
+23
-23
src/utils/glob.ts
···
8
8
* - Exact strings match exactly
9
9
*/
10
10
export function matchGlob(pattern: string, key: string): boolean {
11
-
// Handle exact match
12
-
if (!pattern.includes('*') && !pattern.includes('{')) {
13
-
return pattern === key;
14
-
}
11
+
// Handle exact match
12
+
if (!pattern.includes('*') && !pattern.includes('{')) {
13
+
return pattern === key;
14
+
}
15
15
16
-
// Escape regex special chars (except * and {})
17
-
let regex = pattern.replace(/[.+^$|\\()[\]]/g, '\\$&');
16
+
// Escape regex special chars (except * and {})
17
+
let regex = pattern.replace(/[.+^$|\\()[\]]/g, '\\$&');
18
18
19
-
// Handle {a,b,c} alternation
20
-
regex = regex.replace(/\{([^}]+)\}/g, (_, alts) => `(${alts.split(',').join('|')})`);
19
+
// Handle {a,b,c} alternation
20
+
regex = regex.replace(/\{([^}]+)\}/g, (_, alts) => `(${alts.split(',').join('|')})`);
21
21
22
-
// Use placeholder to avoid double-processing
23
-
const DOUBLE = '\x00DOUBLE\x00';
24
-
const SINGLE = '\x00SINGLE\x00';
22
+
// Use placeholder to avoid double-processing
23
+
const DOUBLE = '\x00DOUBLE\x00';
24
+
const SINGLE = '\x00SINGLE\x00';
25
25
26
-
// Mark ** and * with placeholders
27
-
regex = regex.replace(/\*\*/g, DOUBLE);
28
-
regex = regex.replace(/\*/g, SINGLE);
26
+
// Mark ** and * with placeholders
27
+
regex = regex.replace(/\*\*/g, DOUBLE);
28
+
regex = regex.replace(/\*/g, SINGLE);
29
29
30
-
// Replace placeholders with regex patterns
31
-
// ** matches anything (including /)
32
-
// When followed by /, it's optional (matches zero or more path segments)
33
-
regex = regex
34
-
.replace(new RegExp(`${DOUBLE}/`, 'g'), '(?:.*/)?') // **/ -> optional path prefix
35
-
.replace(new RegExp(`/${DOUBLE}`, 'g'), '(?:/.*)?') // /** -> optional path suffix
36
-
.replace(new RegExp(DOUBLE, 'g'), '.*') // ** alone -> match anything
37
-
.replace(new RegExp(SINGLE, 'g'), '[^/]*'); // * -> match non-slash
30
+
// Replace placeholders with regex patterns
31
+
// ** matches anything (including /)
32
+
// When followed by /, it's optional (matches zero or more path segments)
33
+
regex = regex
34
+
.replace(new RegExp(`${DOUBLE}/`, 'g'), '(?:.*/)?') // **/ -> optional path prefix
35
+
.replace(new RegExp(`/${DOUBLE}`, 'g'), '(?:/.*)?') // /** -> optional path suffix
36
+
.replace(new RegExp(DOUBLE, 'g'), '.*') // ** alone -> match anything
37
+
.replace(new RegExp(SINGLE, 'g'), '[^/]*'); // * -> match non-slash
38
38
39
-
return new RegExp(`^${regex}$`).test(key);
39
+
return new RegExp(`^${regex}$`).test(key);
40
40
}
+29
-30
src/utils/path-encoding.ts
+29
-30
src/utils/path-encoding.ts
···
5
5
* @returns Filesystem-safe encoded key
6
6
*
7
7
* @remarks
8
+
* Preserves forward slashes to create directory structure.
8
9
* Encodes characters that are problematic in filenames:
9
-
* - Forward slash (/) → %2F
10
10
* - Backslash (\) → %5C
11
-
* - Colon (:) → %3A
11
+
* - Colon (:) → %3A (invalid on Windows)
12
12
* - Asterisk (*) → %2A
13
13
* - Question mark (?) → %3F
14
14
* - Quote (") → %22
···
20
20
*
21
21
* @example
22
22
* ```typescript
23
-
* const key = 'user:123/profile.json';
23
+
* const key = 'did:plc:abc123/site/index.html';
24
24
* const encoded = encodeKey(key);
25
-
* // Result: 'user%3A123%2Fprofile.json'
25
+
* // Result: 'did%3Aplc%3Aabc123/site/index.html'
26
+
* // Creates: cache/did%3Aplc%3Aabc123/site/index.html
26
27
* ```
27
28
*/
28
29
export function encodeKey(key: string): string {
29
-
return key
30
-
.replace(/%/g, '%25') // Must be first!
31
-
.replace(/\//g, '%2F')
32
-
.replace(/\\/g, '%5C')
33
-
.replace(/:/g, '%3A')
34
-
.replace(/\*/g, '%2A')
35
-
.replace(/\?/g, '%3F')
36
-
.replace(/"/g, '%22')
37
-
.replace(/</g, '%3C')
38
-
.replace(/>/g, '%3E')
39
-
.replace(/\|/g, '%7C')
40
-
.replace(/\0/g, '%00');
30
+
return key
31
+
.replace(/%/g, '%25') // Must be first!
32
+
.replace(/\\/g, '%5C')
33
+
.replace(/:/g, '%3A')
34
+
.replace(/\*/g, '%2A')
35
+
.replace(/\?/g, '%3F')
36
+
.replace(/"/g, '%22')
37
+
.replace(/</g, '%3C')
38
+
.replace(/>/g, '%3E')
39
+
.replace(/\|/g, '%7C')
40
+
.replace(/\0/g, '%00');
41
41
}
42
42
43
43
/**
···
48
48
*
49
49
* @example
50
50
* ```typescript
51
-
* const encoded = 'user%3A123%2Fprofile.json';
51
+
* const encoded = 'did%3Aplc%3Aabc123/site/index.html';
52
52
* const key = decodeKey(encoded);
53
-
* // Result: 'user:123/profile.json'
53
+
* // Result: 'did:plc:abc123/site/index.html'
54
54
* ```
55
55
*/
56
56
export function decodeKey(encoded: string): string {
57
-
return encoded
58
-
.replace(/%2F/g, '/')
59
-
.replace(/%5C/g, '\\')
60
-
.replace(/%3A/g, ':')
61
-
.replace(/%2A/g, '*')
62
-
.replace(/%3F/g, '?')
63
-
.replace(/%22/g, '"')
64
-
.replace(/%3C/g, '<')
65
-
.replace(/%3E/g, '>')
66
-
.replace(/%7C/g, '|')
67
-
.replace(/%00/g, '\0')
68
-
.replace(/%25/g, '%'); // Must be last!
57
+
return encoded
58
+
.replace(/%5C/g, '\\')
59
+
.replace(/%3A/g, ':')
60
+
.replace(/%2A/g, '*')
61
+
.replace(/%3F/g, '?')
62
+
.replace(/%22/g, '"')
63
+
.replace(/%3C/g, '<')
64
+
.replace(/%3E/g, '>')
65
+
.replace(/%7C/g, '|')
66
+
.replace(/%00/g, '\0')
67
+
.replace(/%25/g, '%'); // Must be last!
69
68
}
+4
-4
src/utils/serialization.ts
+4
-4
src/utils/serialization.ts
···
19
19
* ```
20
20
*/
21
21
export async function defaultSerialize(data: unknown): Promise<Uint8Array> {
22
-
const json = JSON.stringify(data);
23
-
return new TextEncoder().encode(json);
22
+
const json = JSON.stringify(data);
23
+
return new TextEncoder().encode(json);
24
24
}
25
25
26
26
/**
···
41
41
* ```
42
42
*/
43
43
export async function defaultDeserialize(data: Uint8Array): Promise<unknown> {
44
-
const json = new TextDecoder().decode(data);
45
-
return JSON.parse(json);
44
+
const json = new TextDecoder().decode(data);
45
+
return JSON.parse(json);
46
46
}
+373
test/DiskStorageTier.test.ts
+373
test/DiskStorageTier.test.ts
···
1
+
import { describe, it, expect, afterEach } from 'vitest';
2
+
import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js';
3
+
import { rm, readdir, stat } from 'node:fs/promises';
4
+
import { existsSync } from 'node:fs';
5
+
import { join } from 'node:path';
6
+
7
+
describe('DiskStorageTier - Recursive Directory Support', () => {
8
+
const testDir = './test-disk-cache';
9
+
10
+
afterEach(async () => {
11
+
await rm(testDir, { recursive: true, force: true });
12
+
});
13
+
14
+
describe('Nested Directory Creation', () => {
15
+
it('should create nested directories for keys with slashes', async () => {
16
+
const tier = new DiskStorageTier({ directory: testDir });
17
+
18
+
const data = new TextEncoder().encode('test data');
19
+
const metadata = {
20
+
key: 'did:plc:abc/site/pages/index.html',
21
+
size: data.byteLength,
22
+
createdAt: new Date(),
23
+
lastAccessed: new Date(),
24
+
accessCount: 0,
25
+
compressed: false,
26
+
checksum: 'abc123',
27
+
};
28
+
29
+
await tier.set('did:plc:abc/site/pages/index.html', data, metadata);
30
+
31
+
// Verify directory structure was created
32
+
expect(existsSync(join(testDir, 'did%3Aplc%3Aabc'))).toBe(true);
33
+
expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site'))).toBe(true);
34
+
expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site/pages'))).toBe(true);
35
+
expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site/pages/index.html'))).toBe(true);
36
+
expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site/pages/index.html.meta'))).toBe(true);
37
+
});
38
+
39
+
it('should handle multiple files in different nested directories', async () => {
40
+
const tier = new DiskStorageTier({ directory: testDir });
41
+
42
+
const data = new TextEncoder().encode('test');
43
+
const createMetadata = (key: string) => ({
44
+
key,
45
+
size: data.byteLength,
46
+
createdAt: new Date(),
47
+
lastAccessed: new Date(),
48
+
accessCount: 0,
49
+
compressed: false,
50
+
checksum: 'abc',
51
+
});
52
+
53
+
await tier.set('site:a/images/logo.png', data, createMetadata('site:a/images/logo.png'));
54
+
await tier.set('site:a/css/style.css', data, createMetadata('site:a/css/style.css'));
55
+
await tier.set('site:b/index.html', data, createMetadata('site:b/index.html'));
56
+
57
+
expect(await tier.exists('site:a/images/logo.png')).toBe(true);
58
+
expect(await tier.exists('site:a/css/style.css')).toBe(true);
59
+
expect(await tier.exists('site:b/index.html')).toBe(true);
60
+
});
61
+
});
62
+
63
+
describe('Recursive Listing', () => {
64
+
it('should list all keys across nested directories', async () => {
65
+
const tier = new DiskStorageTier({ directory: testDir });
66
+
67
+
const data = new TextEncoder().encode('test');
68
+
const createMetadata = (key: string) => ({
69
+
key,
70
+
size: data.byteLength,
71
+
createdAt: new Date(),
72
+
lastAccessed: new Date(),
73
+
accessCount: 0,
74
+
compressed: false,
75
+
checksum: 'abc',
76
+
});
77
+
78
+
const keys = [
79
+
'site:a/index.html',
80
+
'site:a/about.html',
81
+
'site:a/assets/logo.png',
82
+
'site:b/index.html',
83
+
'site:b/nested/deep/file.txt',
84
+
];
85
+
86
+
for (const key of keys) {
87
+
await tier.set(key, data, createMetadata(key));
88
+
}
89
+
90
+
const listedKeys: string[] = [];
91
+
for await (const key of tier.listKeys()) {
92
+
listedKeys.push(key);
93
+
}
94
+
95
+
expect(listedKeys.sort()).toEqual(keys.sort());
96
+
});
97
+
98
+
it('should list keys with prefix filter across directories', async () => {
99
+
const tier = new DiskStorageTier({ directory: testDir });
100
+
101
+
const data = new TextEncoder().encode('test');
102
+
const createMetadata = (key: string) => ({
103
+
key,
104
+
size: data.byteLength,
105
+
createdAt: new Date(),
106
+
lastAccessed: new Date(),
107
+
accessCount: 0,
108
+
compressed: false,
109
+
checksum: 'abc',
110
+
});
111
+
112
+
await tier.set('site:a/index.html', data, createMetadata('site:a/index.html'));
113
+
await tier.set('site:a/about.html', data, createMetadata('site:a/about.html'));
114
+
await tier.set('site:b/index.html', data, createMetadata('site:b/index.html'));
115
+
await tier.set('user:123/profile.json', data, createMetadata('user:123/profile.json'));
116
+
117
+
const siteKeys: string[] = [];
118
+
for await (const key of tier.listKeys('site:')) {
119
+
siteKeys.push(key);
120
+
}
121
+
122
+
expect(siteKeys.sort()).toEqual([
123
+
'site:a/about.html',
124
+
'site:a/index.html',
125
+
'site:b/index.html',
126
+
]);
127
+
});
128
+
129
+
it('should handle empty directories gracefully', async () => {
130
+
const tier = new DiskStorageTier({ directory: testDir });
131
+
132
+
const keys: string[] = [];
133
+
for await (const key of tier.listKeys()) {
134
+
keys.push(key);
135
+
}
136
+
137
+
expect(keys).toEqual([]);
138
+
});
139
+
});
140
+
141
+
describe('Recursive Stats Collection', () => {
142
+
it('should calculate stats across all nested directories', async () => {
143
+
const tier = new DiskStorageTier({ directory: testDir });
144
+
145
+
const data1 = new TextEncoder().encode('small');
146
+
const data2 = new TextEncoder().encode('medium content here');
147
+
const data3 = new TextEncoder().encode('x'.repeat(1000));
148
+
149
+
const createMetadata = (key: string, size: number) => ({
150
+
key,
151
+
size,
152
+
createdAt: new Date(),
153
+
lastAccessed: new Date(),
154
+
accessCount: 0,
155
+
compressed: false,
156
+
checksum: 'abc',
157
+
});
158
+
159
+
await tier.set('a/file1.txt', data1, createMetadata('a/file1.txt', data1.byteLength));
160
+
await tier.set('a/b/file2.txt', data2, createMetadata('a/b/file2.txt', data2.byteLength));
161
+
await tier.set('a/b/c/file3.txt', data3, createMetadata('a/b/c/file3.txt', data3.byteLength));
162
+
163
+
const stats = await tier.getStats();
164
+
165
+
expect(stats.items).toBe(3);
166
+
expect(stats.bytes).toBe(data1.byteLength + data2.byteLength + data3.byteLength);
167
+
});
168
+
169
+
it('should return zero stats for empty directory', async () => {
170
+
const tier = new DiskStorageTier({ directory: testDir });
171
+
172
+
const stats = await tier.getStats();
173
+
174
+
expect(stats.items).toBe(0);
175
+
expect(stats.bytes).toBe(0);
176
+
});
177
+
});
178
+
179
+
describe('Index Rebuilding', () => {
180
+
it('should rebuild index from nested directory structure on init', async () => {
181
+
const data = new TextEncoder().encode('test data');
182
+
const createMetadata = (key: string) => ({
183
+
key,
184
+
size: data.byteLength,
185
+
createdAt: new Date(),
186
+
lastAccessed: new Date(),
187
+
accessCount: 0,
188
+
compressed: false,
189
+
checksum: 'abc',
190
+
});
191
+
192
+
// Create tier and add nested data
193
+
const tier1 = new DiskStorageTier({ directory: testDir });
194
+
await tier1.set('site:a/index.html', data, createMetadata('site:a/index.html'));
195
+
await tier1.set('site:a/nested/deep/file.txt', data, createMetadata('site:a/nested/deep/file.txt'));
196
+
await tier1.set('site:b/page.html', data, createMetadata('site:b/page.html'));
197
+
198
+
// Create new tier instance (should rebuild index from disk)
199
+
const tier2 = new DiskStorageTier({ directory: testDir });
200
+
201
+
// Give it a moment to rebuild
202
+
await new Promise(resolve => setTimeout(resolve, 100));
203
+
204
+
// Verify all keys are accessible
205
+
expect(await tier2.exists('site:a/index.html')).toBe(true);
206
+
expect(await tier2.exists('site:a/nested/deep/file.txt')).toBe(true);
207
+
expect(await tier2.exists('site:b/page.html')).toBe(true);
208
+
209
+
// Verify stats are correct
210
+
const stats = await tier2.getStats();
211
+
expect(stats.items).toBe(3);
212
+
});
213
+
214
+
it('should handle corrupted metadata files during rebuild', async () => {
215
+
const tier = new DiskStorageTier({ directory: testDir });
216
+
217
+
const data = new TextEncoder().encode('test');
218
+
const metadata = {
219
+
key: 'test/key.txt',
220
+
size: data.byteLength,
221
+
createdAt: new Date(),
222
+
lastAccessed: new Date(),
223
+
accessCount: 0,
224
+
compressed: false,
225
+
checksum: 'abc',
226
+
};
227
+
228
+
await tier.set('test/key.txt', data, metadata);
229
+
230
+
// Verify directory structure
231
+
const entries = await readdir(testDir, { withFileTypes: true });
232
+
expect(entries.length).toBeGreaterThan(0);
233
+
234
+
// New tier instance should handle any issues gracefully
235
+
const tier2 = new DiskStorageTier({ directory: testDir });
236
+
await new Promise(resolve => setTimeout(resolve, 100));
237
+
238
+
// Should still work
239
+
const stats = await tier2.getStats();
240
+
expect(stats.items).toBeGreaterThanOrEqual(0);
241
+
});
242
+
});
243
+
244
+
describe('getWithMetadata Optimization', () => {
245
+
it('should retrieve data and metadata from nested directories in parallel', async () => {
246
+
const tier = new DiskStorageTier({ directory: testDir });
247
+
248
+
const data = new TextEncoder().encode('test data content');
249
+
const metadata = {
250
+
key: 'deep/nested/path/file.json',
251
+
size: data.byteLength,
252
+
createdAt: new Date(),
253
+
lastAccessed: new Date(),
254
+
accessCount: 5,
255
+
compressed: false,
256
+
checksum: 'abc123',
257
+
};
258
+
259
+
await tier.set('deep/nested/path/file.json', data, metadata);
260
+
261
+
const result = await tier.getWithMetadata('deep/nested/path/file.json');
262
+
263
+
expect(result).not.toBeNull();
264
+
expect(result?.data).toEqual(data);
265
+
expect(result?.metadata.key).toBe('deep/nested/path/file.json');
266
+
expect(result?.metadata.accessCount).toBe(5);
267
+
});
268
+
});
269
+
270
+
describe('Deletion from Nested Directories', () => {
271
+
it('should delete files from nested directories', async () => {
272
+
const tier = new DiskStorageTier({ directory: testDir });
273
+
274
+
const data = new TextEncoder().encode('test');
275
+
const createMetadata = (key: string) => ({
276
+
key,
277
+
size: data.byteLength,
278
+
createdAt: new Date(),
279
+
lastAccessed: new Date(),
280
+
accessCount: 0,
281
+
compressed: false,
282
+
checksum: 'abc',
283
+
});
284
+
285
+
await tier.set('a/b/c/file1.txt', data, createMetadata('a/b/c/file1.txt'));
286
+
await tier.set('a/b/file2.txt', data, createMetadata('a/b/file2.txt'));
287
+
288
+
expect(await tier.exists('a/b/c/file1.txt')).toBe(true);
289
+
290
+
await tier.delete('a/b/c/file1.txt');
291
+
292
+
expect(await tier.exists('a/b/c/file1.txt')).toBe(false);
293
+
expect(await tier.exists('a/b/file2.txt')).toBe(true);
294
+
});
295
+
296
+
it('should delete multiple files across nested directories', async () => {
297
+
const tier = new DiskStorageTier({ directory: testDir });
298
+
299
+
const data = new TextEncoder().encode('test');
300
+
const createMetadata = (key: string) => ({
301
+
key,
302
+
size: data.byteLength,
303
+
createdAt: new Date(),
304
+
lastAccessed: new Date(),
305
+
accessCount: 0,
306
+
compressed: false,
307
+
checksum: 'abc',
308
+
});
309
+
310
+
const keys = [
311
+
'site:a/index.html',
312
+
'site:a/nested/page.html',
313
+
'site:b/index.html',
314
+
];
315
+
316
+
for (const key of keys) {
317
+
await tier.set(key, data, createMetadata(key));
318
+
}
319
+
320
+
await tier.deleteMany(keys);
321
+
322
+
for (const key of keys) {
323
+
expect(await tier.exists(key)).toBe(false);
324
+
}
325
+
});
326
+
});
327
+
328
+
describe('Edge Cases', () => {
329
+
it('should handle keys with many nested levels', async () => {
330
+
const tier = new DiskStorageTier({ directory: testDir });
331
+
332
+
const data = new TextEncoder().encode('deep');
333
+
const deepKey = 'a/b/c/d/e/f/g/h/i/j/k/file.txt';
334
+
const metadata = {
335
+
key: deepKey,
336
+
size: data.byteLength,
337
+
createdAt: new Date(),
338
+
lastAccessed: new Date(),
339
+
accessCount: 0,
340
+
compressed: false,
341
+
checksum: 'abc',
342
+
};
343
+
344
+
await tier.set(deepKey, data, metadata);
345
+
346
+
expect(await tier.exists(deepKey)).toBe(true);
347
+
348
+
const retrieved = await tier.get(deepKey);
349
+
expect(retrieved).toEqual(data);
350
+
});
351
+
352
+
it('should handle keys with special characters', async () => {
353
+
const tier = new DiskStorageTier({ directory: testDir });
354
+
355
+
const data = new TextEncoder().encode('test');
356
+
const metadata = {
357
+
key: 'site:abc/file[1].txt',
358
+
size: data.byteLength,
359
+
createdAt: new Date(),
360
+
lastAccessed: new Date(),
361
+
accessCount: 0,
362
+
compressed: false,
363
+
checksum: 'abc',
364
+
};
365
+
366
+
await tier.set('site:abc/file[1].txt', data, metadata);
367
+
368
+
expect(await tier.exists('site:abc/file[1].txt')).toBe(true);
369
+
const retrieved = await tier.get('site:abc/file[1].txt');
370
+
expect(retrieved).toEqual(data);
371
+
});
372
+
});
373
+
});
+457
-457
test/TieredStorage.test.ts
+457
-457
test/TieredStorage.test.ts
···
5
5
import { rm } from 'node:fs/promises';
6
6
7
7
describe('TieredStorage', () => {
8
-
const testDir = './test-cache';
8
+
const testDir = './test-cache';
9
9
10
-
afterEach(async () => {
11
-
await rm(testDir, { recursive: true, force: true });
12
-
});
10
+
afterEach(async () => {
11
+
await rm(testDir, { recursive: true, force: true });
12
+
});
13
13
14
-
describe('Basic Operations', () => {
15
-
it('should store and retrieve data', async () => {
16
-
const storage = new TieredStorage({
17
-
tiers: {
18
-
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
19
-
warm: new DiskStorageTier({ directory: `${testDir}/warm` }),
20
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
21
-
},
22
-
});
14
+
describe('Basic Operations', () => {
15
+
it('should store and retrieve data', async () => {
16
+
const storage = new TieredStorage({
17
+
tiers: {
18
+
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
19
+
warm: new DiskStorageTier({ directory: `${testDir}/warm` }),
20
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
21
+
},
22
+
});
23
23
24
-
await storage.set('test-key', { message: 'Hello, world!' });
25
-
const result = await storage.get('test-key');
24
+
await storage.set('test-key', { message: 'Hello, world!' });
25
+
const result = await storage.get('test-key');
26
26
27
-
expect(result).toEqual({ message: 'Hello, world!' });
28
-
});
27
+
expect(result).toEqual({ message: 'Hello, world!' });
28
+
});
29
29
30
-
it('should return null for non-existent key', async () => {
31
-
const storage = new TieredStorage({
32
-
tiers: {
33
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
34
-
},
35
-
});
30
+
it('should return null for non-existent key', async () => {
31
+
const storage = new TieredStorage({
32
+
tiers: {
33
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
34
+
},
35
+
});
36
36
37
-
const result = await storage.get('non-existent');
38
-
expect(result).toBeNull();
39
-
});
37
+
const result = await storage.get('non-existent');
38
+
expect(result).toBeNull();
39
+
});
40
40
41
-
it('should delete data from all tiers', async () => {
42
-
const storage = new TieredStorage({
43
-
tiers: {
44
-
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
45
-
warm: new DiskStorageTier({ directory: `${testDir}/warm` }),
46
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
47
-
},
48
-
});
41
+
it('should delete data from all tiers', async () => {
42
+
const storage = new TieredStorage({
43
+
tiers: {
44
+
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
45
+
warm: new DiskStorageTier({ directory: `${testDir}/warm` }),
46
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
47
+
},
48
+
});
49
49
50
-
await storage.set('test-key', { data: 'test' });
51
-
await storage.delete('test-key');
52
-
const result = await storage.get('test-key');
50
+
await storage.set('test-key', { data: 'test' });
51
+
await storage.delete('test-key');
52
+
const result = await storage.get('test-key');
53
53
54
-
expect(result).toBeNull();
55
-
});
54
+
expect(result).toBeNull();
55
+
});
56
56
57
-
it('should check if key exists', async () => {
58
-
const storage = new TieredStorage({
59
-
tiers: {
60
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
61
-
},
62
-
});
57
+
it('should check if key exists', async () => {
58
+
const storage = new TieredStorage({
59
+
tiers: {
60
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
61
+
},
62
+
});
63
63
64
-
await storage.set('test-key', { data: 'test' });
64
+
await storage.set('test-key', { data: 'test' });
65
65
66
-
expect(await storage.exists('test-key')).toBe(true);
67
-
expect(await storage.exists('non-existent')).toBe(false);
68
-
});
69
-
});
66
+
expect(await storage.exists('test-key')).toBe(true);
67
+
expect(await storage.exists('non-existent')).toBe(false);
68
+
});
69
+
});
70
70
71
-
describe('Cascading Write', () => {
72
-
it('should write to all configured tiers', async () => {
73
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
74
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
75
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
71
+
describe('Cascading Write', () => {
72
+
it('should write to all configured tiers', async () => {
73
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
74
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
75
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
76
76
77
-
const storage = new TieredStorage({
78
-
tiers: { hot, warm, cold },
79
-
});
77
+
const storage = new TieredStorage({
78
+
tiers: { hot, warm, cold },
79
+
});
80
80
81
-
await storage.set('test-key', { data: 'test' });
81
+
await storage.set('test-key', { data: 'test' });
82
82
83
-
// Verify data exists in all tiers
84
-
expect(await hot.exists('test-key')).toBe(true);
85
-
expect(await warm.exists('test-key')).toBe(true);
86
-
expect(await cold.exists('test-key')).toBe(true);
87
-
});
83
+
// Verify data exists in all tiers
84
+
expect(await hot.exists('test-key')).toBe(true);
85
+
expect(await warm.exists('test-key')).toBe(true);
86
+
expect(await cold.exists('test-key')).toBe(true);
87
+
});
88
88
89
-
it('should skip tiers when specified', async () => {
90
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
91
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
92
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
89
+
it('should skip tiers when specified', async () => {
90
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
91
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
92
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
93
93
94
-
const storage = new TieredStorage({
95
-
tiers: { hot, warm, cold },
96
-
});
94
+
const storage = new TieredStorage({
95
+
tiers: { hot, warm, cold },
96
+
});
97
97
98
-
// Skip hot tier
99
-
await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] });
98
+
// Skip hot tier
99
+
await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] });
100
100
101
-
expect(await hot.exists('test-key')).toBe(false);
102
-
expect(await warm.exists('test-key')).toBe(true);
103
-
expect(await cold.exists('test-key')).toBe(true);
104
-
});
105
-
});
101
+
expect(await hot.exists('test-key')).toBe(false);
102
+
expect(await warm.exists('test-key')).toBe(true);
103
+
expect(await cold.exists('test-key')).toBe(true);
104
+
});
105
+
});
106
106
107
-
describe('Bubbling Read', () => {
108
-
it('should read from hot tier first', async () => {
109
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
110
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
111
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
107
+
describe('Bubbling Read', () => {
108
+
it('should read from hot tier first', async () => {
109
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
110
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
111
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
112
112
113
-
const storage = new TieredStorage({
114
-
tiers: { hot, warm, cold },
115
-
});
113
+
const storage = new TieredStorage({
114
+
tiers: { hot, warm, cold },
115
+
});
116
116
117
-
await storage.set('test-key', { data: 'test' });
118
-
const result = await storage.getWithMetadata('test-key');
117
+
await storage.set('test-key', { data: 'test' });
118
+
const result = await storage.getWithMetadata('test-key');
119
119
120
-
expect(result?.source).toBe('hot');
121
-
expect(result?.data).toEqual({ data: 'test' });
122
-
});
120
+
expect(result?.source).toBe('hot');
121
+
expect(result?.data).toEqual({ data: 'test' });
122
+
});
123
123
124
-
it('should fall back to warm tier on hot miss', async () => {
125
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
126
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
127
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
124
+
it('should fall back to warm tier on hot miss', async () => {
125
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
126
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
127
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
128
128
129
-
const storage = new TieredStorage({
130
-
tiers: { hot, warm, cold },
131
-
});
129
+
const storage = new TieredStorage({
130
+
tiers: { hot, warm, cold },
131
+
});
132
132
133
-
// Write to warm and cold, skip hot
134
-
await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] });
133
+
// Write to warm and cold, skip hot
134
+
await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] });
135
135
136
-
const result = await storage.getWithMetadata('test-key');
136
+
const result = await storage.getWithMetadata('test-key');
137
137
138
-
expect(result?.source).toBe('warm');
139
-
expect(result?.data).toEqual({ data: 'test' });
140
-
});
138
+
expect(result?.source).toBe('warm');
139
+
expect(result?.data).toEqual({ data: 'test' });
140
+
});
141
141
142
-
it('should fall back to cold tier on hot and warm miss', async () => {
143
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
144
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
142
+
it('should fall back to cold tier on hot and warm miss', async () => {
143
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
144
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
145
145
146
-
const storage = new TieredStorage({
147
-
tiers: { hot, cold },
148
-
});
146
+
const storage = new TieredStorage({
147
+
tiers: { hot, cold },
148
+
});
149
149
150
-
// Write only to cold
151
-
await cold.set(
152
-
'test-key',
153
-
new TextEncoder().encode(JSON.stringify({ data: 'test' })),
154
-
{
155
-
key: 'test-key',
156
-
size: 100,
157
-
createdAt: new Date(),
158
-
lastAccessed: new Date(),
159
-
accessCount: 0,
160
-
compressed: false,
161
-
checksum: 'abc123',
162
-
}
163
-
);
150
+
// Write only to cold
151
+
await cold.set(
152
+
'test-key',
153
+
new TextEncoder().encode(JSON.stringify({ data: 'test' })),
154
+
{
155
+
key: 'test-key',
156
+
size: 100,
157
+
createdAt: new Date(),
158
+
lastAccessed: new Date(),
159
+
accessCount: 0,
160
+
compressed: false,
161
+
checksum: 'abc123',
162
+
}
163
+
);
164
164
165
-
const result = await storage.getWithMetadata('test-key');
165
+
const result = await storage.getWithMetadata('test-key');
166
166
167
-
expect(result?.source).toBe('cold');
168
-
expect(result?.data).toEqual({ data: 'test' });
169
-
});
170
-
});
167
+
expect(result?.source).toBe('cold');
168
+
expect(result?.data).toEqual({ data: 'test' });
169
+
});
170
+
});
171
171
172
-
describe('Promotion Strategy', () => {
173
-
it('should eagerly promote data to upper tiers', async () => {
174
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
175
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
176
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
172
+
describe('Promotion Strategy', () => {
173
+
it('should eagerly promote data to upper tiers', async () => {
174
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
175
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
176
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
177
177
178
-
const storage = new TieredStorage({
179
-
tiers: { hot, warm, cold },
180
-
promotionStrategy: 'eager',
181
-
});
178
+
const storage = new TieredStorage({
179
+
tiers: { hot, warm, cold },
180
+
promotionStrategy: 'eager',
181
+
});
182
182
183
-
// Write only to cold
184
-
await cold.set(
185
-
'test-key',
186
-
new TextEncoder().encode(JSON.stringify({ data: 'test' })),
187
-
{
188
-
key: 'test-key',
189
-
size: 100,
190
-
createdAt: new Date(),
191
-
lastAccessed: new Date(),
192
-
accessCount: 0,
193
-
compressed: false,
194
-
checksum: 'abc123',
195
-
}
196
-
);
183
+
// Write only to cold
184
+
await cold.set(
185
+
'test-key',
186
+
new TextEncoder().encode(JSON.stringify({ data: 'test' })),
187
+
{
188
+
key: 'test-key',
189
+
size: 100,
190
+
createdAt: new Date(),
191
+
lastAccessed: new Date(),
192
+
accessCount: 0,
193
+
compressed: false,
194
+
checksum: 'abc123',
195
+
}
196
+
);
197
197
198
-
// Read should promote to hot and warm
199
-
await storage.get('test-key');
198
+
// Read should promote to hot and warm
199
+
await storage.get('test-key');
200
200
201
-
expect(await hot.exists('test-key')).toBe(true);
202
-
expect(await warm.exists('test-key')).toBe(true);
203
-
});
201
+
expect(await hot.exists('test-key')).toBe(true);
202
+
expect(await warm.exists('test-key')).toBe(true);
203
+
});
204
204
205
-
it('should lazily promote data (not automatic)', async () => {
206
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
207
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
208
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
205
+
it('should lazily promote data (not automatic)', async () => {
206
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
207
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
208
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
209
209
210
-
const storage = new TieredStorage({
211
-
tiers: { hot, warm, cold },
212
-
promotionStrategy: 'lazy',
213
-
});
210
+
const storage = new TieredStorage({
211
+
tiers: { hot, warm, cold },
212
+
promotionStrategy: 'lazy',
213
+
});
214
214
215
-
// Write only to cold
216
-
await cold.set(
217
-
'test-key',
218
-
new TextEncoder().encode(JSON.stringify({ data: 'test' })),
219
-
{
220
-
key: 'test-key',
221
-
size: 100,
222
-
createdAt: new Date(),
223
-
lastAccessed: new Date(),
224
-
accessCount: 0,
225
-
compressed: false,
226
-
checksum: 'abc123',
227
-
}
228
-
);
215
+
// Write only to cold
216
+
await cold.set(
217
+
'test-key',
218
+
new TextEncoder().encode(JSON.stringify({ data: 'test' })),
219
+
{
220
+
key: 'test-key',
221
+
size: 100,
222
+
createdAt: new Date(),
223
+
lastAccessed: new Date(),
224
+
accessCount: 0,
225
+
compressed: false,
226
+
checksum: 'abc123',
227
+
}
228
+
);
229
229
230
-
// Read should NOT promote to hot and warm
231
-
await storage.get('test-key');
230
+
// Read should NOT promote to hot and warm
231
+
await storage.get('test-key');
232
232
233
-
expect(await hot.exists('test-key')).toBe(false);
234
-
expect(await warm.exists('test-key')).toBe(false);
235
-
});
236
-
});
233
+
expect(await hot.exists('test-key')).toBe(false);
234
+
expect(await warm.exists('test-key')).toBe(false);
235
+
});
236
+
});
237
237
238
-
describe('TTL Management', () => {
239
-
it('should expire data after TTL', async () => {
240
-
const storage = new TieredStorage({
241
-
tiers: {
242
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
243
-
},
244
-
});
238
+
describe('TTL Management', () => {
239
+
it('should expire data after TTL', async () => {
240
+
const storage = new TieredStorage({
241
+
tiers: {
242
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
243
+
},
244
+
});
245
245
246
-
// Set with 100ms TTL
247
-
await storage.set('test-key', { data: 'test' }, { ttl: 100 });
246
+
// Set with 100ms TTL
247
+
await storage.set('test-key', { data: 'test' }, { ttl: 100 });
248
248
249
-
// Should exist immediately
250
-
expect(await storage.get('test-key')).toEqual({ data: 'test' });
249
+
// Should exist immediately
250
+
expect(await storage.get('test-key')).toEqual({ data: 'test' });
251
251
252
-
// Wait for expiration
253
-
await new Promise((resolve) => setTimeout(resolve, 150));
252
+
// Wait for expiration
253
+
await new Promise((resolve) => setTimeout(resolve, 150));
254
254
255
-
// Should be null after expiration
256
-
expect(await storage.get('test-key')).toBeNull();
257
-
});
255
+
// Should be null after expiration
256
+
expect(await storage.get('test-key')).toBeNull();
257
+
});
258
258
259
-
it('should renew TTL with touch', async () => {
260
-
const storage = new TieredStorage({
261
-
tiers: {
262
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
263
-
},
264
-
defaultTTL: 100,
265
-
});
259
+
it('should renew TTL with touch', async () => {
260
+
const storage = new TieredStorage({
261
+
tiers: {
262
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
263
+
},
264
+
defaultTTL: 100,
265
+
});
266
266
267
-
await storage.set('test-key', { data: 'test' });
267
+
await storage.set('test-key', { data: 'test' });
268
268
269
-
// Wait 50ms
270
-
await new Promise((resolve) => setTimeout(resolve, 50));
269
+
// Wait 50ms
270
+
await new Promise((resolve) => setTimeout(resolve, 50));
271
271
272
-
// Renew TTL
273
-
await storage.touch('test-key', 200);
272
+
// Renew TTL
273
+
await storage.touch('test-key', 200);
274
274
275
-
// Wait another 100ms (would have expired without touch)
276
-
await new Promise((resolve) => setTimeout(resolve, 100));
275
+
// Wait another 100ms (would have expired without touch)
276
+
await new Promise((resolve) => setTimeout(resolve, 100));
277
277
278
-
// Should still exist
279
-
expect(await storage.get('test-key')).toEqual({ data: 'test' });
280
-
});
281
-
});
278
+
// Should still exist
279
+
expect(await storage.get('test-key')).toEqual({ data: 'test' });
280
+
});
281
+
});
282
282
283
-
describe('Prefix Invalidation', () => {
284
-
it('should invalidate all keys with prefix', async () => {
285
-
const storage = new TieredStorage({
286
-
tiers: {
287
-
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
288
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
289
-
},
290
-
});
283
+
describe('Prefix Invalidation', () => {
284
+
it('should invalidate all keys with prefix', async () => {
285
+
const storage = new TieredStorage({
286
+
tiers: {
287
+
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
288
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
289
+
},
290
+
});
291
291
292
-
await storage.set('user:123', { name: 'Alice' });
293
-
await storage.set('user:456', { name: 'Bob' });
294
-
await storage.set('post:789', { title: 'Test' });
292
+
await storage.set('user:123', { name: 'Alice' });
293
+
await storage.set('user:456', { name: 'Bob' });
294
+
await storage.set('post:789', { title: 'Test' });
295
295
296
-
const deleted = await storage.invalidate('user:');
296
+
const deleted = await storage.invalidate('user:');
297
297
298
-
expect(deleted).toBe(2);
299
-
expect(await storage.exists('user:123')).toBe(false);
300
-
expect(await storage.exists('user:456')).toBe(false);
301
-
expect(await storage.exists('post:789')).toBe(true);
302
-
});
303
-
});
298
+
expect(deleted).toBe(2);
299
+
expect(await storage.exists('user:123')).toBe(false);
300
+
expect(await storage.exists('user:456')).toBe(false);
301
+
expect(await storage.exists('post:789')).toBe(true);
302
+
});
303
+
});
304
304
305
-
describe('Compression', () => {
306
-
it('should compress data when enabled', async () => {
307
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
305
+
describe('Compression', () => {
306
+
it('should compress data when enabled', async () => {
307
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
308
308
309
-
const storage = new TieredStorage({
310
-
tiers: { cold },
311
-
compression: true,
312
-
});
309
+
const storage = new TieredStorage({
310
+
tiers: { cold },
311
+
compression: true,
312
+
});
313
313
314
-
const largeData = { data: 'x'.repeat(10000) };
315
-
const result = await storage.set('test-key', largeData);
314
+
const largeData = { data: 'x'.repeat(10000) };
315
+
const result = await storage.set('test-key', largeData);
316
316
317
-
// Check that compressed flag is set
318
-
expect(result.metadata.compressed).toBe(true);
317
+
// Check that compressed flag is set
318
+
expect(result.metadata.compressed).toBe(true);
319
319
320
-
// Verify data can be retrieved correctly
321
-
const retrieved = await storage.get('test-key');
322
-
expect(retrieved).toEqual(largeData);
323
-
});
324
-
});
320
+
// Verify data can be retrieved correctly
321
+
const retrieved = await storage.get('test-key');
322
+
expect(retrieved).toEqual(largeData);
323
+
});
324
+
});
325
325
326
-
describe('Bootstrap', () => {
327
-
it('should bootstrap hot from warm', async () => {
328
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
329
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
330
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
326
+
describe('Bootstrap', () => {
327
+
it('should bootstrap hot from warm', async () => {
328
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
329
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
330
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
331
331
332
-
const storage = new TieredStorage({
333
-
tiers: { hot, warm, cold },
334
-
});
332
+
const storage = new TieredStorage({
333
+
tiers: { hot, warm, cold },
334
+
});
335
335
336
-
// Write some data
337
-
await storage.set('key1', { data: '1' });
338
-
await storage.set('key2', { data: '2' });
339
-
await storage.set('key3', { data: '3' });
336
+
// Write some data
337
+
await storage.set('key1', { data: '1' });
338
+
await storage.set('key2', { data: '2' });
339
+
await storage.set('key3', { data: '3' });
340
340
341
-
// Clear hot tier
342
-
await hot.clear();
341
+
// Clear hot tier
342
+
await hot.clear();
343
343
344
-
// Bootstrap hot from warm
345
-
const loaded = await storage.bootstrapHot();
344
+
// Bootstrap hot from warm
345
+
const loaded = await storage.bootstrapHot();
346
346
347
-
expect(loaded).toBe(3);
348
-
expect(await hot.exists('key1')).toBe(true);
349
-
expect(await hot.exists('key2')).toBe(true);
350
-
expect(await hot.exists('key3')).toBe(true);
351
-
});
347
+
expect(loaded).toBe(3);
348
+
expect(await hot.exists('key1')).toBe(true);
349
+
expect(await hot.exists('key2')).toBe(true);
350
+
expect(await hot.exists('key3')).toBe(true);
351
+
});
352
352
353
-
it('should bootstrap warm from cold', async () => {
354
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
355
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
353
+
it('should bootstrap warm from cold', async () => {
354
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
355
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
356
356
357
-
const storage = new TieredStorage({
358
-
tiers: { warm, cold },
359
-
});
357
+
const storage = new TieredStorage({
358
+
tiers: { warm, cold },
359
+
});
360
360
361
-
// Write directly to cold
362
-
await cold.set(
363
-
'key1',
364
-
new TextEncoder().encode(JSON.stringify({ data: '1' })),
365
-
{
366
-
key: 'key1',
367
-
size: 100,
368
-
createdAt: new Date(),
369
-
lastAccessed: new Date(),
370
-
accessCount: 0,
371
-
compressed: false,
372
-
checksum: 'abc',
373
-
}
374
-
);
361
+
// Write directly to cold
362
+
await cold.set(
363
+
'key1',
364
+
new TextEncoder().encode(JSON.stringify({ data: '1' })),
365
+
{
366
+
key: 'key1',
367
+
size: 100,
368
+
createdAt: new Date(),
369
+
lastAccessed: new Date(),
370
+
accessCount: 0,
371
+
compressed: false,
372
+
checksum: 'abc',
373
+
}
374
+
);
375
375
376
-
// Bootstrap warm from cold
377
-
const loaded = await storage.bootstrapWarm({ limit: 10 });
376
+
// Bootstrap warm from cold
377
+
const loaded = await storage.bootstrapWarm({ limit: 10 });
378
378
379
-
expect(loaded).toBe(1);
380
-
expect(await warm.exists('key1')).toBe(true);
381
-
});
382
-
});
379
+
expect(loaded).toBe(1);
380
+
expect(await warm.exists('key1')).toBe(true);
381
+
});
382
+
});
383
383
384
-
describe('Statistics', () => {
385
-
it('should return statistics for all tiers', async () => {
386
-
const storage = new TieredStorage({
387
-
tiers: {
388
-
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
389
-
warm: new DiskStorageTier({ directory: `${testDir}/warm` }),
390
-
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
391
-
},
392
-
});
384
+
describe('Statistics', () => {
385
+
it('should return statistics for all tiers', async () => {
386
+
const storage = new TieredStorage({
387
+
tiers: {
388
+
hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }),
389
+
warm: new DiskStorageTier({ directory: `${testDir}/warm` }),
390
+
cold: new DiskStorageTier({ directory: `${testDir}/cold` }),
391
+
},
392
+
});
393
393
394
-
await storage.set('key1', { data: 'test1' });
395
-
await storage.set('key2', { data: 'test2' });
394
+
await storage.set('key1', { data: 'test1' });
395
+
await storage.set('key2', { data: 'test2' });
396
396
397
-
const stats = await storage.getStats();
397
+
const stats = await storage.getStats();
398
398
399
-
expect(stats.cold.items).toBe(2);
400
-
expect(stats.warm?.items).toBe(2);
401
-
expect(stats.hot?.items).toBe(2);
402
-
});
403
-
});
399
+
expect(stats.cold.items).toBe(2);
400
+
expect(stats.warm?.items).toBe(2);
401
+
expect(stats.hot?.items).toBe(2);
402
+
});
403
+
});
404
404
405
-
describe('Placement Rules', () => {
406
-
it('should place index.html in all tiers based on rule', async () => {
407
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
408
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
409
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
405
+
describe('Placement Rules', () => {
406
+
it('should place index.html in all tiers based on rule', async () => {
407
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
408
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
409
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
410
410
411
-
const storage = new TieredStorage({
412
-
tiers: { hot, warm, cold },
413
-
placementRules: [
414
-
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
415
-
{ pattern: '**', tiers: ['warm', 'cold'] },
416
-
],
417
-
});
411
+
const storage = new TieredStorage({
412
+
tiers: { hot, warm, cold },
413
+
placementRules: [
414
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
415
+
{ pattern: '**', tiers: ['warm', 'cold'] },
416
+
],
417
+
});
418
418
419
-
await storage.set('site:abc/index.html', { content: 'hello' });
419
+
await storage.set('site:abc/index.html', { content: 'hello' });
420
420
421
-
expect(await hot.exists('site:abc/index.html')).toBe(true);
422
-
expect(await warm.exists('site:abc/index.html')).toBe(true);
423
-
expect(await cold.exists('site:abc/index.html')).toBe(true);
424
-
});
421
+
expect(await hot.exists('site:abc/index.html')).toBe(true);
422
+
expect(await warm.exists('site:abc/index.html')).toBe(true);
423
+
expect(await cold.exists('site:abc/index.html')).toBe(true);
424
+
});
425
425
426
-
it('should skip hot tier for non-matching files', async () => {
427
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
428
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
429
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
426
+
it('should skip hot tier for non-matching files', async () => {
427
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
428
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
429
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
430
430
431
-
const storage = new TieredStorage({
432
-
tiers: { hot, warm, cold },
433
-
placementRules: [
434
-
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
435
-
{ pattern: '**', tiers: ['warm', 'cold'] },
436
-
],
437
-
});
431
+
const storage = new TieredStorage({
432
+
tiers: { hot, warm, cold },
433
+
placementRules: [
434
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
435
+
{ pattern: '**', tiers: ['warm', 'cold'] },
436
+
],
437
+
});
438
438
439
-
await storage.set('site:abc/about.html', { content: 'about' });
439
+
await storage.set('site:abc/about.html', { content: 'about' });
440
440
441
-
expect(await hot.exists('site:abc/about.html')).toBe(false);
442
-
expect(await warm.exists('site:abc/about.html')).toBe(true);
443
-
expect(await cold.exists('site:abc/about.html')).toBe(true);
444
-
});
441
+
expect(await hot.exists('site:abc/about.html')).toBe(false);
442
+
expect(await warm.exists('site:abc/about.html')).toBe(true);
443
+
expect(await cold.exists('site:abc/about.html')).toBe(true);
444
+
});
445
445
446
-
it('should match directory patterns', async () => {
447
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
448
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
449
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
446
+
it('should match directory patterns', async () => {
447
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
448
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
449
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
450
450
451
-
const storage = new TieredStorage({
452
-
tiers: { hot, warm, cold },
453
-
placementRules: [
454
-
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
455
-
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
456
-
],
457
-
});
451
+
const storage = new TieredStorage({
452
+
tiers: { hot, warm, cold },
453
+
placementRules: [
454
+
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
455
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
456
+
],
457
+
});
458
458
459
-
await storage.set('assets/images/logo.png', { data: 'png' });
460
-
await storage.set('index.html', { data: 'html' });
459
+
await storage.set('assets/images/logo.png', { data: 'png' });
460
+
await storage.set('index.html', { data: 'html' });
461
461
462
-
// assets/** should skip hot
463
-
expect(await hot.exists('assets/images/logo.png')).toBe(false);
464
-
expect(await warm.exists('assets/images/logo.png')).toBe(true);
462
+
// assets/** should skip hot
463
+
expect(await hot.exists('assets/images/logo.png')).toBe(false);
464
+
expect(await warm.exists('assets/images/logo.png')).toBe(true);
465
465
466
-
// everything else goes to all tiers
467
-
expect(await hot.exists('index.html')).toBe(true);
468
-
});
466
+
// everything else goes to all tiers
467
+
expect(await hot.exists('index.html')).toBe(true);
468
+
});
469
469
470
-
it('should match file extension patterns', async () => {
471
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
472
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
473
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
470
+
it('should match file extension patterns', async () => {
471
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
472
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
473
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
474
474
475
-
const storage = new TieredStorage({
476
-
tiers: { hot, warm, cold },
477
-
placementRules: [
478
-
{ pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] },
479
-
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
480
-
],
481
-
});
475
+
const storage = new TieredStorage({
476
+
tiers: { hot, warm, cold },
477
+
placementRules: [
478
+
{ pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] },
479
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
480
+
],
481
+
});
482
482
483
-
await storage.set('site/hero.png', { data: 'image' });
484
-
await storage.set('site/video.mp4', { data: 'video' });
485
-
await storage.set('site/index.html', { data: 'html' });
483
+
await storage.set('site/hero.png', { data: 'image' });
484
+
await storage.set('site/video.mp4', { data: 'video' });
485
+
await storage.set('site/index.html', { data: 'html' });
486
486
487
-
// Images and video skip hot
488
-
expect(await hot.exists('site/hero.png')).toBe(false);
489
-
expect(await hot.exists('site/video.mp4')).toBe(false);
487
+
// Images and video skip hot
488
+
expect(await hot.exists('site/hero.png')).toBe(false);
489
+
expect(await hot.exists('site/video.mp4')).toBe(false);
490
490
491
-
// HTML goes everywhere
492
-
expect(await hot.exists('site/index.html')).toBe(true);
493
-
});
491
+
// HTML goes everywhere
492
+
expect(await hot.exists('site/index.html')).toBe(true);
493
+
});
494
494
495
-
it('should use first matching rule', async () => {
496
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
497
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
498
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
495
+
it('should use first matching rule', async () => {
496
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
497
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
498
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
499
499
500
-
const storage = new TieredStorage({
501
-
tiers: { hot, warm, cold },
502
-
placementRules: [
503
-
// Specific rule first
504
-
{ pattern: 'assets/critical.css', tiers: ['hot', 'warm', 'cold'] },
505
-
// General rule second
506
-
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
507
-
{ pattern: '**', tiers: ['warm', 'cold'] },
508
-
],
509
-
});
500
+
const storage = new TieredStorage({
501
+
tiers: { hot, warm, cold },
502
+
placementRules: [
503
+
// Specific rule first
504
+
{ pattern: 'assets/critical.css', tiers: ['hot', 'warm', 'cold'] },
505
+
// General rule second
506
+
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
507
+
{ pattern: '**', tiers: ['warm', 'cold'] },
508
+
],
509
+
});
510
510
511
-
await storage.set('assets/critical.css', { data: 'css' });
512
-
await storage.set('assets/style.css', { data: 'css' });
511
+
await storage.set('assets/critical.css', { data: 'css' });
512
+
await storage.set('assets/style.css', { data: 'css' });
513
513
514
-
// critical.css matches first rule -> hot
515
-
expect(await hot.exists('assets/critical.css')).toBe(true);
514
+
// critical.css matches first rule -> hot
515
+
expect(await hot.exists('assets/critical.css')).toBe(true);
516
516
517
-
// style.css matches second rule -> no hot
518
-
expect(await hot.exists('assets/style.css')).toBe(false);
519
-
});
517
+
// style.css matches second rule -> no hot
518
+
expect(await hot.exists('assets/style.css')).toBe(false);
519
+
});
520
520
521
-
it('should allow skipTiers to override placement rules', async () => {
522
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
523
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
524
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
521
+
it('should allow skipTiers to override placement rules', async () => {
522
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
523
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
524
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
525
525
526
-
const storage = new TieredStorage({
527
-
tiers: { hot, warm, cold },
528
-
placementRules: [
529
-
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
530
-
],
531
-
});
526
+
const storage = new TieredStorage({
527
+
tiers: { hot, warm, cold },
528
+
placementRules: [
529
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
530
+
],
531
+
});
532
532
533
-
// Explicit skipTiers should override the rule
534
-
await storage.set('large-file.bin', { data: 'big' }, { skipTiers: ['hot'] });
533
+
// Explicit skipTiers should override the rule
534
+
await storage.set('large-file.bin', { data: 'big' }, { skipTiers: ['hot'] });
535
535
536
-
expect(await hot.exists('large-file.bin')).toBe(false);
537
-
expect(await warm.exists('large-file.bin')).toBe(true);
538
-
expect(await cold.exists('large-file.bin')).toBe(true);
539
-
});
536
+
expect(await hot.exists('large-file.bin')).toBe(false);
537
+
expect(await warm.exists('large-file.bin')).toBe(true);
538
+
expect(await cold.exists('large-file.bin')).toBe(true);
539
+
});
540
540
541
-
it('should always include cold tier even if not in rule', async () => {
542
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
543
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
544
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
541
+
it('should always include cold tier even if not in rule', async () => {
542
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
543
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
544
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
545
545
546
-
const storage = new TieredStorage({
547
-
tiers: { hot, warm, cold },
548
-
placementRules: [
549
-
// Rule doesn't include cold (should be auto-added)
550
-
{ pattern: '**', tiers: ['hot', 'warm'] },
551
-
],
552
-
});
546
+
const storage = new TieredStorage({
547
+
tiers: { hot, warm, cold },
548
+
placementRules: [
549
+
// Rule doesn't include cold (should be auto-added)
550
+
{ pattern: '**', tiers: ['hot', 'warm'] },
551
+
],
552
+
});
553
553
554
-
await storage.set('test-key', { data: 'test' });
554
+
await storage.set('test-key', { data: 'test' });
555
555
556
-
expect(await cold.exists('test-key')).toBe(true);
557
-
});
556
+
expect(await cold.exists('test-key')).toBe(true);
557
+
});
558
558
559
-
it('should write to all tiers when no rules match', async () => {
560
-
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
561
-
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
562
-
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
559
+
it('should write to all tiers when no rules match', async () => {
560
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
561
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
562
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
563
563
564
-
const storage = new TieredStorage({
565
-
tiers: { hot, warm, cold },
566
-
placementRules: [
567
-
{ pattern: 'specific-pattern-only', tiers: ['warm', 'cold'] },
568
-
],
569
-
});
564
+
const storage = new TieredStorage({
565
+
tiers: { hot, warm, cold },
566
+
placementRules: [
567
+
{ pattern: 'specific-pattern-only', tiers: ['warm', 'cold'] },
568
+
],
569
+
});
570
570
571
-
// This doesn't match any rule
572
-
await storage.set('other-key', { data: 'test' });
571
+
// This doesn't match any rule
572
+
await storage.set('other-key', { data: 'test' });
573
573
574
-
expect(await hot.exists('other-key')).toBe(true);
575
-
expect(await warm.exists('other-key')).toBe(true);
576
-
expect(await cold.exists('other-key')).toBe(true);
577
-
});
578
-
});
574
+
expect(await hot.exists('other-key')).toBe(true);
575
+
expect(await warm.exists('other-key')).toBe(true);
576
+
expect(await cold.exists('other-key')).toBe(true);
577
+
});
578
+
});
579
579
});
+77
-77
test/glob.test.ts
+77
-77
test/glob.test.ts
···
2
2
import { matchGlob } from '../src/utils/glob.js';
3
3
4
4
describe('matchGlob', () => {
5
-
describe('exact matches', () => {
6
-
it('should match exact strings', () => {
7
-
expect(matchGlob('index.html', 'index.html')).toBe(true);
8
-
expect(matchGlob('index.html', 'about.html')).toBe(false);
9
-
});
5
+
describe('exact matches', () => {
6
+
it('should match exact strings', () => {
7
+
expect(matchGlob('index.html', 'index.html')).toBe(true);
8
+
expect(matchGlob('index.html', 'about.html')).toBe(false);
9
+
});
10
10
11
-
it('should match paths exactly', () => {
12
-
expect(matchGlob('site/index.html', 'site/index.html')).toBe(true);
13
-
expect(matchGlob('site/index.html', 'other/index.html')).toBe(false);
14
-
});
15
-
});
11
+
it('should match paths exactly', () => {
12
+
expect(matchGlob('site/index.html', 'site/index.html')).toBe(true);
13
+
expect(matchGlob('site/index.html', 'other/index.html')).toBe(false);
14
+
});
15
+
});
16
16
17
-
describe('* wildcard', () => {
18
-
it('should match any characters except /', () => {
19
-
expect(matchGlob('*.html', 'index.html')).toBe(true);
20
-
expect(matchGlob('*.html', 'about.html')).toBe(true);
21
-
expect(matchGlob('*.html', 'style.css')).toBe(false);
22
-
});
17
+
describe('* wildcard', () => {
18
+
it('should match any characters except /', () => {
19
+
expect(matchGlob('*.html', 'index.html')).toBe(true);
20
+
expect(matchGlob('*.html', 'about.html')).toBe(true);
21
+
expect(matchGlob('*.html', 'style.css')).toBe(false);
22
+
});
23
23
24
-
it('should not match across path separators', () => {
25
-
expect(matchGlob('*.html', 'dir/index.html')).toBe(false);
26
-
});
24
+
it('should not match across path separators', () => {
25
+
expect(matchGlob('*.html', 'dir/index.html')).toBe(false);
26
+
});
27
27
28
-
it('should work with prefix and suffix', () => {
29
-
expect(matchGlob('index.*', 'index.html')).toBe(true);
30
-
expect(matchGlob('index.*', 'index.css')).toBe(true);
31
-
expect(matchGlob('index.*', 'about.html')).toBe(false);
32
-
});
33
-
});
28
+
it('should work with prefix and suffix', () => {
29
+
expect(matchGlob('index.*', 'index.html')).toBe(true);
30
+
expect(matchGlob('index.*', 'index.css')).toBe(true);
31
+
expect(matchGlob('index.*', 'about.html')).toBe(false);
32
+
});
33
+
});
34
34
35
-
describe('** wildcard', () => {
36
-
it('should match any characters including /', () => {
37
-
expect(matchGlob('**', 'anything')).toBe(true);
38
-
expect(matchGlob('**', 'path/to/file.txt')).toBe(true);
39
-
});
35
+
describe('** wildcard', () => {
36
+
it('should match any characters including /', () => {
37
+
expect(matchGlob('**', 'anything')).toBe(true);
38
+
expect(matchGlob('**', 'path/to/file.txt')).toBe(true);
39
+
});
40
40
41
-
it('should match deeply nested paths', () => {
42
-
expect(matchGlob('**/index.html', 'index.html')).toBe(true);
43
-
expect(matchGlob('**/index.html', 'site/index.html')).toBe(true);
44
-
expect(matchGlob('**/index.html', 'a/b/c/index.html')).toBe(true);
45
-
expect(matchGlob('**/index.html', 'a/b/c/about.html')).toBe(false);
46
-
});
41
+
it('should match deeply nested paths', () => {
42
+
expect(matchGlob('**/index.html', 'index.html')).toBe(true);
43
+
expect(matchGlob('**/index.html', 'site/index.html')).toBe(true);
44
+
expect(matchGlob('**/index.html', 'a/b/c/index.html')).toBe(true);
45
+
expect(matchGlob('**/index.html', 'a/b/c/about.html')).toBe(false);
46
+
});
47
47
48
-
it('should match directory prefixes', () => {
49
-
expect(matchGlob('assets/**', 'assets/style.css')).toBe(true);
50
-
expect(matchGlob('assets/**', 'assets/images/logo.png')).toBe(true);
51
-
expect(matchGlob('assets/**', 'other/style.css')).toBe(false);
52
-
});
48
+
it('should match directory prefixes', () => {
49
+
expect(matchGlob('assets/**', 'assets/style.css')).toBe(true);
50
+
expect(matchGlob('assets/**', 'assets/images/logo.png')).toBe(true);
51
+
expect(matchGlob('assets/**', 'other/style.css')).toBe(false);
52
+
});
53
53
54
-
it('should match in the middle of a path', () => {
55
-
expect(matchGlob('site/**/index.html', 'site/index.html')).toBe(true);
56
-
expect(matchGlob('site/**/index.html', 'site/pages/index.html')).toBe(true);
57
-
expect(matchGlob('site/**/index.html', 'site/a/b/c/index.html')).toBe(true);
58
-
});
59
-
});
54
+
it('should match in the middle of a path', () => {
55
+
expect(matchGlob('site/**/index.html', 'site/index.html')).toBe(true);
56
+
expect(matchGlob('site/**/index.html', 'site/pages/index.html')).toBe(true);
57
+
expect(matchGlob('site/**/index.html', 'site/a/b/c/index.html')).toBe(true);
58
+
});
59
+
});
60
60
61
-
describe('{a,b,c} alternation', () => {
62
-
it('should match any of the alternatives', () => {
63
-
expect(matchGlob('*.{html,css,js}', 'index.html')).toBe(true);
64
-
expect(matchGlob('*.{html,css,js}', 'style.css')).toBe(true);
65
-
expect(matchGlob('*.{html,css,js}', 'app.js')).toBe(true);
66
-
expect(matchGlob('*.{html,css,js}', 'image.png')).toBe(false);
67
-
});
61
+
describe('{a,b,c} alternation', () => {
62
+
it('should match any of the alternatives', () => {
63
+
expect(matchGlob('*.{html,css,js}', 'index.html')).toBe(true);
64
+
expect(matchGlob('*.{html,css,js}', 'style.css')).toBe(true);
65
+
expect(matchGlob('*.{html,css,js}', 'app.js')).toBe(true);
66
+
expect(matchGlob('*.{html,css,js}', 'image.png')).toBe(false);
67
+
});
68
68
69
-
it('should work with ** and alternation', () => {
70
-
expect(matchGlob('**/*.{jpg,png,gif}', 'logo.png')).toBe(true);
71
-
expect(matchGlob('**/*.{jpg,png,gif}', 'images/logo.png')).toBe(true);
72
-
expect(matchGlob('**/*.{jpg,png,gif}', 'a/b/photo.jpg')).toBe(true);
73
-
expect(matchGlob('**/*.{jpg,png,gif}', 'style.css')).toBe(false);
74
-
});
75
-
});
69
+
it('should work with ** and alternation', () => {
70
+
expect(matchGlob('**/*.{jpg,png,gif}', 'logo.png')).toBe(true);
71
+
expect(matchGlob('**/*.{jpg,png,gif}', 'images/logo.png')).toBe(true);
72
+
expect(matchGlob('**/*.{jpg,png,gif}', 'a/b/photo.jpg')).toBe(true);
73
+
expect(matchGlob('**/*.{jpg,png,gif}', 'style.css')).toBe(false);
74
+
});
75
+
});
76
76
77
-
describe('edge cases', () => {
78
-
it('should handle empty strings', () => {
79
-
expect(matchGlob('', '')).toBe(true);
80
-
expect(matchGlob('', 'something')).toBe(false);
81
-
expect(matchGlob('**', '')).toBe(true);
82
-
});
77
+
describe('edge cases', () => {
78
+
it('should handle empty strings', () => {
79
+
expect(matchGlob('', '')).toBe(true);
80
+
expect(matchGlob('', 'something')).toBe(false);
81
+
expect(matchGlob('**', '')).toBe(true);
82
+
});
83
83
84
-
it('should escape regex special characters', () => {
85
-
expect(matchGlob('file.txt', 'file.txt')).toBe(true);
86
-
expect(matchGlob('file.txt', 'filextxt')).toBe(false);
87
-
expect(matchGlob('file[1].txt', 'file[1].txt')).toBe(true);
88
-
});
84
+
it('should escape regex special characters', () => {
85
+
expect(matchGlob('file.txt', 'file.txt')).toBe(true);
86
+
expect(matchGlob('file.txt', 'filextxt')).toBe(false);
87
+
expect(matchGlob('file[1].txt', 'file[1].txt')).toBe(true);
88
+
});
89
89
90
-
it('should handle keys with colons (common in storage)', () => {
91
-
expect(matchGlob('site:*/index.html', 'site:abc/index.html')).toBe(true);
92
-
expect(matchGlob('site:**/index.html', 'site:abc/pages/index.html')).toBe(true);
93
-
});
94
-
});
90
+
it('should handle keys with colons (common in storage)', () => {
91
+
expect(matchGlob('site:*/index.html', 'site:abc/index.html')).toBe(true);
92
+
expect(matchGlob('site:**/index.html', 'site:abc/pages/index.html')).toBe(true);
93
+
});
94
+
});
95
95
});