+48
-11
README.md
+48
-11
README.md
···
28
28
warm: new DiskStorageTier({ directory: './cache' }),
29
29
cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
30
30
},
31
-
compression: true,
31
+
placementRules: [
32
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
33
+
{ pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] },
34
+
{ pattern: '**', tiers: ['warm', 'cold'] },
35
+
],
32
36
})
33
37
34
-
// critical file: keep in memory for instant serving
35
-
await storage.set('site:abc/index.html', indexHtml)
36
-
37
-
// big files: skip hot, let them live in warm + cold
38
-
await storage.set('site:abc/video.mp4', videoData, { skipTiers: ['hot'] })
39
-
await storage.set('site:abc/hero.png', imageData, { skipTiers: ['hot'] })
38
+
// just set - rules decide where it goes
39
+
await storage.set('site:abc/index.html', indexHtml) // → hot + warm + cold
40
+
await storage.set('site:abc/hero.png', imageData) // → warm + cold
41
+
await storage.set('site:abc/video.mp4', videoData) // → warm + cold
40
42
41
-
// on read, bubbles up from wherever it lives
42
-
const result = await storage.getWithMetadata('site:abc/index.html')
43
-
console.log(result.source) // 'hot' - served from memory
43
+
// reads bubble up from wherever it lives
44
+
const page = await storage.getWithMetadata('site:abc/index.html')
45
+
console.log(page.source) // 'hot'
44
46
45
47
const video = await storage.getWithMetadata('site:abc/video.mp4')
46
-
console.log(video.source) // 'warm' - served from disk, never touches memory
48
+
console.log(video.source) // 'warm'
47
49
48
50
// nuke entire site
49
51
await storage.invalidate('site:abc/')
···
93
95
```
94
96
95
97
A file that hasn't been accessed eventually gets evicted from hot (LRU), then warm (size limit + policy). Next request fetches from cold and promotes it back up.
98
+
99
+
## Placement rules
100
+
101
+
Define once which keys go where, instead of passing `skipTiers` on every `set()`:
102
+
103
+
```typescript
104
+
const storage = new TieredStorage({
105
+
tiers: {
106
+
hot: new MemoryStorageTier({ maxSizeBytes: 50 * 1024 * 1024 }),
107
+
warm: new DiskStorageTier({ directory: './cache' }),
108
+
cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
109
+
},
110
+
placementRules: [
111
+
// index.html goes everywhere for instant serving
112
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
113
+
114
+
// images and video skip hot
115
+
{ pattern: '**/*.{jpg,png,gif,webp,mp4}', tiers: ['warm', 'cold'] },
116
+
117
+
// assets directory skips hot
118
+
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
119
+
120
+
// everything else: warm + cold only
121
+
{ pattern: '**', tiers: ['warm', 'cold'] },
122
+
],
123
+
})
124
+
125
+
// just call set() - rules handle placement
126
+
await storage.set('site:abc/index.html', html) // → hot + warm + cold
127
+
await storage.set('site:abc/hero.png', image) // → warm + cold
128
+
await storage.set('site:abc/assets/font.woff', font) // → warm + cold
129
+
await storage.set('site:abc/about.html', html) // → warm + cold
130
+
```
131
+
132
+
Rules are evaluated in order. First match wins. Cold is always included.
96
133
97
134
## API
98
135
+17
-10
serve-example.ts
+17
-10
serve-example.ts
···
44
44
prefix: 'demo-sites/',
45
45
}),
46
46
},
47
+
placementRules: [
48
+
// index.html goes to all tiers for instant serving
49
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
50
+
51
+
// everything else: warm + cold only
52
+
{ pattern: '**', tiers: ['warm', 'cold'] },
53
+
],
47
54
compression: true,
48
55
defaultTTL: 14 * 24 * 60 * 60 * 1000,
49
56
promotionStrategy: 'lazy',
···
62
69
console.log('\n📦 Loading example site into tiered storage...\n');
63
70
64
71
const files = [
65
-
{ name: 'index.html', skipTiers: [], mimeType: 'text/html' },
66
-
{ name: 'about.html', skipTiers: ['hot'], mimeType: 'text/html' },
67
-
{ name: 'docs.html', skipTiers: ['hot'], mimeType: 'text/html' },
68
-
{ name: 'style.css', skipTiers: ['hot'], mimeType: 'text/css' },
69
-
{ name: 'script.js', skipTiers: ['hot'], mimeType: 'application/javascript' },
72
+
{ name: 'index.html', mimeType: 'text/html' },
73
+
{ name: 'about.html', mimeType: 'text/html' },
74
+
{ name: 'docs.html', mimeType: 'text/html' },
75
+
{ name: 'style.css', mimeType: 'text/css' },
76
+
{ name: 'script.js', mimeType: 'application/javascript' },
70
77
];
71
78
72
79
for (const file of files) {
···
74
81
const key = `${siteId}/${siteName}/${file.name}`;
75
82
76
83
await storage.set(key, content, {
77
-
skipTiers: file.skipTiers as ('hot' | 'warm')[],
78
84
metadata: { mimeType: file.mimeType },
79
85
});
80
86
81
-
const tierInfo =
82
-
file.skipTiers.length === 0
83
-
? '🔥 hot + 💾 warm + ☁️ cold'
84
-
: `💾 warm + ☁️ cold (skipped hot)`;
87
+
// Determine which tiers this file went to based on placement rules
88
+
const isIndex = file.name === 'index.html';
89
+
const tierInfo = isIndex
90
+
? '🔥 hot + 💾 warm + ☁️ cold'
91
+
: '💾 warm + ☁️ cold (skipped hot)';
85
92
const sizeKB = (content.length / 1024).toFixed(2);
86
93
console.log(` ✓ ${file.name.padEnd(15)} ${sizeKB.padStart(6)} KB → ${tierInfo}`);
87
94
}
+47
-11
src/TieredStorage.ts
+47
-11
src/TieredStorage.ts
···
6
6
StorageMetadata,
7
7
AllTierStats,
8
8
StorageSnapshot,
9
-
} from './types/index.js';
9
+
PlacementRule,
10
+
} from './types/index';
10
11
import { compress, decompress } from './utils/compression.js';
11
12
import { defaultSerialize, defaultDeserialize } from './utils/serialization.js';
12
13
import { calculateChecksum } from './utils/checksum.js';
14
+
import { matchGlob } from './utils/glob.js';
13
15
14
16
/**
15
17
* Main orchestrator for tiered storage system.
···
202
204
// 3. Create metadata
203
205
const metadata = this.createMetadata(key, finalData, options);
204
206
205
-
// 4. Write to all tiers (cascading down)
207
+
// 4. Determine which tiers to write to
208
+
const allowedTiers = this.getTiersForKey(key, options?.skipTiers);
209
+
210
+
// 5. Write to tiers
206
211
const tiersWritten: ('hot' | 'warm' | 'cold')[] = [];
207
212
208
-
// Write to hot (if configured and not skipped)
209
-
if (this.config.tiers.hot && !options?.skipTiers?.includes('hot')) {
213
+
if (this.config.tiers.hot && allowedTiers.includes('hot')) {
210
214
await this.config.tiers.hot.set(key, finalData, metadata);
211
215
tiersWritten.push('hot');
216
+
}
212
217
213
-
// Hot writes cascade to warm
214
-
if (this.config.tiers.warm && !options?.skipTiers?.includes('warm')) {
215
-
await this.config.tiers.warm.set(key, finalData, metadata);
216
-
tiersWritten.push('warm');
217
-
}
218
-
} else if (this.config.tiers.warm && !options?.skipTiers?.includes('warm')) {
219
-
// Write to warm (if hot skipped)
218
+
if (this.config.tiers.warm && allowedTiers.includes('warm')) {
220
219
await this.config.tiers.warm.set(key, finalData, metadata);
221
220
tiersWritten.push('warm');
222
221
}
···
226
225
tiersWritten.push('cold');
227
226
228
227
return { key, metadata, tiersWritten };
228
+
}
229
+
230
+
/**
231
+
* Determine which tiers a key should be written to.
232
+
*
233
+
* @param key - The key being stored
234
+
* @param skipTiers - Explicit tiers to skip (overrides placement rules)
235
+
* @returns Array of tiers to write to
236
+
*
237
+
* @remarks
238
+
* Priority: skipTiers option > placementRules > all configured tiers
239
+
*/
240
+
private getTiersForKey(
241
+
key: string,
242
+
skipTiers?: ('hot' | 'warm')[]
243
+
): ('hot' | 'warm' | 'cold')[] {
244
+
// If explicit skipTiers provided, use that
245
+
if (skipTiers && skipTiers.length > 0) {
246
+
const allTiers: ('hot' | 'warm' | 'cold')[] = ['hot', 'warm', 'cold'];
247
+
return allTiers.filter((t) => !skipTiers.includes(t as 'hot' | 'warm'));
248
+
}
249
+
250
+
// Check placement rules
251
+
if (this.config.placementRules) {
252
+
for (const rule of this.config.placementRules) {
253
+
if (matchGlob(rule.pattern, key)) {
254
+
// Ensure cold is always included
255
+
if (!rule.tiers.includes('cold')) {
256
+
return [...rule.tiers, 'cold'];
257
+
}
258
+
return rule.tiers;
259
+
}
260
+
}
261
+
}
262
+
263
+
// Default: write to all configured tiers
264
+
return ['hot', 'warm', 'cold'];
229
265
}
230
266
231
267
/**
+1
src/index.ts
+1
src/index.ts
+45
src/types/index.ts
+45
src/types/index.ts
···
222
222
}
223
223
224
224
/**
225
+
* Rule for automatic tier placement based on key patterns.
226
+
*
227
+
* @remarks
228
+
* Rules are evaluated in order. First matching rule wins.
229
+
* Use this to define which keys go to which tiers without
230
+
* specifying skipTiers on every set() call.
231
+
*
232
+
* @example
233
+
* ```typescript
234
+
* placementRules: [
235
+
* { pattern: 'index.html', tiers: ['hot', 'warm', 'cold'] },
236
+
* { pattern: '*.html', tiers: ['warm', 'cold'] },
237
+
* { pattern: 'assets/**', tiers: ['warm', 'cold'] },
238
+
* { pattern: '**', tiers: ['warm', 'cold'] }, // default
239
+
* ]
240
+
* ```
241
+
*/
242
+
export interface PlacementRule {
243
+
/**
244
+
* Glob pattern to match against keys.
245
+
*
246
+
* @remarks
247
+
* Supports basic globs:
248
+
* - `*` matches any characters except `/`
249
+
* - `**` matches any characters including `/`
250
+
* - Exact matches work too: `index.html`
251
+
*/
252
+
pattern: string;
253
+
254
+
/**
255
+
* Which tiers to write to for matching keys.
256
+
*
257
+
* @remarks
258
+
* Cold is always included (source of truth).
259
+
* Use `['hot', 'warm', 'cold']` for critical files.
260
+
* Use `['warm', 'cold']` for large files.
261
+
* Use `['cold']` for archival only.
262
+
*/
263
+
tiers: ('hot' | 'warm' | 'cold')[];
264
+
}
265
+
266
+
/**
225
267
* Configuration for the TieredStorage system.
226
268
*
227
269
* @typeParam T - The type of data being stored (for serialization)
···
246
288
/** Required cold tier - slowest, largest capacity (e.g., S3, R2, object storage) */
247
289
cold: StorageTier;
248
290
};
291
+
292
+
/** Rules for automatic tier placement based on key patterns. First match wins. */
293
+
placementRules?: PlacementRule[];
249
294
250
295
/**
251
296
* Whether to automatically compress data before storing.
+40
src/utils/glob.ts
+40
src/utils/glob.ts
···
1
+
/**
2
+
* Simple glob pattern matching for key placement rules.
3
+
*
4
+
* Supports:
5
+
* - `*` matches any characters except `/`
6
+
* - `**` matches any characters including `/` (including empty string)
7
+
* - `{a,b,c}` matches any of the alternatives
8
+
* - Exact strings match exactly
9
+
*/
10
+
export function matchGlob(pattern: string, key: string): boolean {
11
+
// Handle exact match
12
+
if (!pattern.includes('*') && !pattern.includes('{')) {
13
+
return pattern === key;
14
+
}
15
+
16
+
// Escape regex special chars (except * and {})
17
+
let regex = pattern.replace(/[.+^$|\\()[\]]/g, '\\$&');
18
+
19
+
// Handle {a,b,c} alternation
20
+
regex = regex.replace(/\{([^}]+)\}/g, (_, alts) => `(${alts.split(',').join('|')})`);
21
+
22
+
// Use placeholder to avoid double-processing
23
+
const DOUBLE = '\x00DOUBLE\x00';
24
+
const SINGLE = '\x00SINGLE\x00';
25
+
26
+
// Mark ** and * with placeholders
27
+
regex = regex.replace(/\*\*/g, DOUBLE);
28
+
regex = regex.replace(/\*/g, SINGLE);
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
38
+
39
+
return new RegExp(`^${regex}$`).test(key);
40
+
}
+175
test/TieredStorage.test.ts
+175
test/TieredStorage.test.ts
···
401
401
expect(stats.hot?.items).toBe(2);
402
402
});
403
403
});
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` });
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
+
});
418
+
419
+
await storage.set('site:abc/index.html', { content: 'hello' });
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
+
});
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` });
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
+
});
438
+
439
+
await storage.set('site:abc/about.html', { content: 'about' });
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
+
});
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` });
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
+
});
458
+
459
+
await storage.set('assets/images/logo.png', { data: 'png' });
460
+
await storage.set('index.html', { data: 'html' });
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);
465
+
466
+
// everything else goes to all tiers
467
+
expect(await hot.exists('index.html')).toBe(true);
468
+
});
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` });
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
+
});
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' });
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);
490
+
491
+
// HTML goes everywhere
492
+
expect(await hot.exists('site/index.html')).toBe(true);
493
+
});
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` });
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
+
});
510
+
511
+
await storage.set('assets/critical.css', { data: 'css' });
512
+
await storage.set('assets/style.css', { data: 'css' });
513
+
514
+
// critical.css matches first rule -> hot
515
+
expect(await hot.exists('assets/critical.css')).toBe(true);
516
+
517
+
// style.css matches second rule -> no hot
518
+
expect(await hot.exists('assets/style.css')).toBe(false);
519
+
});
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` });
525
+
526
+
const storage = new TieredStorage({
527
+
tiers: { hot, warm, cold },
528
+
placementRules: [
529
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
530
+
],
531
+
});
532
+
533
+
// Explicit skipTiers should override the rule
534
+
await storage.set('large-file.bin', { data: 'big' }, { skipTiers: ['hot'] });
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
+
});
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` });
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
+
});
553
+
554
+
await storage.set('test-key', { data: 'test' });
555
+
556
+
expect(await cold.exists('test-key')).toBe(true);
557
+
});
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` });
563
+
564
+
const storage = new TieredStorage({
565
+
tiers: { hot, warm, cold },
566
+
placementRules: [
567
+
{ pattern: 'specific-pattern-only', tiers: ['warm', 'cold'] },
568
+
],
569
+
});
570
+
571
+
// This doesn't match any rule
572
+
await storage.set('other-key', { data: 'test' });
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
+
});
404
579
});
+95
test/glob.test.ts
+95
test/glob.test.ts
···
1
+
import { describe, it, expect } from 'vitest';
2
+
import { matchGlob } from '../src/utils/glob.js';
3
+
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
+
});
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
+
});
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
+
});
23
+
24
+
it('should not match across path separators', () => {
25
+
expect(matchGlob('*.html', 'dir/index.html')).toBe(false);
26
+
});
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
+
});
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
+
});
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
+
});
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
+
});
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
+
});
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
+
});
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
+
});
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
+
});
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
+
});
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
+
});
95
+
});