From ad1c1e9762c4ccc2fc5c00b38d517aa53e51db7d Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Wed, 25 Mar 2026 23:23:52 -0700 Subject: [PATCH 1/4] Fix Redis state typings for url and client options --- .changeset/warm-pandas-double.md | 5 ++ packages/state-redis/src/index.test.ts | 26 ++++++++++ packages/state-redis/src/index.ts | 66 +++++++++++++++++++++----- 3 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 .changeset/warm-pandas-double.md diff --git a/.changeset/warm-pandas-double.md b/.changeset/warm-pandas-double.md new file mode 100644 index 00000000..ba6fdc9f --- /dev/null +++ b/.changeset/warm-pandas-double.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/state-redis": patch +--- + +Fix `createRedisState` typings so `url` appears in IntelliSense and the factory accepts an existing Redis client. diff --git a/packages/state-redis/src/index.test.ts b/packages/state-redis/src/index.test.ts index 14df0551..d98c6ceb 100644 --- a/packages/state-redis/src/index.test.ts +++ b/packages/state-redis/src/index.test.ts @@ -1,4 +1,5 @@ import type { Logger } from "chat"; +import type { RedisClientType } from "redis"; import { describe, expect, it, vi } from "vitest"; import { createRedisState, RedisStateAdapter } from "./index"; @@ -22,6 +23,31 @@ describe("RedisStateAdapter", () => { expect(adapter).toBeInstanceOf(RedisStateAdapter); }); + it("should create an adapter with url and default logger", () => { + const adapter = createRedisState({ + url: "redis://localhost:6379", + }); + + expect(adapter).toBeInstanceOf(RedisStateAdapter); + }); + + it("should accept an existing redis client", () => { + const client = { + close: vi.fn(), + connect: vi.fn(), + isOpen: true, + on: vi.fn(), + } as unknown as RedisClientType; + + const adapter = createRedisState({ + client, + logger: mockLogger, + }); + + expect(adapter).toBeInstanceOf(RedisStateAdapter); + expect(adapter.getClient()).toBe(client); + }); + it("should have appendToList method", () => { const adapter = createRedisState({ url: "redis://localhost:6379", diff --git a/packages/state-redis/src/index.ts b/packages/state-redis/src/index.ts index 92e7dfdf..4f2c7529 100644 --- a/packages/state-redis/src/index.ts +++ b/packages/state-redis/src/index.ts @@ -6,11 +6,31 @@ export interface RedisStateAdapterOptions { /** Key prefix for all Redis keys (default: "chat-sdk") */ keyPrefix?: string; /** Logger instance for error reporting */ - logger: Logger; + logger?: Logger; /** Redis connection URL (e.g., redis://localhost:6379) */ url: string; } +export interface RedisStateClientOptions { + /** Existing redis client instance */ + client: RedisClientType; + /** Key prefix for all Redis keys (default: "chat-sdk") */ + keyPrefix?: string; + /** Logger instance for error reporting */ + logger?: Logger; +} + +export interface CreateRedisStateOptions { + /** Existing redis client instance */ + client?: RedisClientType; + /** Key prefix for all Redis keys (default: "chat-sdk") */ + keyPrefix?: string; + /** Logger instance for error reporting */ + logger?: Logger; + /** Redis connection URL (e.g., redis://localhost:6379) */ + url?: string; +} + /** * Redis state adapter for production use. * @@ -21,13 +41,20 @@ export class RedisStateAdapter implements StateAdapter { private readonly client: RedisClientType; private readonly keyPrefix: string; private readonly logger: Logger; + private readonly ownsClient: boolean; private connected = false; private connectPromise: Promise | null = null; - constructor(options: RedisStateAdapterOptions) { - this.client = createClient({ url: options.url }); + constructor(options: RedisStateAdapterOptions | RedisStateClientOptions) { + if ("client" in options) { + this.client = options.client; + this.ownsClient = false; + } else { + this.client = createClient({ url: options.url }); + this.ownsClient = true; + } this.keyPrefix = options.keyPrefix || "chat-sdk"; - this.logger = options.logger; + this.logger = options.logger ?? new ConsoleLogger("info").child("redis"); // Handle connection errors this.client.on("error", (err) => { @@ -50,8 +77,15 @@ export class RedisStateAdapter implements StateAdapter { // Reuse existing connection attempt to avoid race conditions if (!this.connectPromise) { - this.connectPromise = this.client.connect().then(() => { + this.connectPromise = (async () => { + if (!this.client.isOpen) { + await this.client.connect(); + } + this.connected = true; + })().catch((error) => { + this.connectPromise = null; + throw error; }); } @@ -60,7 +94,9 @@ export class RedisStateAdapter implements StateAdapter { async disconnect(): Promise { if (this.connected) { - await this.client.close(); + if (this.ownsClient) { + await this.client.close(); + } this.connected = false; this.connectPromise = null; } @@ -321,18 +357,26 @@ function generateToken(): string { } export function createRedisState( - options?: Partial + options: CreateRedisStateOptions = {} ): RedisStateAdapter { - const url = options?.url ?? process.env.REDIS_URL; + if (options.client) { + return new RedisStateAdapter({ + client: options.client, + keyPrefix: options.keyPrefix, + logger: options.logger, + }); + } + + const url = options.url ?? process.env.REDIS_URL; if (!url) { throw new Error( - "Redis url is required. Set REDIS_URL or provide it in options." + "Redis url is required. Set REDIS_URL or provide url in options." ); } const resolved: RedisStateAdapterOptions = { url, - keyPrefix: options?.keyPrefix, - logger: options?.logger ?? new ConsoleLogger("info").child("redis"), + keyPrefix: options.keyPrefix, + logger: options.logger, }; return new RedisStateAdapter(resolved); } From d8d44af6b4fe0bf0a6ae817b3c137e5bb6b767d3 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Thu, 26 Mar 2026 21:36:03 -0700 Subject: [PATCH 2/4] Wait for injected Redis clients to become ready --- packages/state-redis/src/index.test.ts | 34 ++++++++++++++++++++++++++ packages/state-redis/src/index.ts | 34 +++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/state-redis/src/index.test.ts b/packages/state-redis/src/index.test.ts index d98c6ceb..7167320e 100644 --- a/packages/state-redis/src/index.test.ts +++ b/packages/state-redis/src/index.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import type { Logger } from "chat"; import type { RedisClientType } from "redis"; import { describe, expect, it, vi } from "vitest"; @@ -36,6 +37,7 @@ describe("RedisStateAdapter", () => { close: vi.fn(), connect: vi.fn(), isOpen: true, + isReady: true, on: vi.fn(), } as unknown as RedisClientType; @@ -48,6 +50,38 @@ describe("RedisStateAdapter", () => { expect(adapter.getClient()).toBe(client); }); + it("should wait for an injected open client to become ready", async () => { + const emitter = new EventEmitter(); + const client = Object.assign(emitter, { + close: vi.fn(), + connect: vi.fn(), + isOpen: true, + isReady: false, + }) as unknown as RedisClientType & { + isReady: boolean; + }; + + const adapter = createRedisState({ + client, + logger: mockLogger, + }); + + let resolved = false; + const connectPromise = adapter.connect().then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + expect(client.connect).not.toHaveBeenCalled(); + + client.isReady = true; + emitter.emit("ready"); + + await connectPromise; + expect(resolved).toBe(true); + }); + it("should have appendToList method", () => { const adapter = createRedisState({ url: "redis://localhost:6379", diff --git a/packages/state-redis/src/index.ts b/packages/state-redis/src/index.ts index 4f2c7529..f4069acc 100644 --- a/packages/state-redis/src/index.ts +++ b/packages/state-redis/src/index.ts @@ -70,6 +70,37 @@ export class RedisStateAdapter implements StateAdapter { return `${this.keyPrefix}:subscriptions`; } + private async waitForReady(): Promise { + if (this.client.isReady) { + return; + } + + await new Promise((resolve, reject) => { + const handleReady = () => { + cleanup(); + resolve(); + }; + + const handleError = (error: unknown) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + this.client.off("ready", handleReady); + this.client.off("error", handleError); + }; + + this.client.on("ready", handleReady); + this.client.on("error", handleError); + + if (this.client.isReady) { + cleanup(); + resolve(); + } + }); + } + async connect(): Promise { if (this.connected) { return; @@ -78,10 +109,11 @@ export class RedisStateAdapter implements StateAdapter { // Reuse existing connection attempt to avoid race conditions if (!this.connectPromise) { this.connectPromise = (async () => { - if (!this.client.isOpen) { + if (!this.client.isReady && !this.client.isOpen) { await this.client.connect(); } + await this.waitForReady(); this.connected = true; })().catch((error) => { this.connectPromise = null; From 185fe87b975c255869580dc0143f50c626175217 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Thu, 26 Mar 2026 21:40:21 -0700 Subject: [PATCH 3/4] Simplify Redis readiness guard --- packages/state-redis/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/state-redis/src/index.ts b/packages/state-redis/src/index.ts index f4069acc..677971db 100644 --- a/packages/state-redis/src/index.ts +++ b/packages/state-redis/src/index.ts @@ -109,7 +109,7 @@ export class RedisStateAdapter implements StateAdapter { // Reuse existing connection attempt to avoid race conditions if (!this.connectPromise) { this.connectPromise = (async () => { - if (!this.client.isReady && !this.client.isOpen) { + if (!(this.client.isReady || this.client.isOpen)) { await this.client.connect(); } From 0b8efd61ae54f3124053f334da6c666b64de2058 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Thu, 26 Mar 2026 21:50:43 -0700 Subject: [PATCH 4/4] Stabilize memory state TTL test with fake timers --- packages/state-memory/src/index.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/state-memory/src/index.test.ts b/packages/state-memory/src/index.test.ts index f98bbd4b..2e3b31b2 100644 --- a/packages/state-memory/src/index.test.ts +++ b/packages/state-memory/src/index.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createMemoryState, type MemoryStateAdapter } from "./index"; describe("MemoryStateAdapter", () => { @@ -186,13 +186,20 @@ describe("MemoryStateAdapter", () => { }); it("should refresh TTL on subsequent appends", async () => { - await adapter.appendToList("list1", { id: 1 }, { ttlMs: 50 }); - await new Promise((resolve) => setTimeout(resolve, 30)); - // Append again — refreshes TTL - await adapter.appendToList("list1", { id: 2 }, { ttlMs: 50 }); + vi.useFakeTimers(); - const result = await adapter.getList("list1"); - expect(result).toEqual([{ id: 1 }, { id: 2 }]); + try { + await adapter.appendToList("list1", { id: 1 }, { ttlMs: 50 }); + await vi.advanceTimersByTimeAsync(30); + + // Append again — refreshes TTL + await adapter.appendToList("list1", { id: 2 }, { ttlMs: 50 }); + + const result = await adapter.getList("list1"); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + } finally { + vi.useRealTimers(); + } }); it("should keep lists isolated by key", async () => {