From c071cdbc8e4e012c236ccdbc1aad8973b4513e6a Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Thu, 26 Feb 2026 11:43:26 -0800 Subject: [PATCH 1/5] node-cache - fix: ttl was not defaulting to 0 --- packages/node-cache/src/index.ts | 47 ++++++++++++++++---------- packages/node-cache/test/index.test.ts | 25 ++++++++++++++ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/packages/node-cache/src/index.ts b/packages/node-cache/src/index.ts index 8c0ab79a..a6cd01ad 100644 --- a/packages/node-cache/src/index.ts +++ b/packages/node-cache/src/index.ts @@ -114,11 +114,7 @@ export class NodeCache extends Hookified { * @param {number | string} [ttl] - this is in seconds and undefined will use the default ttl * @returns {boolean} */ - public set( - key: string | number, - value: T, - ttl: number | string = 0, - ): boolean { + public set(key: string | number, value: T, ttl?: number | string): boolean { // Check on key type /* v8 ignore next -- @preserve */ if (typeof key !== "string" && typeof key !== "number") { @@ -127,24 +123,34 @@ export class NodeCache extends Hookified { // Check on ttl type /* v8 ignore next -- @preserve */ - if (ttl && typeof ttl !== "number" && typeof ttl !== "string") { + if ( + ttl !== undefined && + typeof ttl !== "number" && + typeof ttl !== "string" + ) { throw this.createError(NodeCacheErrors.ETTLTYPE, this.formatKey(key)); } - const keyValue = this.formatKey(key); - let ttlValue = 0; - if (this.options.stdTTL) { - ttlValue = this.getExpirationTimestamp(this.options.stdTTL); - } - - if (ttl) { - ttlValue = this.getExpirationTimestamp(ttl); + // Reject negative TTL values + if (typeof ttl === "number" && ttl < 0) { + return false; } - let expirationTimestamp = 0; // Never delete - if (ttlValue && ttlValue > 0) { - expirationTimestamp = ttlValue; + const keyValue = this.formatKey(key); + let expirationTimestamp = 0; // 0 = never delete + + if (ttl !== undefined && (typeof ttl === "string" || ttl > 0)) { + // Explicit positive TTL or string shorthand overrides stdTTL + expirationTimestamp = this.getExpirationTimestamp(ttl); + } else if ( + ttl === undefined && + this.options.stdTTL && + (typeof this.options.stdTTL === "string" || this.options.stdTTL > 0) + ) { + // ttl omitted, fall back to stdTTL if set + expirationTimestamp = this.getExpirationTimestamp(this.options.stdTTL); } + // ttl === 0 means cache indefinitely (expirationTimestamp stays 0) // Check on max key size /* v8 ignore next -- @preserve */ @@ -162,7 +168,7 @@ export class NodeCache extends Hookified { }); // Event - this.emit("set", keyValue, value, ttlValue); + this.emit("set", keyValue, value, expirationTimestamp); // Add the bytes to the stats this._stats.incrementKSize(keyValue); @@ -321,6 +327,11 @@ export class NodeCache extends Hookified { * @returns {boolean} true if the key has been found and changed. Otherwise returns false. */ public ttl(key: string | number, ttl?: number | string): boolean { + // Reject negative TTL values + if (typeof ttl === "number" && ttl < 0) { + return false; + } + const result = this.store.get(this.formatKey(key)); if (result) { // biome-ignore lint/style/noNonNullAssertion: need to fix diff --git a/packages/node-cache/test/index.test.ts b/packages/node-cache/test/index.test.ts index e5c35cec..46771e08 100644 --- a/packages/node-cache/test/index.test.ts +++ b/packages/node-cache/test/index.test.ts @@ -235,6 +235,31 @@ describe("NodeCache", () => { expect(cache.get("moo")).toBe(undefined); }); + test("should cache indefinitely when ttl is explicitly 0 even with stdTTL set", async () => { + const cache = new NodeCache({ checkperiod: 0, stdTTL: 0.5 }); + cache.set("withStdTTL", "expires"); // omitted ttl → uses stdTTL + cache.set("unlimited", "stays", 0); // explicit 0 → cache indefinitely + await sleep(600); + expect(cache.get("withStdTTL")).toBe(undefined); + expect(cache.get("unlimited")).toBe("stays"); + }); + + test("should return false and not store key when ttl is negative", () => { + const cache = new NodeCache({ checkperiod: 0 }); + const result = cache.set("foo", "bar", -1); + expect(result).toBe(false); + expect(cache.has("foo")).toBe(false); + expect(cache.get("foo")).toBe(undefined); + }); + + test("should return false on ttl() method when ttl is negative", () => { + const cache = new NodeCache({ checkperiod: 0 }); + cache.set("foo", "bar"); + const result = cache.ttl("foo", -1); + expect(result).toBe(false); + expect(cache.get("foo")).toBe("bar"); + }); + test("should get the internal id and stop the interval", () => { const cache = new NodeCache(); expect(cache.getIntervalId()).toBeDefined(); From cb97aa8872e8a828b7e2b81852f1c42d55746f0c Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Thu, 26 Feb 2026 11:50:30 -0800 Subject: [PATCH 2/5] handle the ttl better --- packages/node-cache/src/index.ts | 20 +++++++++++++++++--- packages/node-cache/test/index.test.ts | 24 +++++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/node-cache/src/index.ts b/packages/node-cache/src/index.ts index a6cd01ad..7b13b4dd 100644 --- a/packages/node-cache/src/index.ts +++ b/packages/node-cache/src/index.ts @@ -334,9 +334,23 @@ export class NodeCache extends Hookified { const result = this.store.get(this.formatKey(key)); if (result) { - // biome-ignore lint/style/noNonNullAssertion: need to fix - const ttlValue = ttl ?? this.options.stdTTL!; - result.ttl = this.getExpirationTimestamp(ttlValue); + if (ttl !== undefined && (typeof ttl === "string" || ttl > 0)) { + // Explicit positive TTL or string shorthand + result.ttl = this.getExpirationTimestamp(ttl); + } else if (ttl === 0) { + // Explicit 0 = unlimited + result.ttl = 0; + } else if ( + this.options.stdTTL && + (typeof this.options.stdTTL === "string" || this.options.stdTTL > 0) + ) { + // ttl omitted, fall back to stdTTL + result.ttl = this.getExpirationTimestamp(this.options.stdTTL); + } else { + // No ttl, no stdTTL = unlimited + result.ttl = 0; + } + this.store.set(this.formatKey(key), result); return true; } diff --git a/packages/node-cache/test/index.test.ts b/packages/node-cache/test/index.test.ts index 46771e08..dc1bb356 100644 --- a/packages/node-cache/test/index.test.ts +++ b/packages/node-cache/test/index.test.ts @@ -127,12 +127,12 @@ describe("NodeCache", () => { test("ttl should default to 0 if no ttl is set", () => { const cache = new NodeCache({ checkperiod: 0 }); - cache.set("foo", "bar"); // Set to 10 by stdTTL + cache.set("foo", "bar"); const ttl = cache.getTtl("foo"); expect(ttl).toBe(0); - cache.ttl("foo"); + cache.ttl("foo"); // No args, stdTTL is 0 → stays unlimited const ttl2 = cache.getTtl("foo"); - expect(ttl2).toBeGreaterThan(ttl!); + expect(ttl2).toBe(0); }); test("should return 0 if there is no key to delete", () => { @@ -260,6 +260,24 @@ describe("NodeCache", () => { expect(cache.get("foo")).toBe("bar"); }); + test("should set unlimited expiration on ttl() method when ttl is 0", async () => { + const cache = new NodeCache({ checkperiod: 0, stdTTL: 0.5 }); + cache.set("foo", "bar"); // uses stdTTL (0.5s) + cache.ttl("foo", 0); // override to unlimited + await sleep(600); + expect(cache.get("foo")).toBe("bar"); + expect(cache.getTtl("foo")).toBe(0); + }); + + test("should use stdTTL when ttl() is called without ttl argument and stdTTL is set", () => { + const cache = new NodeCache({ checkperiod: 0, stdTTL: 60 }); + cache.set("foo", "bar", 0); // explicit 0 = unlimited + expect(cache.getTtl("foo")).toBe(0); + cache.ttl("foo"); // no ttl arg → fall back to stdTTL (60s) + const ttl = cache.getTtl("foo"); + expect(ttl).toBeGreaterThan(0); + }); + test("should get the internal id and stop the interval", () => { const cache = new NodeCache(); expect(cache.getIntervalId()).toBeDefined(); From e2b97408bb728827ed99a39ab89510e6416acdce Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Thu, 26 Feb 2026 11:53:39 -0800 Subject: [PATCH 3/5] adding resolveExpiration --- packages/node-cache/src/index.ts | 33 ++++++++++++++++++-------- packages/node-cache/test/index.test.ts | 7 ++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/node-cache/src/index.ts b/packages/node-cache/src/index.ts index 7b13b4dd..d16a2085 100644 --- a/packages/node-cache/src/index.ts +++ b/packages/node-cache/src/index.ts @@ -141,14 +141,14 @@ export class NodeCache extends Hookified { if (ttl !== undefined && (typeof ttl === "string" || ttl > 0)) { // Explicit positive TTL or string shorthand overrides stdTTL - expirationTimestamp = this.getExpirationTimestamp(ttl); + expirationTimestamp = this.resolveExpiration(ttl); } else if ( ttl === undefined && - this.options.stdTTL && - (typeof this.options.stdTTL === "string" || this.options.stdTTL > 0) + this.options.stdTTL !== undefined && + this.options.stdTTL !== 0 ) { - // ttl omitted, fall back to stdTTL if set - expirationTimestamp = this.getExpirationTimestamp(this.options.stdTTL); + // ttl omitted, fall back to stdTTL if set and non-zero + expirationTimestamp = this.resolveExpiration(this.options.stdTTL); } // ttl === 0 means cache indefinitely (expirationTimestamp stays 0) @@ -336,16 +336,16 @@ export class NodeCache extends Hookified { if (result) { if (ttl !== undefined && (typeof ttl === "string" || ttl > 0)) { // Explicit positive TTL or string shorthand - result.ttl = this.getExpirationTimestamp(ttl); + result.ttl = this.resolveExpiration(ttl); } else if (ttl === 0) { // Explicit 0 = unlimited result.ttl = 0; } else if ( - this.options.stdTTL && - (typeof this.options.stdTTL === "string" || this.options.stdTTL > 0) + this.options.stdTTL !== undefined && + this.options.stdTTL !== 0 ) { - // ttl omitted, fall back to stdTTL - result.ttl = this.getExpirationTimestamp(this.options.stdTTL); + // ttl omitted, fall back to stdTTL if set and non-zero + result.ttl = this.resolveExpiration(this.options.stdTTL); } else { // No ttl, no stdTTL = unlimited result.ttl = 0; @@ -501,6 +501,19 @@ export class NodeCache extends Hookified { return expirationTimestamp; } + /** + * Resolves a TTL value to an expiration timestamp, returning 0 (unlimited) if the + * resolved timestamp is not in the future (e.g. "0s" or a zero-duration string). + */ + private resolveExpiration(ttl: number | string): number { + const timestamp = this.getExpirationTimestamp(ttl); + if (timestamp <= Date.now()) { + return 0; + } + + return timestamp; + } + private checkData(): void { for (const [key, value] of this.store.entries()) { if (value.ttl > 0 && value.ttl < Date.now()) { diff --git a/packages/node-cache/test/index.test.ts b/packages/node-cache/test/index.test.ts index dc1bb356..db0070c1 100644 --- a/packages/node-cache/test/index.test.ts +++ b/packages/node-cache/test/index.test.ts @@ -269,6 +269,13 @@ describe("NodeCache", () => { expect(cache.getTtl("foo")).toBe(0); }); + test("should treat zero-duration string stdTTL as unlimited", () => { + const cache = new NodeCache({ checkperiod: 0, stdTTL: "0ms" }); + cache.set("foo", "bar"); + expect(cache.getTtl("foo")).toBe(0); + expect(cache.get("foo")).toBe("bar"); + }); + test("should use stdTTL when ttl() is called without ttl argument and stdTTL is set", () => { const cache = new NodeCache({ checkperiod: 0, stdTTL: 60 }); cache.set("foo", "bar", 0); // explicit 0 = unlimited From 6301f65db215d1f162a4f42b5d4f711eca80880f Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Thu, 26 Feb 2026 11:56:37 -0800 Subject: [PATCH 4/5] isNegativeTtl --- packages/node-cache/src/index.ts | 27 ++++++++++++++++++++++---- packages/node-cache/test/index.test.ts | 15 ++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/node-cache/src/index.ts b/packages/node-cache/src/index.ts index d16a2085..bf330cb1 100644 --- a/packages/node-cache/src/index.ts +++ b/packages/node-cache/src/index.ts @@ -131,8 +131,8 @@ export class NodeCache extends Hookified { throw this.createError(NodeCacheErrors.ETTLTYPE, this.formatKey(key)); } - // Reject negative TTL values - if (typeof ttl === "number" && ttl < 0) { + // Reject negative TTL values (numeric or numeric string) + if (this.isNegativeTtl(ttl)) { return false; } @@ -327,8 +327,8 @@ export class NodeCache extends Hookified { * @returns {boolean} true if the key has been found and changed. Otherwise returns false. */ public ttl(key: string | number, ttl?: number | string): boolean { - // Reject negative TTL values - if (typeof ttl === "number" && ttl < 0) { + // Reject negative TTL values (numeric or numeric string) + if (this.isNegativeTtl(ttl)) { return false; } @@ -514,6 +514,25 @@ export class NodeCache extends Hookified { return timestamp; } + /** + * Checks whether a TTL value is negative. Handles both numbers and + * purely numeric strings (e.g. "-1"). + */ + private isNegativeTtl(ttl?: number | string): boolean { + if (typeof ttl === "number") { + return ttl < 0; + } + + if (typeof ttl === "string") { + const num = Number(ttl); + if (!Number.isNaN(num) && num < 0) { + return true; + } + } + + return false; + } + private checkData(): void { for (const [key, value] of this.store.entries()) { if (value.ttl > 0 && value.ttl < Date.now()) { diff --git a/packages/node-cache/test/index.test.ts b/packages/node-cache/test/index.test.ts index db0070c1..8cecda97 100644 --- a/packages/node-cache/test/index.test.ts +++ b/packages/node-cache/test/index.test.ts @@ -260,6 +260,21 @@ describe("NodeCache", () => { expect(cache.get("foo")).toBe("bar"); }); + test("should reject negative TTL passed as a numeric string in set()", () => { + const cache = new NodeCache({ checkperiod: 0 }); + const result = cache.set("foo", "bar", "-1"); + expect(result).toBe(false); + expect(cache.has("foo")).toBe(false); + }); + + test("should reject negative TTL passed as a numeric string in ttl()", () => { + const cache = new NodeCache({ checkperiod: 0 }); + cache.set("foo", "bar"); + const result = cache.ttl("foo", "-5"); + expect(result).toBe(false); + expect(cache.get("foo")).toBe("bar"); + }); + test("should set unlimited expiration on ttl() method when ttl is 0", async () => { const cache = new NodeCache({ checkperiod: 0, stdTTL: 0.5 }); cache.set("foo", "bar"); // uses stdTTL (0.5s) From 59857e64ecc28700b45a267a928651a02dc9ae52 Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Thu, 26 Feb 2026 11:58:03 -0800 Subject: [PATCH 5/5] mset fixes --- packages/node-cache/src/index.ts | 7 +++++-- packages/node-cache/test/index.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/node-cache/src/index.ts b/packages/node-cache/src/index.ts index bf330cb1..77af5ed7 100644 --- a/packages/node-cache/src/index.ts +++ b/packages/node-cache/src/index.ts @@ -189,11 +189,14 @@ export class NodeCache extends Hookified { throw this.createError(NodeCacheErrors.EKEYSTYPE); } + let success = true; for (const item of data) { - this.set(item.key, item.value, item.ttl); + if (!this.set(item.key, item.value, item.ttl)) { + success = false; + } } - return true; + return success; } /** diff --git a/packages/node-cache/test/index.test.ts b/packages/node-cache/test/index.test.ts index 8cecda97..507fd148 100644 --- a/packages/node-cache/test/index.test.ts +++ b/packages/node-cache/test/index.test.ts @@ -48,6 +48,18 @@ describe("NodeCache", () => { expect(cache.get("baz")).toBe("qux"); }); + test("should return false from mset when any item has a negative ttl", () => { + const cache = new NodeCache({ checkperiod: 0 }); + const list = [ + { key: "good", value: "ok" }, + { key: "bad", value: "nope", ttl: -1 }, + ]; + const result = cache.mset(list); + expect(result).toBe(false); + expect(cache.get("good")).toBe("ok"); + expect(cache.has("bad")).toBe(false); + }); + test("should get multiple cache items", () => { const cache = new NodeCache({ checkperiod: 0 }); cache.set("foo", "bar");