feat: Add Redis-backed conversation store and distributed rate limiter - #8
Conversation
|
The InMemoryRedis.eval only knows how to run the one script we ship. That's deliberate: without a real Lua interpreter I can't run arbitrary scripts, but faithfully reproducing INCR + PEXPIRE-on-first-hit + PTTL means the limiter's actual math (remaining, retryAfterMs, window rollover) gets exercised for real in CI, and the Lua path is what runs in prod. If someone passes a different script it throws loudly rather than silently doing the wrong thing. |
|
Went with meta-in-a-string + messages-in-a-list instead of one JSON blob specifically to make appends atomic. A serialized blob forces read-modify-write, which loses updates under concurrent writers. The one wart is the existence check in append is a separate GET before the RPUSH, so there's a tiny TOCTOU if a conversation expires in between. Acceptable here since the ttl is measured in minutes/hours, but worth knowing. |
This adds packages/store-redis, the persistence layer for conversations plus a rate limiter that actually works across more than one process. The whole thing sits behind a small RedisPort interface (just the handful of commands we use) so the store and limiter never touch a concrete client directly. That seam is what lets InMemoryRedis stand in as both the no-config fallback and the test double, so nothing here needs a running Redis to pass CI.
Conversation history goes into a Redis list rather than a serialized blob, so appending is a single atomic RPUSH and two nodes writing at once can't clobber each other. Reads run every message back through Zod since data coming out of an external store isn't something I want to trust blindly, and there's an optional TTL that slides forward on each append so idle sessions age out on their own. The rate limiter is a fixed-window counter where the increment and the expiry happen in one Lua script, which closes the gap where a crash right after INCR could leave a counter that never resets. It returns the same result shape as the in-process limiters in rate-limiter so callers can swap between them.
Fixed-window has the usual boundary overshoot, so I left a note pointing at SlidingWindowLog for when exactness matters more than memory. Full unit coverage on both, including ttl expiry, window rollover, and key isolation.