From 0768137f945d0b37a6fb159773705557eace80ec Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:26:16 +0300 Subject: [PATCH 1/2] fix(state-pg): allow setIfNotExists after ttl expiry --- packages/state-pg/src/index.test.ts | 24 ++++++++++++++++++++++++ packages/state-pg/src/index.ts | 7 ++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/state-pg/src/index.test.ts b/packages/state-pg/src/index.test.ts index 7809e249a..cf546f5b0 100644 --- a/packages/state-pg/src/index.test.ts +++ b/packages/state-pg/src/index.test.ts @@ -439,6 +439,30 @@ describe("PostgresStateAdapter", () => { expect(result).toBe(true); }); + it("should allow setIfNotExists to replace expired keys", async () => { + queryRows = [{ cache_key: "key" }]; + const result = await adapter.setIfNotExists("key", "value", 5000); + + expect(result).toBe(true); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining( + "ON CONFLICT (key_prefix, cache_key) DO UPDATE" + ), + ["chat-sdk", "key", '"value"', expect.any(Date)] + ); + + const calls = (pool.query as ReturnType).mock.calls; + const setIfNotExistsCall = calls.find( + (c: unknown[]) => + typeof c[0] === "string" && + c[0].includes("INSERT INTO chat_state_cache") && + c[0].includes("WHERE chat_state_cache.expires_at IS NOT NULL") && + c[0].includes("chat_state_cache.expires_at <= now()") + ); + + expect(setIfNotExistsCall).toBeTruthy(); + }); + it("should delete a value without throwing", async () => { await adapter.delete("key"); }); diff --git a/packages/state-pg/src/index.ts b/packages/state-pg/src/index.ts index 9efdc749b..7aed76c56 100644 --- a/packages/state-pg/src/index.ts +++ b/packages/state-pg/src/index.ts @@ -260,7 +260,12 @@ export class PostgresStateAdapter implements StateAdapter { const result = await this.pool.query( `INSERT INTO chat_state_cache (key_prefix, cache_key, value, expires_at) VALUES ($1, $2, $3, $4) - ON CONFLICT (key_prefix, cache_key) DO NOTHING + ON CONFLICT (key_prefix, cache_key) DO UPDATE + SET value = EXCLUDED.value, + expires_at = EXCLUDED.expires_at, + updated_at = now() + WHERE chat_state_cache.expires_at IS NOT NULL + AND chat_state_cache.expires_at <= now() RETURNING cache_key`, [this.keyPrefix, key, serialized, expiresAt] ); From 0870b6dd3af37f07db82215dc9efb15df1b6a273 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Wed, 15 Jul 2026 13:04:34 +1000 Subject: [PATCH 2/2] fix(state-pg): add changeset and tighten setIfNotExists expiry test --- .changeset/state-pg-set-if-not-exists-ttl.md | 5 +++++ packages/state-pg/src/index.test.ts | 14 ++------------ 2 files changed, 7 insertions(+), 12 deletions(-) create mode 100644 .changeset/state-pg-set-if-not-exists-ttl.md diff --git a/.changeset/state-pg-set-if-not-exists-ttl.md b/.changeset/state-pg-set-if-not-exists-ttl.md new file mode 100644 index 000000000..8f8c7721b --- /dev/null +++ b/.changeset/state-pg-set-if-not-exists-ttl.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/state-pg": patch +--- + +Fix `setIfNotExists()` so it can claim a cache key whose existing row has expired. Previously the query used `ON CONFLICT DO NOTHING`, so an expired row in `chat_state_cache` still blocked acquisition until opportunistic cleanup deleted it — diverging from the memory and Redis adapters, which treat expired entries as absent. Keys stored without a TTL remain permanent and are never overwritten. diff --git a/packages/state-pg/src/index.test.ts b/packages/state-pg/src/index.test.ts index cf546f5b0..69908ecf9 100644 --- a/packages/state-pg/src/index.test.ts +++ b/packages/state-pg/src/index.test.ts @@ -446,21 +446,11 @@ describe("PostgresStateAdapter", () => { expect(result).toBe(true); expect(pool.query).toHaveBeenCalledWith( expect.stringContaining( - "ON CONFLICT (key_prefix, cache_key) DO UPDATE" + `WHERE chat_state_cache.expires_at IS NOT NULL + AND chat_state_cache.expires_at <= now()` ), ["chat-sdk", "key", '"value"', expect.any(Date)] ); - - const calls = (pool.query as ReturnType).mock.calls; - const setIfNotExistsCall = calls.find( - (c: unknown[]) => - typeof c[0] === "string" && - c[0].includes("INSERT INTO chat_state_cache") && - c[0].includes("WHERE chat_state_cache.expires_at IS NOT NULL") && - c[0].includes("chat_state_cache.expires_at <= now()") - ); - - expect(setIfNotExistsCall).toBeTruthy(); }); it("should delete a value without throwing", async () => {