wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs typescript

Compare changes

Choose any two refs to compare.

+53 -11
README.md
···
warm: new DiskStorageTier({ directory: './cache' }),
cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
},
-
compression: true,
+
placementRules: [
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
+
{ pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] },
+
{ pattern: '**', tiers: ['warm', 'cold'] },
+
],
})
-
// critical file: keep in memory for instant serving
-
await storage.set('site:abc/index.html', indexHtml)
-
-
// big files: skip hot, let them live in warm + cold
-
await storage.set('site:abc/video.mp4', videoData, { skipTiers: ['hot'] })
-
await storage.set('site:abc/hero.png', imageData, { skipTiers: ['hot'] })
+
// just set - rules decide where it goes
+
await storage.set('site:abc/index.html', indexHtml) // โ†’ hot + warm + cold
+
await storage.set('site:abc/hero.png', imageData) // โ†’ warm + cold
+
await storage.set('site:abc/video.mp4', videoData) // โ†’ warm + cold
-
// on read, bubbles up from wherever it lives
-
const result = await storage.getWithMetadata('site:abc/index.html')
-
console.log(result.source) // 'hot' - served from memory
+
// reads bubble up from wherever it lives
+
const page = await storage.getWithMetadata('site:abc/index.html')
+
console.log(page.source) // 'hot'
const video = await storage.getWithMetadata('site:abc/video.mp4')
-
console.log(video.source) // 'warm' - served from disk, never touches memory
+
console.log(video.source) // 'warm'
// nuke entire site
await storage.invalidate('site:abc/')
···
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.
+
## Placement rules
+
+
Define once which keys go where, instead of passing `skipTiers` on every `set()`:
+
+
```typescript
+
const storage = new TieredStorage({
+
tiers: {
+
hot: new MemoryStorageTier({ maxSizeBytes: 50 * 1024 * 1024 }),
+
warm: new DiskStorageTier({ directory: './cache' }),
+
cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }),
+
},
+
placementRules: [
+
// index.html goes everywhere for instant serving
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
+
+
// images and video skip hot
+
{ pattern: '**/*.{jpg,png,gif,webp,mp4}', tiers: ['warm', 'cold'] },
+
+
// assets directory skips hot
+
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
+
+
// everything else: warm + cold only
+
{ pattern: '**', tiers: ['warm', 'cold'] },
+
],
+
})
+
+
// just call set() - rules handle placement
+
await storage.set('site:abc/index.html', html) // โ†’ hot + warm + cold
+
await storage.set('site:abc/hero.png', image) // โ†’ warm + cold
+
await storage.set('site:abc/assets/font.woff', font) // โ†’ warm + cold
+
await storage.set('site:abc/about.html', html) // โ†’ warm + cold
+
```
+
+
Rules are evaluated in order. First match wins. Cold is always included.
+
## API
### `storage.get(key)`
···
setMetadata(key: string, metadata: StorageMetadata): Promise<void>
getStats(): Promise<TierStats>
clear(): Promise<void>
+
+
// Optional: combine get + getMetadata for better performance
+
getWithMetadata?(key: string): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null>
}
```
+
+
The optional `getWithMetadata` method returns both data and metadata in a single call. Implement it if your backend can fetch both efficiently (e.g., parallel I/O, single query). Falls back to separate `get()` + `getMetadata()` calls if not implemented.
## Running the demo
+17 -10
serve-example.ts
···
prefix: 'demo-sites/',
}),
},
+
placementRules: [
+
// index.html goes to all tiers for instant serving
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
+
+
// everything else: warm + cold only
+
{ pattern: '**', tiers: ['warm', 'cold'] },
+
],
compression: true,
defaultTTL: 14 * 24 * 60 * 60 * 1000,
promotionStrategy: 'lazy',
···
console.log('\n๐Ÿ“ฆ Loading example site into tiered storage...\n');
const files = [
-
{ name: 'index.html', skipTiers: [], mimeType: 'text/html' },
-
{ name: 'about.html', skipTiers: ['hot'], mimeType: 'text/html' },
-
{ name: 'docs.html', skipTiers: ['hot'], mimeType: 'text/html' },
-
{ name: 'style.css', skipTiers: ['hot'], mimeType: 'text/css' },
-
{ name: 'script.js', skipTiers: ['hot'], mimeType: 'application/javascript' },
+
{ name: 'index.html', mimeType: 'text/html' },
+
{ name: 'about.html', mimeType: 'text/html' },
+
{ name: 'docs.html', mimeType: 'text/html' },
+
{ name: 'style.css', mimeType: 'text/css' },
+
{ name: 'script.js', mimeType: 'application/javascript' },
];
for (const file of files) {
···
const key = `${siteId}/${siteName}/${file.name}`;
await storage.set(key, content, {
-
skipTiers: file.skipTiers as ('hot' | 'warm')[],
metadata: { mimeType: file.mimeType },
});
-
const tierInfo =
-
file.skipTiers.length === 0
-
? '๐Ÿ”ฅ hot + ๐Ÿ’พ warm + โ˜๏ธ cold'
-
: `๐Ÿ’พ warm + โ˜๏ธ cold (skipped hot)`;
+
// Determine which tiers this file went to based on placement rules
+
const isIndex = file.name === 'index.html';
+
const tierInfo = isIndex
+
? '๐Ÿ”ฅ hot + ๐Ÿ’พ warm + โ˜๏ธ cold'
+
: '๐Ÿ’พ warm + โ˜๏ธ cold (skipped hot)';
const sizeKB = (content.length / 1024).toFixed(2);
console.log(` โœ“ ${file.name.padEnd(15)} ${sizeKB.padStart(6)} KB โ†’ ${tierInfo}`);
}
+112 -55
src/TieredStorage.ts
···
StorageResult,
SetResult,
StorageMetadata,
+
StorageTier,
AllTierStats,
StorageSnapshot,
-
} from './types/index.js';
+
PlacementRule,
+
} from './types/index';
import { compress, decompress } from './utils/compression.js';
import { defaultSerialize, defaultDeserialize } from './utils/serialization.js';
import { calculateChecksum } from './utils/checksum.js';
+
import { matchGlob } from './utils/glob.js';
/**
* Main orchestrator for tiered storage system.
···
async getWithMetadata(key: string): Promise<StorageResult<T> | null> {
// 1. Check hot tier first
if (this.config.tiers.hot) {
-
const data = await this.config.tiers.hot.get(key);
-
if (data) {
-
const metadata = await this.config.tiers.hot.getMetadata(key);
-
if (!metadata) {
-
await this.delete(key);
-
} else if (this.isExpired(metadata)) {
+
const result = await this.getFromTier(this.config.tiers.hot, key);
+
if (result) {
+
if (this.isExpired(result.metadata)) {
await this.delete(key);
return null;
-
} else {
-
await this.updateAccessStats(key, 'hot');
-
return {
-
data: (await this.deserializeData(data)) as T,
-
metadata,
-
source: 'hot',
-
};
}
+
// Fire-and-forget access stats update (non-critical)
+
void this.updateAccessStats(key, 'hot');
+
return {
+
data: (await this.deserializeData(result.data)) as T,
+
metadata: result.metadata,
+
source: 'hot',
+
};
}
}
// 2. Check warm tier
if (this.config.tiers.warm) {
-
const data = await this.config.tiers.warm.get(key);
-
if (data) {
-
const metadata = await this.config.tiers.warm.getMetadata(key);
-
if (!metadata) {
-
await this.delete(key);
-
} else if (this.isExpired(metadata)) {
+
const result = await this.getFromTier(this.config.tiers.warm, key);
+
if (result) {
+
if (this.isExpired(result.metadata)) {
await this.delete(key);
return null;
-
} else {
-
if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') {
-
await this.config.tiers.hot.set(key, data, metadata);
-
}
-
-
await this.updateAccessStats(key, 'warm');
-
return {
-
data: (await this.deserializeData(data)) as T,
-
metadata,
-
source: 'warm',
-
};
+
}
+
// Eager promotion to hot tier (awaited - guaranteed to complete)
+
if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') {
+
await this.config.tiers.hot.set(key, result.data, result.metadata);
}
+
// Fire-and-forget access stats update (non-critical)
+
void this.updateAccessStats(key, 'warm');
+
return {
+
data: (await this.deserializeData(result.data)) as T,
+
metadata: result.metadata,
+
source: 'warm',
+
};
}
}
// 3. Check cold tier (source of truth)
-
const data = await this.config.tiers.cold.get(key);
-
if (data) {
-
const metadata = await this.config.tiers.cold.getMetadata(key);
-
if (!metadata) {
-
await this.config.tiers.cold.delete(key);
-
return null;
-
}
-
-
if (this.isExpired(metadata)) {
+
const result = await this.getFromTier(this.config.tiers.cold, key);
+
if (result) {
+
if (this.isExpired(result.metadata)) {
await this.delete(key);
return null;
}
// Promote to warm and hot (if configured)
+
// Eager promotion is awaited to guarantee completion
if (this.config.promotionStrategy === 'eager') {
+
const promotions: Promise<void>[] = [];
if (this.config.tiers.warm) {
-
await this.config.tiers.warm.set(key, data, metadata);
+
promotions.push(this.config.tiers.warm.set(key, result.data, result.metadata));
}
if (this.config.tiers.hot) {
-
await this.config.tiers.hot.set(key, data, metadata);
+
promotions.push(this.config.tiers.hot.set(key, result.data, result.metadata));
}
+
await Promise.all(promotions);
}
-
await this.updateAccessStats(key, 'cold');
+
// Fire-and-forget access stats update (non-critical)
+
void this.updateAccessStats(key, 'cold');
return {
-
data: (await this.deserializeData(data)) as T,
-
metadata,
+
data: (await this.deserializeData(result.data)) as T,
+
metadata: result.metadata,
source: 'cold',
};
}
···
}
/**
+
* Get data and metadata from a tier using the most efficient method.
+
*
+
* @remarks
+
* Uses the tier's getWithMetadata if available, otherwise falls back
+
* to separate get() and getMetadata() calls.
+
*/
+
private async getFromTier(
+
tier: StorageTier,
+
key: string
+
): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null> {
+
// Use optimized combined method if available
+
if (tier.getWithMetadata) {
+
return tier.getWithMetadata(key);
+
}
+
+
// Fallback: separate calls
+
const data = await tier.get(key);
+
if (!data) {
+
return null;
+
}
+
const metadata = await tier.getMetadata(key);
+
if (!metadata) {
+
return null;
+
}
+
return { data, metadata };
+
}
+
+
/**
* Store data with optional configuration.
*
* @param key - The key to store under
···
// 3. Create metadata
const metadata = this.createMetadata(key, finalData, options);
-
// 4. Write to all tiers (cascading down)
+
// 4. Determine which tiers to write to
+
const allowedTiers = this.getTiersForKey(key, options?.skipTiers);
+
+
// 5. Write to tiers
const tiersWritten: ('hot' | 'warm' | 'cold')[] = [];
-
// Write to hot (if configured and not skipped)
-
if (this.config.tiers.hot && !options?.skipTiers?.includes('hot')) {
+
if (this.config.tiers.hot && allowedTiers.includes('hot')) {
await this.config.tiers.hot.set(key, finalData, metadata);
tiersWritten.push('hot');
+
}
-
// Hot writes cascade to warm
-
if (this.config.tiers.warm && !options?.skipTiers?.includes('warm')) {
-
await this.config.tiers.warm.set(key, finalData, metadata);
-
tiersWritten.push('warm');
-
}
-
} else if (this.config.tiers.warm && !options?.skipTiers?.includes('warm')) {
-
// Write to warm (if hot skipped)
+
if (this.config.tiers.warm && allowedTiers.includes('warm')) {
await this.config.tiers.warm.set(key, finalData, metadata);
tiersWritten.push('warm');
}
···
tiersWritten.push('cold');
return { key, metadata, tiersWritten };
+
}
+
+
/**
+
* Determine which tiers a key should be written to.
+
*
+
* @param key - The key being stored
+
* @param skipTiers - Explicit tiers to skip (overrides placement rules)
+
* @returns Array of tiers to write to
+
*
+
* @remarks
+
* Priority: skipTiers option > placementRules > all configured tiers
+
*/
+
private getTiersForKey(
+
key: string,
+
skipTiers?: ('hot' | 'warm')[]
+
): ('hot' | 'warm' | 'cold')[] {
+
// If explicit skipTiers provided, use that
+
if (skipTiers && skipTiers.length > 0) {
+
const allTiers: ('hot' | 'warm' | 'cold')[] = ['hot', 'warm', 'cold'];
+
return allTiers.filter((t) => !skipTiers.includes(t as 'hot' | 'warm'));
+
}
+
+
// Check placement rules
+
if (this.config.placementRules) {
+
for (const rule of this.config.placementRules) {
+
if (matchGlob(rule.pattern, key)) {
+
// Ensure cold is always included
+
if (!rule.tiers.includes('cold')) {
+
return [...rule.tiers, 'cold'];
+
}
+
return rule.tiers;
+
}
+
}
+
}
+
+
// Default: write to all configured tiers
+
return ['hot', 'warm', 'cold'];
}
/**
+2
src/index.ts
···
StorageTier,
StorageMetadata,
TierStats,
+
TierGetResult,
AllTierStats,
TieredStorageConfig,
+
PlacementRule,
SetOptions,
StorageResult,
SetResult,
+36 -11
src/tiers/DiskStorageTier.ts
···
import { readFile, writeFile, unlink, readdir, stat, mkdir, rm, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
-
import type { StorageTier, StorageMetadata, TierStats } from '../types/index.js';
+
import type { StorageTier, StorageMetadata, TierStats, TierGetResult } from '../types/index.js';
import { encodeKey } from '../utils/path-encoding.js';
/**
···
try {
const data = await readFile(filePath);
+
return new Uint8Array(data);
+
} catch (error) {
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+
return null;
+
}
+
throw error;
+
}
+
}
-
const metadata = await this.getMetadata(key);
-
if (metadata) {
-
metadata.lastAccessed = new Date();
-
metadata.accessCount++;
-
await this.setMetadata(key, metadata);
+
/**
+
* Retrieve data and metadata together in a single operation.
+
*
+
* @param key - The key to retrieve
+
* @returns The data and metadata, or null if not found
+
*
+
* @remarks
+
* Reads data and metadata files in parallel for better performance.
+
*/
+
async getWithMetadata(key: string): Promise<TierGetResult | null> {
+
const filePath = this.getFilePath(key);
+
const metaPath = this.getMetaPath(key);
+
+
try {
+
// Read data and metadata in parallel
+
const [dataBuffer, metaContent] = await Promise.all([
+
readFile(filePath),
+
readFile(metaPath, 'utf-8'),
+
]);
-
const entry = this.metadataIndex.get(key);
-
if (entry) {
-
entry.lastAccessed = metadata.lastAccessed;
-
}
+
const metadata = JSON.parse(metaContent) as StorageMetadata;
+
+
// Convert date strings back to Date objects
+
metadata.createdAt = new Date(metadata.createdAt);
+
metadata.lastAccessed = new Date(metadata.lastAccessed);
+
if (metadata.ttl) {
+
metadata.ttl = new Date(metadata.ttl);
}
-
return new Uint8Array(data);
+
return { data: new Uint8Array(dataBuffer), metadata };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
+19 -1
src/tiers/MemoryStorageTier.ts
···
import { lru } from 'tiny-lru';
-
import type { StorageTier, StorageMetadata, TierStats } from '../types/index.js';
+
import type { StorageTier, StorageMetadata, TierStats, TierGetResult } from '../types/index.js';
interface CacheEntry {
data: Uint8Array;
···
this.stats.hits++;
return entry.data;
+
}
+
+
/**
+
* Retrieve data and metadata together in a single cache lookup.
+
*
+
* @param key - The key to retrieve
+
* @returns The data and metadata, or null if not found
+
*/
+
async getWithMetadata(key: string): Promise<TierGetResult | null> {
+
const entry = this.cache.get(key);
+
+
if (!entry) {
+
this.stats.misses++;
+
return null;
+
}
+
+
this.stats.hits++;
+
return { data: entry.data, metadata: entry.metadata };
}
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
+70 -1
src/tiers/S3StorageTier.ts
···
type S3ClientConfig,
} from '@aws-sdk/client-s3';
import type { Readable } from 'node:stream';
-
import type { StorageTier, StorageMetadata, TierStats } from '../types/index.js';
+
import type { StorageTier, StorageMetadata, TierStats, TierGetResult } from '../types/index.js';
/**
* Configuration for S3StorageTier.
···
}
return await this.streamToUint8Array(response.Body as Readable);
+
} catch (error) {
+
if (this.isNoSuchKeyError(error)) {
+
return null;
+
}
+
throw error;
+
}
+
}
+
+
/**
+
* Retrieve data and metadata together in a single operation.
+
*
+
* @param key - The key to retrieve
+
* @returns The data and metadata, or null if not found
+
*
+
* @remarks
+
* When using a separate metadata bucket, fetches data and metadata in parallel.
+
* Otherwise, uses the data object's embedded metadata.
+
*/
+
async getWithMetadata(key: string): Promise<TierGetResult | null> {
+
const s3Key = this.getS3Key(key);
+
+
try {
+
if (this.metadataBucket) {
+
// Fetch data and metadata in parallel
+
const [dataResponse, metadataResponse] = await Promise.all([
+
this.client.send(new GetObjectCommand({
+
Bucket: this.config.bucket,
+
Key: s3Key,
+
})),
+
this.client.send(new GetObjectCommand({
+
Bucket: this.metadataBucket,
+
Key: s3Key + '.meta',
+
})),
+
]);
+
+
if (!dataResponse.Body || !metadataResponse.Body) {
+
return null;
+
}
+
+
const [data, metaBuffer] = await Promise.all([
+
this.streamToUint8Array(dataResponse.Body as Readable),
+
this.streamToUint8Array(metadataResponse.Body as Readable),
+
]);
+
+
const json = new TextDecoder().decode(metaBuffer);
+
const metadata = JSON.parse(json) as StorageMetadata;
+
metadata.createdAt = new Date(metadata.createdAt);
+
metadata.lastAccessed = new Date(metadata.lastAccessed);
+
if (metadata.ttl) {
+
metadata.ttl = new Date(metadata.ttl);
+
}
+
+
return { data, metadata };
+
} else {
+
// Get data with embedded metadata from response headers
+
const response = await this.client.send(new GetObjectCommand({
+
Bucket: this.config.bucket,
+
Key: s3Key,
+
}));
+
+
if (!response.Body || !response.Metadata) {
+
return null;
+
}
+
+
const data = await this.streamToUint8Array(response.Body as Readable);
+
const metadata = this.s3ToMetadata(response.Metadata);
+
+
return { data, metadata };
+
}
} catch (error) {
if (this.isNoSuchKeyError(error)) {
return null;
+67
src/types/index.ts
···
* }
* ```
*/
+
/**
+
* Result from a combined get+metadata operation on a tier.
+
*/
+
export interface TierGetResult {
+
/** The retrieved data */
+
data: Uint8Array;
+
/** Metadata associated with the data */
+
metadata: StorageMetadata;
+
}
+
export interface StorageTier {
/**
* Retrieve data for a key.
···
* @returns The data as a Uint8Array, or null if not found
*/
get(key: string): Promise<Uint8Array | null>;
+
+
/**
+
* Retrieve data and metadata together in a single operation.
+
*
+
* @param key - The key to retrieve
+
* @returns The data and metadata, or null if not found
+
*
+
* @remarks
+
* This is more efficient than calling get() and getMetadata() separately,
+
* especially for disk and network-based tiers.
+
*/
+
getWithMetadata?(key: string): Promise<TierGetResult | null>;
/**
* Store data with associated metadata.
···
}
/**
+
* Rule for automatic tier placement based on key patterns.
+
*
+
* @remarks
+
* Rules are evaluated in order. First matching rule wins.
+
* Use this to define which keys go to which tiers without
+
* specifying skipTiers on every set() call.
+
*
+
* @example
+
* ```typescript
+
* placementRules: [
+
* { pattern: 'index.html', tiers: ['hot', 'warm', 'cold'] },
+
* { pattern: '*.html', tiers: ['warm', 'cold'] },
+
* { pattern: 'assets/**', tiers: ['warm', 'cold'] },
+
* { pattern: '**', tiers: ['warm', 'cold'] }, // default
+
* ]
+
* ```
+
*/
+
export interface PlacementRule {
+
/**
+
* Glob pattern to match against keys.
+
*
+
* @remarks
+
* Supports basic globs:
+
* - `*` matches any characters except `/`
+
* - `**` matches any characters including `/`
+
* - Exact matches work too: `index.html`
+
*/
+
pattern: string;
+
+
/**
+
* Which tiers to write to for matching keys.
+
*
+
* @remarks
+
* Cold is always included (source of truth).
+
* Use `['hot', 'warm', 'cold']` for critical files.
+
* Use `['warm', 'cold']` for large files.
+
* Use `['cold']` for archival only.
+
*/
+
tiers: ('hot' | 'warm' | 'cold')[];
+
}
+
+
/**
* Configuration for the TieredStorage system.
*
* @typeParam T - The type of data being stored (for serialization)
···
/** Required cold tier - slowest, largest capacity (e.g., S3, R2, object storage) */
cold: StorageTier;
};
+
+
/** Rules for automatic tier placement based on key patterns. First match wins. */
+
placementRules?: PlacementRule[];
/**
* Whether to automatically compress data before storing.
+40
src/utils/glob.ts
···
+
/**
+
* Simple glob pattern matching for key placement rules.
+
*
+
* Supports:
+
* - `*` matches any characters except `/`
+
* - `**` matches any characters including `/` (including empty string)
+
* - `{a,b,c}` matches any of the alternatives
+
* - Exact strings match exactly
+
*/
+
export function matchGlob(pattern: string, key: string): boolean {
+
// Handle exact match
+
if (!pattern.includes('*') && !pattern.includes('{')) {
+
return pattern === key;
+
}
+
+
// Escape regex special chars (except * and {})
+
let regex = pattern.replace(/[.+^$|\\()[\]]/g, '\\$&');
+
+
// Handle {a,b,c} alternation
+
regex = regex.replace(/\{([^}]+)\}/g, (_, alts) => `(${alts.split(',').join('|')})`);
+
+
// Use placeholder to avoid double-processing
+
const DOUBLE = '\x00DOUBLE\x00';
+
const SINGLE = '\x00SINGLE\x00';
+
+
// Mark ** and * with placeholders
+
regex = regex.replace(/\*\*/g, DOUBLE);
+
regex = regex.replace(/\*/g, SINGLE);
+
+
// Replace placeholders with regex patterns
+
// ** matches anything (including /)
+
// When followed by /, it's optional (matches zero or more path segments)
+
regex = regex
+
.replace(new RegExp(`${DOUBLE}/`, 'g'), '(?:.*/)?') // **/ -> optional path prefix
+
.replace(new RegExp(`/${DOUBLE}`, 'g'), '(?:/.*)?') // /** -> optional path suffix
+
.replace(new RegExp(DOUBLE, 'g'), '.*') // ** alone -> match anything
+
.replace(new RegExp(SINGLE, 'g'), '[^/]*'); // * -> match non-slash
+
+
return new RegExp(`^${regex}$`).test(key);
+
}
+175
test/TieredStorage.test.ts
···
expect(stats.hot?.items).toBe(2);
});
});
+
+
describe('Placement Rules', () => {
+
it('should place index.html in all tiers based on rule', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
+
{ pattern: '**', tiers: ['warm', 'cold'] },
+
],
+
});
+
+
await storage.set('site:abc/index.html', { content: 'hello' });
+
+
expect(await hot.exists('site:abc/index.html')).toBe(true);
+
expect(await warm.exists('site:abc/index.html')).toBe(true);
+
expect(await cold.exists('site:abc/index.html')).toBe(true);
+
});
+
+
it('should skip hot tier for non-matching files', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
{ pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] },
+
{ pattern: '**', tiers: ['warm', 'cold'] },
+
],
+
});
+
+
await storage.set('site:abc/about.html', { content: 'about' });
+
+
expect(await hot.exists('site:abc/about.html')).toBe(false);
+
expect(await warm.exists('site:abc/about.html')).toBe(true);
+
expect(await cold.exists('site:abc/about.html')).toBe(true);
+
});
+
+
it('should match directory patterns', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
+
],
+
});
+
+
await storage.set('assets/images/logo.png', { data: 'png' });
+
await storage.set('index.html', { data: 'html' });
+
+
// assets/** should skip hot
+
expect(await hot.exists('assets/images/logo.png')).toBe(false);
+
expect(await warm.exists('assets/images/logo.png')).toBe(true);
+
+
// everything else goes to all tiers
+
expect(await hot.exists('index.html')).toBe(true);
+
});
+
+
it('should match file extension patterns', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
{ pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] },
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
+
],
+
});
+
+
await storage.set('site/hero.png', { data: 'image' });
+
await storage.set('site/video.mp4', { data: 'video' });
+
await storage.set('site/index.html', { data: 'html' });
+
+
// Images and video skip hot
+
expect(await hot.exists('site/hero.png')).toBe(false);
+
expect(await hot.exists('site/video.mp4')).toBe(false);
+
+
// HTML goes everywhere
+
expect(await hot.exists('site/index.html')).toBe(true);
+
});
+
+
it('should use first matching rule', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
// Specific rule first
+
{ pattern: 'assets/critical.css', tiers: ['hot', 'warm', 'cold'] },
+
// General rule second
+
{ pattern: 'assets/**', tiers: ['warm', 'cold'] },
+
{ pattern: '**', tiers: ['warm', 'cold'] },
+
],
+
});
+
+
await storage.set('assets/critical.css', { data: 'css' });
+
await storage.set('assets/style.css', { data: 'css' });
+
+
// critical.css matches first rule -> hot
+
expect(await hot.exists('assets/critical.css')).toBe(true);
+
+
// style.css matches second rule -> no hot
+
expect(await hot.exists('assets/style.css')).toBe(false);
+
});
+
+
it('should allow skipTiers to override placement rules', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
{ pattern: '**', tiers: ['hot', 'warm', 'cold'] },
+
],
+
});
+
+
// Explicit skipTiers should override the rule
+
await storage.set('large-file.bin', { data: 'big' }, { skipTiers: ['hot'] });
+
+
expect(await hot.exists('large-file.bin')).toBe(false);
+
expect(await warm.exists('large-file.bin')).toBe(true);
+
expect(await cold.exists('large-file.bin')).toBe(true);
+
});
+
+
it('should always include cold tier even if not in rule', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
// Rule doesn't include cold (should be auto-added)
+
{ pattern: '**', tiers: ['hot', 'warm'] },
+
],
+
});
+
+
await storage.set('test-key', { data: 'test' });
+
+
expect(await cold.exists('test-key')).toBe(true);
+
});
+
+
it('should write to all tiers when no rules match', async () => {
+
const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 });
+
const warm = new DiskStorageTier({ directory: `${testDir}/warm` });
+
const cold = new DiskStorageTier({ directory: `${testDir}/cold` });
+
+
const storage = new TieredStorage({
+
tiers: { hot, warm, cold },
+
placementRules: [
+
{ pattern: 'specific-pattern-only', tiers: ['warm', 'cold'] },
+
],
+
});
+
+
// This doesn't match any rule
+
await storage.set('other-key', { data: 'test' });
+
+
expect(await hot.exists('other-key')).toBe(true);
+
expect(await warm.exists('other-key')).toBe(true);
+
expect(await cold.exists('other-key')).toBe(true);
+
});
+
});
});
+95
test/glob.test.ts
···
+
import { describe, it, expect } from 'vitest';
+
import { matchGlob } from '../src/utils/glob.js';
+
+
describe('matchGlob', () => {
+
describe('exact matches', () => {
+
it('should match exact strings', () => {
+
expect(matchGlob('index.html', 'index.html')).toBe(true);
+
expect(matchGlob('index.html', 'about.html')).toBe(false);
+
});
+
+
it('should match paths exactly', () => {
+
expect(matchGlob('site/index.html', 'site/index.html')).toBe(true);
+
expect(matchGlob('site/index.html', 'other/index.html')).toBe(false);
+
});
+
});
+
+
describe('* wildcard', () => {
+
it('should match any characters except /', () => {
+
expect(matchGlob('*.html', 'index.html')).toBe(true);
+
expect(matchGlob('*.html', 'about.html')).toBe(true);
+
expect(matchGlob('*.html', 'style.css')).toBe(false);
+
});
+
+
it('should not match across path separators', () => {
+
expect(matchGlob('*.html', 'dir/index.html')).toBe(false);
+
});
+
+
it('should work with prefix and suffix', () => {
+
expect(matchGlob('index.*', 'index.html')).toBe(true);
+
expect(matchGlob('index.*', 'index.css')).toBe(true);
+
expect(matchGlob('index.*', 'about.html')).toBe(false);
+
});
+
});
+
+
describe('** wildcard', () => {
+
it('should match any characters including /', () => {
+
expect(matchGlob('**', 'anything')).toBe(true);
+
expect(matchGlob('**', 'path/to/file.txt')).toBe(true);
+
});
+
+
it('should match deeply nested paths', () => {
+
expect(matchGlob('**/index.html', 'index.html')).toBe(true);
+
expect(matchGlob('**/index.html', 'site/index.html')).toBe(true);
+
expect(matchGlob('**/index.html', 'a/b/c/index.html')).toBe(true);
+
expect(matchGlob('**/index.html', 'a/b/c/about.html')).toBe(false);
+
});
+
+
it('should match directory prefixes', () => {
+
expect(matchGlob('assets/**', 'assets/style.css')).toBe(true);
+
expect(matchGlob('assets/**', 'assets/images/logo.png')).toBe(true);
+
expect(matchGlob('assets/**', 'other/style.css')).toBe(false);
+
});
+
+
it('should match in the middle of a path', () => {
+
expect(matchGlob('site/**/index.html', 'site/index.html')).toBe(true);
+
expect(matchGlob('site/**/index.html', 'site/pages/index.html')).toBe(true);
+
expect(matchGlob('site/**/index.html', 'site/a/b/c/index.html')).toBe(true);
+
});
+
});
+
+
describe('{a,b,c} alternation', () => {
+
it('should match any of the alternatives', () => {
+
expect(matchGlob('*.{html,css,js}', 'index.html')).toBe(true);
+
expect(matchGlob('*.{html,css,js}', 'style.css')).toBe(true);
+
expect(matchGlob('*.{html,css,js}', 'app.js')).toBe(true);
+
expect(matchGlob('*.{html,css,js}', 'image.png')).toBe(false);
+
});
+
+
it('should work with ** and alternation', () => {
+
expect(matchGlob('**/*.{jpg,png,gif}', 'logo.png')).toBe(true);
+
expect(matchGlob('**/*.{jpg,png,gif}', 'images/logo.png')).toBe(true);
+
expect(matchGlob('**/*.{jpg,png,gif}', 'a/b/photo.jpg')).toBe(true);
+
expect(matchGlob('**/*.{jpg,png,gif}', 'style.css')).toBe(false);
+
});
+
});
+
+
describe('edge cases', () => {
+
it('should handle empty strings', () => {
+
expect(matchGlob('', '')).toBe(true);
+
expect(matchGlob('', 'something')).toBe(false);
+
expect(matchGlob('**', '')).toBe(true);
+
});
+
+
it('should escape regex special characters', () => {
+
expect(matchGlob('file.txt', 'file.txt')).toBe(true);
+
expect(matchGlob('file.txt', 'filextxt')).toBe(false);
+
expect(matchGlob('file[1].txt', 'file[1].txt')).toBe(true);
+
});
+
+
it('should handle keys with colons (common in storage)', () => {
+
expect(matchGlob('site:*/index.html', 'site:abc/index.html')).toBe(true);
+
expect(matchGlob('site:**/index.html', 'site:abc/pages/index.html')).toBe(true);
+
});
+
});
+
});