alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
215 lines (193 loc) • 6.18 kB
text/typescript
import { CacheError } from "../errors/CacheError.ts";
/**
* Cache provider interface.
*
* All methods are asynchronous and return promises.
* Values are stored as Uint8Array.
*/
export abstract class CacheProvider {
protected encoder: TextEncoder = new TextEncoder();
protected decoder: TextDecoder = new TextDecoder();
protected codes = {
BINARY: 0x01,
JSON: 0x02,
STRING: 0x03,
COMPRESSED: 0x04,
};
/**
* Get the value of a key.
*
* @param name Cache name, used to group keys. Should be Redis-like "some:group:name" format.
* @param key The key of the value to get.
*
* @return The value of the key, or undefined if the key does not exist.
*/
public abstract get(
name: string,
key: string,
): Promise<Uint8Array | undefined>;
/**
* Set the string value of a key.
*
* @param name Cache name, used to group keys. Should be Redis-like "some:group:name" format.
* @param key The key of the value to set.
* @param value The value to set.
* @param ttl The time-to-live of the key, in milliseconds.
*
* @return The value of the key.
*/
public abstract set(
name: string,
key: string,
value: Uint8Array,
ttl?: number,
): Promise<Uint8Array>;
/**
* Remove the specified keys.
*
* @param name Cache name, used to group keys. Should be Redis-like "some:group:name" format.
* @param keys The keys to delete.
*/
public abstract del(name: string, ...keys: string[]): Promise<void>;
public abstract has(name: string, key: string): Promise<boolean>;
public abstract keys(name: string, filter?: string): Promise<string[]>;
/**
* Remove all keys from all cache names.
*/
public abstract clear(): Promise<void>;
/**
* Increment the integer value of a key by the given amount.
*
* If the key does not exist, it is set to 0 before performing the operation.
* This operation is atomic when using Redis.
*
* @param name Cache name, used to group keys.
* @param key The key to increment.
* @param amount The amount to increment by.
* @returns The new value after incrementing.
*/
public abstract incr(
name: string,
key: string,
amount: number,
): Promise<number>;
// ---------------------------------------------------------------------------
// High-level methods — serialize/compress layer used by CachePrimitive
// ---------------------------------------------------------------------------
/**
* Set a typed value with automatic serialization and optional compression.
*/
public async setTyped(
name: string,
key: string,
value: unknown,
options?: { ttl?: number; compress?: boolean },
): Promise<void> {
let data = this.serialize(value);
if (options?.compress) {
data = await this.compress(data);
}
await this.set(name, key, data, options?.ttl);
}
/**
* Get a typed value with automatic deserialization and optional decompression.
*/
public async getTyped<T>(name: string, key: string): Promise<T | undefined> {
const data = await this.get(name, key);
if (data) {
if (data[0] === this.codes.COMPRESSED) {
const decompressed = await this.decompress(data.subarray(1));
return this.deserialize<T>(decompressed);
}
return this.deserialize<T>(data);
}
return undefined;
}
/**
* Invalidate cache keys with wildcard support.
*
* Keys ending in `*` are expanded via `this.keys()`.
* Called with no keys, delegates to `this.del(name)` which clears the entire container.
*/
public async invalidateKeys(name: string, keys: string[]): Promise<void> {
const keysToDelete: string[] = [];
for (const key of keys) {
if (key.endsWith("*")) {
const result = await this.keys(name, key.slice(0, -1));
keysToDelete.push(...result);
} else {
keysToDelete.push(key);
}
}
await this.del(name, ...keysToDelete);
}
/**
* Serialize a value to a typed Uint8Array with a leading type marker byte.
*/
protected serialize(value: unknown): Uint8Array {
if (value instanceof Uint8Array) {
const result = new Uint8Array(1 + value.length);
result[0] = this.codes.BINARY;
result.set(value, 1);
return result;
}
const encoded = this.encoder.encode(
typeof value === "string" ? value : JSON.stringify(value),
);
const code =
typeof value === "string" ? this.codes.STRING : this.codes.JSON;
const result = new Uint8Array(1 + encoded.length);
result[0] = code;
result.set(encoded, 1);
return result;
}
/**
* Deserialize a typed Uint8Array back to the original value.
*/
protected deserialize<T>(uint8Array: Uint8Array): T {
const type = uint8Array[0];
const payload = uint8Array.slice(1);
if (type === this.codes.BINARY) {
return payload as T;
}
if (type === this.codes.JSON) {
return JSON.parse(this.decoder.decode(payload)) as T;
}
if (type === this.codes.STRING) {
return this.decoder.decode(payload) as T;
}
throw new CacheError(`Unknown serialization type: ${type}`);
}
/**
* Compress data with gzip, prepending a COMPRESSED marker byte.
*/
protected async compress(data: Uint8Array): Promise<Uint8Array> {
const buf = (data.buffer as ArrayBuffer).slice(
data.byteOffset,
data.byteOffset + data.byteLength,
);
const compressed = new Uint8Array(
await new Response(
new Blob([buf]).stream().pipeThrough(new CompressionStream("gzip")),
).arrayBuffer(),
);
const result = new Uint8Array(1 + compressed.length);
result[0] = this.codes.COMPRESSED;
result.set(compressed, 1);
return result;
}
/**
* Decompress gzipped data.
*/
protected async decompress(data: Uint8Array): Promise<Uint8Array> {
const buf = (data.buffer as ArrayBuffer).slice(
data.byteOffset,
data.byteOffset + data.byteLength,
);
return new Uint8Array(
await new Response(
new Blob([buf]).stream().pipeThrough(new DecompressionStream("gzip")),
).arrayBuffer(),
);
}
}