Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const config: Config = {
"^@filters/(.*)$": "<rootDir>/src/filters/$1",
"^@middleware/(.*)$": "<rootDir>/src/middleware/$1",
"^@utils/(.*)$": "<rootDir>/src/utils/$1",
"^@ports/(.*)$": "<rootDir>/src/ports/$1",
"^@adapters/(.*)$": "<rootDir>/src/adapters/$1",
},
};

Expand Down
92 changes: 89 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
},
"dependencies": {
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1"
"class-validator": "^0.14.1",
"ioredis": "^5.10.1"
},
"devDependencies": {
"@changesets/cli": "^2.27.7",
Expand Down Expand Up @@ -79,4 +80,4 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.50.1"
}
}
}
120 changes: 120 additions & 0 deletions src/adapters/in-memory-cache-store.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @file in-memory-cache-store.adapter.ts
*
* In-memory implementation of ICacheStore backed by a plain JavaScript Map.
*
* Behaviour:
* - Values are JSON-serialized on write and JSON-parsed on read, matching
* the Redis adapter exactly so both can be swapped transparently.
* - TTL is enforced lazily: an expired entry is evicted the first time it
* is read, rather than via a background sweep timer.
* - A parse failure (malformed JSON) returns null instead of throwing.
* - No external dependencies — suitable for unit tests, local development,
* or lightweight production usage that does not require persistence.
*
* Exports:
* - CacheEntry → internal shape of stored entries (exported for tests)
* - InMemoryCacheStore → the concrete in-memory adapter class
*/

import type { ICacheStore } from "@ports/cache-store.port";

// ---------------------------------------------------------------------------
// Internal data shape
// ---------------------------------------------------------------------------

/**
* Shape of each entry held inside the backing Map.
* Exported so that unit tests can inspect the internal store if needed.
*/
export interface CacheEntry {
/** JSON-serialized representation of the cached value */
value: string;

/**
* Absolute Unix timestamp (ms) at which this entry expires.
* null means the entry never expires.
*/
expiresAt: number | null;
}

// ---------------------------------------------------------------------------
// Adapter
// ---------------------------------------------------------------------------

/**
* In-memory adapter for the ICacheStore port.
*
* Usage:
* ```typescript
* const store = new InMemoryCacheStore();
* await store.set("session:abc", { userId: 1 }, 60); // expires in 60 s
* const session = await store.get<Session>("session:abc");
* ```
*/
export class InMemoryCacheStore implements ICacheStore {
/**
* The backing store.
* Maps every cache key to its serialized value and optional expiry timestamp.
*/
private readonly store = new Map<string, CacheEntry>();

// ---------------------------------------------------------------------------
// ICacheStore implementation
// ---------------------------------------------------------------------------

/** {@inheritDoc ICacheStore.get} */
async get<T>(key: string): Promise<T | null> {
// Look up the entry — undefined means the key was never set or was deleted
const entry = this.store.get(key);

// Key does not exist in the store
if (entry === undefined) return null;

// Lazy TTL expiry: check whether the entry has passed its deadline.
// Date.now() returns the current time in milliseconds.
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
// Remove the stale entry and treat the lookup as a cache miss
this.store.delete(key);
return null;
}

// Deserialize the stored JSON string back to the caller's expected type.
// Return null on malformed JSON instead of propagating a SyntaxError.
try {
return JSON.parse(entry.value) as T;
} catch {
// Parse failure — treat as a cache miss
return null;
}
}

/** {@inheritDoc ICacheStore.set} */
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
// Compute the absolute expiry timestamp from the relative TTL.
// Multiply seconds by 1 000 to convert to milliseconds for Date.now() comparison.
// null signals "no expiry" so the entry lives until deleted or clear() is called.
const expiresAt =
ttlSeconds !== undefined && ttlSeconds > 0
? Date.now() + ttlSeconds * 1_000
: null;

// Serialize the value to a JSON string before storing to match Redis adapter behaviour
this.store.set(key, {
value: JSON.stringify(value),
expiresAt,
});
}

/** {@inheritDoc ICacheStore.delete} */
async delete(key: string): Promise<void> {
// Map.delete is a no-op when the key does not exist — no guard required
this.store.delete(key);
}

/** {@inheritDoc ICacheStore.clear} */
async clear(): Promise<void> {
// Remove every entry from the backing Map in O(1)
this.store.clear();
}
}
Loading
Loading