Skip to content
63 changes: 36 additions & 27 deletions core/keyv/src/adapters/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import { Hookified } from "hookified";
import { detectKeyvStorage, type KeyvStorageCapability } from "../capabilities.js";
import { Keyv } from "../keyv.js";
import { type KeyvEntry, KeyvEvents, type KeyvStorageAdapter, type StoredData } from "../types.js";
import { isDataExpired } from "../utils.js";

/**
* Internal wrapper for values stored in the memory adapter.
* Keeps expiry metadata co-located with the value but outside
* the serialized/compressed/encrypted payload.
*/
type MemoryEntry = {
value: unknown;
expires?: number;
};

/**
* Configuration options for KeyvMemoryAdapter.
Expand Down Expand Up @@ -178,18 +187,17 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
*/
public async get<T>(key: string): Promise<StoredData<T> | undefined> {
const keyPrefix = this.getKeyPrefix(key, this._namespace);
const data = this._store.get(keyPrefix);
if (data === undefined || data === null) {
const entry = this._store.get(keyPrefix) as MemoryEntry | undefined;
if (entry === undefined || entry === null) {
return undefined;
}

// Check if it is expired
if (isDataExpired(data)) {
if (entry.expires !== undefined && Date.now() > entry.expires) {
this._store.delete(keyPrefix);
return undefined;
}

return data as T;
return entry.value as T;
}

/**
Expand All @@ -201,7 +209,11 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
*/
public async set(key: string, value: any, ttl?: number): Promise<boolean> {
const keyPrefix = this.getKeyPrefix(key, this._namespace);
this._store.set(keyPrefix, value, ttl);
const entry: MemoryEntry = {
value,
expires: ttl ? Date.now() + ttl : undefined,
};
this._store.set(keyPrefix, entry, ttl);
return true;
}

Expand All @@ -213,7 +225,11 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
const results: boolean[] = [];
for (const entry of entries) {
const keyPrefix = this.getKeyPrefix(entry.key, this._namespace);
this._store.set(keyPrefix, entry.value, entry.ttl);
const memEntry: MemoryEntry = {
value: entry.value,
expires: entry.ttl ? Date.now() + entry.ttl : undefined,
};
this._store.set(keyPrefix, memEntry, entry.ttl);
results.push(true);
}

Expand Down Expand Up @@ -261,21 +277,12 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
*/
public async has(key: string): Promise<boolean> {
const keyPrefix = this.getKeyPrefix(key, this._namespace);
if (!this._store.has(keyPrefix)) {
const entry = this._store.get(keyPrefix) as MemoryEntry | undefined;
if (entry === undefined || entry === null) {
return false;
}

let data = this._store.get(keyPrefix);
// Handle serialized data (stored as JSON string by Keyv's serialization layer)
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// Not valid JSON, treat as raw value
}
}

if (data !== undefined && data !== null && isDataExpired(data)) {
if (entry.expires !== undefined && Date.now() > entry.expires) {
this._store.delete(keyPrefix);
return false;
}
Expand Down Expand Up @@ -306,19 +313,19 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
const values: Array<StoredData<T | undefined>> = [];
for (const key of keys) {
const keyPrefix = this.getKeyPrefix(key, this._namespace);
const data = this._store.get(keyPrefix);
if (data === undefined || data === null) {
const entry = this._store.get(keyPrefix) as MemoryEntry | undefined;
if (entry === undefined || entry === null) {
values.push(undefined as StoredData<T | undefined>);
continue;
}

if (isDataExpired(data)) {
if (entry.expires !== undefined && Date.now() > entry.expires) {
this._store.delete(keyPrefix);
values.push(undefined as StoredData<T | undefined>);
continue;
}

values.push(data as StoredData<T | undefined>);
values.push(entry.value as StoredData<T | undefined>);
}

return values;
Expand Down Expand Up @@ -363,16 +370,18 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
const namespace = this._namespace;
const entries = [...(this._store as Map<any, any>).entries()];

for (const [key, data] of entries) {
for (const [key, raw] of entries) {
// Filter by namespace if set
if (namespace) {
if (!key.startsWith(`${namespace}${this._keySeparator}`)) {
continue;
}
}

const entry = raw as MemoryEntry;

// Check expiration
if (data && isDataExpired(data)) {
if (entry?.expires !== undefined && Date.now() > entry.expires) {
this._store.delete(key);
continue;
}
Expand All @@ -382,7 +391,7 @@ export class KeyvMemoryAdapter extends Hookified implements KeyvStorageAdapter {
? key.slice(namespace.length + this._keySeparator.length)
: key;

yield [keyWithoutPrefix, data];
yield [keyWithoutPrefix, entry?.value];
}
}

Expand Down
90 changes: 83 additions & 7 deletions core/keyv/src/keyv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export class Keyv<GenericValue = any> extends Hookified {
*/
private _sanitize!: KeyvSanitizeAdapter;

/**
* When true, Keyv checks expiry at its layer on get/getMany/has/hasMany.
*/
private _checkExpired = false;

/**
* Keyv Constructor
* @param {KeyvStorageAdapter | KeyvOptions | Map<any, any> | any} store to be provided or just the options
Expand Down Expand Up @@ -115,6 +120,7 @@ export class Keyv<GenericValue = any> extends Hookified {
}

this.setTtl(mergedOptions.ttl);
this._checkExpired = mergedOptions.checkExpired ?? false;
}

/**
Expand Down Expand Up @@ -265,6 +271,14 @@ export class Keyv<GenericValue = any> extends Hookified {
return this._stats;
}

/**
* When true, Keyv checks expiry at its layer on get/getMany/has/hasMany.
* When false (default), trusts the storage adapter.
*/
public get checkExpired(): boolean {
return this._checkExpired;
}

/**
* Set the stats. When setting a new instance it will unsubscribe the old listeners
* and subscribe the new instance.
Expand Down Expand Up @@ -370,7 +384,17 @@ export class Keyv<GenericValue = any> extends Hookified {
this.emitTelemetry(KeyvEvents.STAT_ERROR, key as string);
}

const [data] = await this.decodeWithExpire<Value>(key as string, rawData);
let data: KeyvValue<Value> | undefined;
if (this._checkExpired) {
[data] = await this.decodeWithExpire<Value>(key as string, rawData);
} else {
data =
rawData === undefined || rawData === null
? undefined
: typeof rawData === "string"
? await this.decode<Value>(rawData)
: (rawData as KeyvValue<Value>);
}

if (data === undefined) {
await this.hookWithDeprecated(KeyvHooks.AFTER_GET, {
Expand Down Expand Up @@ -401,7 +425,21 @@ export class Keyv<GenericValue = any> extends Hookified {
const rawData =
await // biome-ignore lint/style/noNonNullAssertion: guaranteed by resolveStore
this._store.getMany!<Value>(keys);
const deserialized = await this.decodeWithExpire<Value>(keys, rawData as unknown[]);

let deserialized: Array<KeyvValue<Value> | undefined>;
if (this._checkExpired) {
deserialized = await this.decodeWithExpire<Value>(keys, rawData as unknown[]);
} else {
deserialized = await Promise.all(
(rawData as unknown[]).map(async (row) => {
if (row === undefined || row === null) {
return undefined;
}

return typeof row === "string" ? this.decode<Value>(row) : (row as KeyvValue<Value>);
}),
);
}

const result: Array<Value | undefined> = deserialized.map((row) =>
row !== undefined ? row.value : undefined,
Expand Down Expand Up @@ -436,7 +474,17 @@ export class Keyv<GenericValue = any> extends Hookified {
await this.hookWithDeprecated(KeyvHooks.BEFORE_GET_RAW, { key });
const rawData = await this._store.get(key);

const [data] = await this.decodeWithExpire<Value>(key, rawData);
let data: KeyvValue<Value> | undefined;
if (this._checkExpired) {
[data] = await this.decodeWithExpire<Value>(key, rawData);
} else {
data =
rawData === undefined || rawData === null
? undefined
: typeof rawData === "string"
? await this.decode<Value>(rawData)
: (rawData as KeyvValue<Value>);
}

if (data === undefined) {
await this.hookWithDeprecated(KeyvHooks.AFTER_GET_RAW, {
Expand Down Expand Up @@ -482,7 +530,21 @@ export class Keyv<GenericValue = any> extends Hookified {
const rawData =
await // biome-ignore lint/style/noNonNullAssertion: guaranteed by resolveStore
this._store.getMany!<Value>(keys);
const result = await this.decodeWithExpire<Value>(keys, rawData as unknown[]);

let result: Array<KeyvValue<Value> | undefined>;
if (this._checkExpired) {
result = await this.decodeWithExpire<Value>(keys, rawData as unknown[]);
} else {
result = await Promise.all(
(rawData as unknown[]).map(async (row) => {
if (row === undefined || row === null) {
return undefined;
}

return typeof row === "string" ? this.decode<Value>(row) : (row as KeyvValue<Value>);
}),
);
}

// Add in hits and misses
for (let i = 0; i < result.length; i++) {
Expand Down Expand Up @@ -818,7 +880,15 @@ export class Keyv<GenericValue = any> extends Hookified {

let result = false;
try {
result = await this._store.has(key);
if (this._checkExpired) {
const rawData = await this._store.get(key);
if (rawData !== undefined && rawData !== null) {
const [data] = await this.decodeWithExpire(key, rawData);
result = data !== undefined;
}
} else {
result = await this._store.has(key);
}
} catch (error) {
this.emit(KeyvEvents.ERROR, error);
this.emitTelemetry(KeyvEvents.STAT_ERROR, key as string);
Expand All @@ -840,7 +910,13 @@ export class Keyv<GenericValue = any> extends Hookified {

let results: boolean[] = [];
try {
results = await this._store.hasMany(keys);
if (this._checkExpired) {
const rawData = await this._store.getMany(keys);
const deserialized = await this.decodeWithExpire(keys, rawData as unknown[]);
results = deserialized.map((row) => row !== undefined);
} else {
results = await this._store.hasMany(keys);
}
} catch (error) {
this.emit(KeyvEvents.ERROR, error);
this.emitTelemetry(KeyvEvents.STAT_ERROR, keys);
Expand Down Expand Up @@ -896,7 +972,7 @@ export class Keyv<GenericValue = any> extends Hookified {
for await (const [key, raw] of this._store.iterator()) {
const data = await this.decode(raw as string);

if (data && isDataExpired(data)) {
if (this._checkExpired && data && isDataExpired(data)) {
await this.delete(key as string);
continue;
}
Expand Down
6 changes: 6 additions & 0 deletions core/keyv/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,12 @@ export type KeyvOptions = {
* @default undefined
*/
encryption?: KeyvEncryptionAdapter;
/**
* When true, Keyv checks expiry on get/getMany/has/hasMany at its layer.
* When false (default), trusts the storage adapter to handle expiry.
* @default false
*/
checkExpired?: boolean;
};

/**
Expand Down
5 changes: 4 additions & 1 deletion core/keyv/test/adapters/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ describe("Keyv Generic set / get / has Operations", () => {
const store = new Map();
const keyv = new KeyvMemoryAdapter(store);
const key = faker.string.uuid();
await keyv.set(key, { value: "test", expires: Date.now() - 1000 });
await keyv.set(key, "test", 1);
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
expect(await keyv.has(key)).toBe(false);
expect(store.has(key)).toBe(false);
});
Expand Down
13 changes: 3 additions & 10 deletions core/keyv/test/get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,7 @@ describe("Keyv", async () => {
});

test("handles expired values correctly", async () => {
const deleteManyMock = vi.fn();
const adapter = new KeyvMemoryAdapter(new Map());
adapter.deleteMany = deleteManyMock;
const deleteSpy = vi.spyOn(adapter, "delete");
const keyv = new Keyv({ store: adapter });
await keyv.setMany(
testData.map((data) => ({
Expand All @@ -82,12 +79,8 @@ describe("Keyv", async () => {
vi.advanceTimersByTime(1001);
const result = await keyv.getMany(testKeys);
expect(result.length).toEqual(testData.length);
// It should return undefined for expired keys
// It should return undefined for expired keys (adapter handles expiry)
expect(result[0]).toBeUndefined();
// It should call deleteMany with all the keys at once
expect(deleteManyMock).toHaveBeenCalledWith(testKeys);
// It should not call delete for each key individually
expect(deleteSpy).not.toHaveBeenCalled();
});
});
});
Expand Down Expand Up @@ -124,7 +117,7 @@ testRunner.it("Keyv should wait for the expired get", async (t) => {
},
} as KeyvStorageAdapter;

const keyv = new Keyv({ store });
const keyv = new Keyv({ store, checkExpired: true });

// Round 1
const v1 = await keyv.get("foo");
Expand Down Expand Up @@ -330,7 +323,7 @@ testRunner.it("getMany should fallback to individual get when store has no getMa
testRunner.it("getMany fallback should handle expired keys", async (t) => {
const store = createStore();
store.getMany = undefined as unknown as typeof store.getMany;
const keyv = new Keyv({ store });
const keyv = new Keyv({ store, checkExpired: true });
await keyv.set("key1", "val1", 1);
await snooze(100);
const result = await keyv.getMany(["key1"]);
Expand Down
Loading
Loading