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

combine get+metadata into single operation across all storage tiers

nekomimi.pet d9e31433 2f40f17b

verified
+5
README.md
···
setMetadata(key: string, metadata: StorageMetadata): Promise<void>
getStats(): Promise<TierStats>
clear(): Promise<void>
}
```
## Running the demo
···
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
+65 -44
src/TieredStorage.ts
···
StorageResult,
SetResult,
StorageMetadata,
AllTierStats,
StorageSnapshot,
PlacementRule,
···
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)) {
await this.delete(key);
return null;
-
} else {
-
await this.updateAccessStats(key, 'hot');
-
return {
-
data: (await this.deserializeData(data)) as T,
-
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)) {
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',
-
};
}
}
}
// 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)) {
await this.delete(key);
return null;
}
// Promote to warm and hot (if configured)
if (this.config.promotionStrategy === 'eager') {
if (this.config.tiers.warm) {
-
await this.config.tiers.warm.set(key, data, metadata);
}
if (this.config.tiers.hot) {
-
await this.config.tiers.hot.set(key, data, metadata);
}
}
-
await this.updateAccessStats(key, 'cold');
return {
-
data: (await this.deserializeData(data)) as T,
-
metadata,
source: 'cold',
};
}
return null;
}
/**
···
StorageResult,
SetResult,
StorageMetadata,
+
StorageTier,
AllTierStats,
StorageSnapshot,
PlacementRule,
···
async getWithMetadata(key: string): Promise<StorageResult<T> | null> {
// 1. Check hot tier first
if (this.config.tiers.hot) {
+
const result = await this.getFromTier(this.config.tiers.hot, key);
+
if (result) {
+
if (this.isExpired(result.metadata)) {
await this.delete(key);
return null;
}
+
// 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 result = await this.getFromTier(this.config.tiers.warm, key);
+
if (result) {
+
if (this.isExpired(result.metadata)) {
await this.delete(key);
return null;
+
}
+
// 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 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) {
+
promotions.push(this.config.tiers.warm.set(key, result.data, result.metadata));
}
if (this.config.tiers.hot) {
+
promotions.push(this.config.tiers.hot.set(key, result.data, result.metadata));
}
+
await Promise.all(promotions);
}
+
// Fire-and-forget access stats update (non-critical)
+
void this.updateAccessStats(key, 'cold');
return {
+
data: (await this.deserializeData(result.data)) as T,
+
metadata: result.metadata,
source: 'cold',
};
}
return null;
+
}
+
+
/**
+
* 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 };
}
/**
+1
src/index.ts
···
StorageTier,
StorageMetadata,
TierStats,
AllTierStats,
TieredStorageConfig,
PlacementRule,
···
StorageTier,
StorageMetadata,
TierStats,
+
TierGetResult,
AllTierStats,
TieredStorageConfig,
PlacementRule,
+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 { encodeKey } from '../utils/path-encoding.js';
/**
···
try {
const data = await readFile(filePath);
-
const metadata = await this.getMetadata(key);
-
if (metadata) {
-
metadata.lastAccessed = new Date();
-
metadata.accessCount++;
-
await this.setMetadata(key, metadata);
-
const entry = this.metadataIndex.get(key);
-
if (entry) {
-
entry.lastAccessed = metadata.lastAccessed;
-
}
}
-
return new Uint8Array(data);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
···
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, 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;
+
}
+
}
+
/**
+
* 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 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 { 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';
interface CacheEntry {
data: Uint8Array;
···
this.stats.hits++;
return entry.data;
}
async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> {
···
import { lru } from 'tiny-lru';
+
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';
/**
* Configuration for S3StorageTier.
···
}
return await this.streamToUint8Array(response.Body as Readable);
} catch (error) {
if (this.isNoSuchKeyError(error)) {
return null;
···
type S3ClientConfig,
} from '@aws-sdk/client-s3';
import type { Readable } from 'node:stream';
+
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;
+22
src/types/index.ts
···
* }
* ```
*/
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>;
/**
* Store data with associated metadata.
···
* }
* ```
*/
+
/**
+
* 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.