@chatcops/store-redis 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CoderCops
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
1
+ # @chatcops/store-redis
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Redis storage adapter for ChatCops supporting both `@upstash/redis` (HTTP / serverless) and `ioredis` (TCP / persistent connections).
4
+
5
+ Implements all four ChatCops storage interfaces:
6
+ - **conversations**: metadata in Redis hashes, messages as JSON lists, conversation IDs in a sorted set ordered by `updated_at`, with atomic version-checked appends, updates, removals, listing, and purging.
7
+ - **counters**: atomic increments with fixed TTL reset for rate limits and budgets.
8
+ - **kv**: JSON key-value store with `setIfAbsent` (`NX`) for distributed locking.
9
+ - **events**: analytics event collection and querying.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @chatcops/store-redis
15
+ ```
16
+
17
+ For Upstash / serverless:
18
+ ```bash
19
+ npm install @upstash/redis
20
+ ```
21
+
22
+ For standalone Redis (Node.js runtime):
23
+ ```bash
24
+ npm install ioredis
25
+ ```
26
+
27
+ ## Setup on Vercel
28
+
29
+ ### 1. Using `@upstash/redis` (Recommended for Edge / Serverless)
30
+
31
+ The `@upstash/redis` client communicates over HTTP REST, making it ideal for Vercel Serverless and Edge Functions without persistent TCP connection limits:
32
+
33
+ ```typescript
34
+ import { Redis } from '@upstash/redis';
35
+ import { redisStorage } from '@chatcops/store-redis';
36
+ import { chatcopsVercelHandler } from '@chatcops/server';
37
+
38
+ const redis = new Redis({
39
+ url: process.env.UPSTASH_REDIS_REST_URL!,
40
+ token: process.env.UPSTASH_REDIS_REST_TOKEN!,
41
+ });
42
+
43
+ const storage = redisStorage({
44
+ redis,
45
+ prefix: 'chatcops', // default: 'chatcops'
46
+ conversationTtlMs: 30 * 24 * 60 * 60 * 1000, // default: 30 days
47
+ });
48
+
49
+ export const POST = chatcopsVercelHandler({
50
+ storage,
51
+ provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
52
+ systemPrompt: 'You are a helpful assistant.',
53
+ });
54
+ ```
55
+
56
+ ### 2. Using `ioredis` via `fromIoRedis` (Node.js Serverless Functions)
57
+
58
+ When connecting directly to a standard Redis instance (or Redis cluster / Valkey) in Vercel Node.js Serverless Functions:
59
+
60
+ ```typescript
61
+ import RedisIo from 'ioredis';
62
+ import { fromIoRedis, redisStorage } from '@chatcops/store-redis';
63
+ import { chatcopsVercelHandler } from '@chatcops/server';
64
+
65
+ // Reuse connection across warm lambda invocations
66
+ const io = new RedisIo(process.env.REDIS_URL!, {
67
+ maxRetriesPerRequest: 3,
68
+ });
69
+
70
+ const storage = redisStorage({
71
+ redis: fromIoRedis(io),
72
+ prefix: 'chatcops',
73
+ });
74
+
75
+ export const POST = chatcopsVercelHandler({
76
+ storage,
77
+ provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
78
+ systemPrompt: 'You are a helpful assistant.',
79
+ });
80
+ ```
81
+
82
+ ## Options
83
+
84
+ `redisStorage({ redis, prefix, conversationTtlMs })`
85
+
86
+ - `redis`: Either a `@upstash/redis` client instance or an adapter created with `fromIoRedis(ioClient)`.
87
+ - `prefix` (optional, default `'chatcops'`): Key prefix applied to all Redis keys for multi-tenant and environment isolation.
88
+ - `conversationTtlMs` (optional, default 30 days): TTL applied to conversation keys (`PEXPIRE`).
89
+
90
+ ## Events Architecture: Hourly Count Hashes
91
+
92
+ Analytics events are stored using **hourly count hashes** rather than capped streams (`XADD`).
93
+
94
+ - **Why hourly count hashes over streams**: Redis Streams enforce strictly increasing entry IDs, which causes failures when events arrive out-of-order or with client timestamps in the past. Additionally, querying event counts aggregated by hour or day from a stream requires scanning and parsing historical stream logs client-side.
95
+ - **Hourly count hashes**: Events are bucketed by UTC hour (`{<prefix>}:events:<hour>`) and tracked in a sorted set index (`{<prefix>}:events:hours`). Each hash stores counts keyed by event type (`HINCRBY`). Queries efficiently read only the active hour buckets within the query window and aggregate into hourly or daily totals directly.
96
+ - **Hour-rounding query semantics**: Because events are pre-aggregated into 1-hour UTC buckets, queries round `from` down to the start of its UTC hour (`Math.floor(from / HOUR_MS) * HOUR_MS`) and `to` up to the end of its UTC hour (`Math.ceil(to / HOUR_MS) * HOUR_MS`), ensuring partial-hour queries accurately select all overlapping hourly buckets.
97
+
98
+ ## Redis Cluster Compatibility
99
+
100
+ All keys generated for a given prefix share the `{<prefix>}` hash tag (e.g. `{chatcops}:conversations`, `{chatcops}:conv:<id>:meta`, `{chatcops}:events:hours`), ensuring all keys for that prefix reside in the same Redis Cluster hash slot while preserving isolation between prefixes. Every script-accessed key is explicitly passed through the `KEYS` array.
@@ -0,0 +1,30 @@
1
+ import type { IoRedisLike, RedisLike } from './types.js';
2
+ /**
3
+ * Safely decodes a result from an eval script or parses a JSON string.
4
+ * If the client (like @upstash/redis) already automatically deserialized the result
5
+ * from JSON, returns it directly. If it is a JSON string (like raw responses from ioredis),
6
+ * parses it into the target type.
7
+ */
8
+ export declare function decodeEvalResult<T>(value: unknown): T;
9
+ /**
10
+ * Consolidated internal alias for JSON string parsing.
11
+ */
12
+ export declare const parseJson: typeof decodeEvalResult;
13
+ /**
14
+ * Normalizes an unknown value into an array, falling back to an empty array
15
+ * if the value is nullish or was encoded as an empty table/object by Lua cjson.
16
+ */
17
+ export declare function normalizeList<T>(value: unknown): T[];
18
+ /**
19
+ * Formats a prefix into a stable Redis Cluster hash slot tag.
20
+ * Always wraps the prefix in braces so different prefixes like 'x' and '{x}'
21
+ * remain isolated while co-locating keys within each prefix into a single hash slot.
22
+ */
23
+ export declare function formatSlotPrefix(prefix: string): string;
24
+ /**
25
+ * Adapts an ioredis client instance into the minimal RedisLike interface compatible
26
+ * with @upstash/redis, correctly handling ioredis's eval(script, numKeys, ...keysAndArgs)
27
+ * signature and JSON value parsing.
28
+ */
29
+ export declare function fromIoRedis(redis: IoRedisLike): RedisLike;
30
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAmB,MAAM,YAAY,CAAC;AAE1E;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,GAAG,CAAC,CAUrD;AAED;;GAEG;AACH,eAAO,MAAM,SAAS,yBAAmB,CAAC;AAE1C;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,GAAG,CAAC,EAAE,CAGpD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,SAAS,CAuDzD"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Safely decodes a result from an eval script or parses a JSON string.
3
+ * If the client (like @upstash/redis) already automatically deserialized the result
4
+ * from JSON, returns it directly. If it is a JSON string (like raw responses from ioredis),
5
+ * parses it into the target type.
6
+ */
7
+ export function decodeEvalResult(value) {
8
+ if (value === null || value === undefined)
9
+ return null;
10
+ if (typeof value === 'string') {
11
+ try {
12
+ return JSON.parse(value);
13
+ }
14
+ catch {
15
+ return value;
16
+ }
17
+ }
18
+ return value;
19
+ }
20
+ /**
21
+ * Consolidated internal alias for JSON string parsing.
22
+ */
23
+ export const parseJson = decodeEvalResult;
24
+ /**
25
+ * Normalizes an unknown value into an array, falling back to an empty array
26
+ * if the value is nullish or was encoded as an empty table/object by Lua cjson.
27
+ */
28
+ export function normalizeList(value) {
29
+ if (Array.isArray(value))
30
+ return value;
31
+ return [];
32
+ }
33
+ /**
34
+ * Formats a prefix into a stable Redis Cluster hash slot tag.
35
+ * Always wraps the prefix in braces so different prefixes like 'x' and '{x}'
36
+ * remain isolated while co-locating keys within each prefix into a single hash slot.
37
+ */
38
+ export function formatSlotPrefix(prefix) {
39
+ return `{${prefix}}`;
40
+ }
41
+ /**
42
+ * Adapts an ioredis client instance into the minimal RedisLike interface compatible
43
+ * with @upstash/redis, correctly handling ioredis's eval(script, numKeys, ...keysAndArgs)
44
+ * signature and JSON value parsing.
45
+ */
46
+ export function fromIoRedis(redis) {
47
+ if (!redis) {
48
+ throw new TypeError('fromIoRedis requires a redis client instance');
49
+ }
50
+ return {
51
+ eval: async (script, keys = [], args = []) => {
52
+ const numKeys = keys.length;
53
+ const evalArgs = (args ?? []).map((a) => typeof a === 'number' ? a : String(a));
54
+ const res = await redis.eval(script, numKeys, ...keys, ...evalArgs);
55
+ return res;
56
+ },
57
+ get: async (key) => {
58
+ const raw = await redis.get(key);
59
+ if (raw === null || raw === undefined)
60
+ return null;
61
+ return parseJson(raw);
62
+ },
63
+ set: async (key, value, opts) => {
64
+ const valStr = typeof value === 'string' ? value : JSON.stringify(value);
65
+ const args = [];
66
+ if (opts?.ex !== undefined) {
67
+ args.push('EX', opts.ex);
68
+ }
69
+ else if (opts?.px !== undefined) {
70
+ args.push('PX', opts.px);
71
+ }
72
+ if (opts?.nx) {
73
+ args.push('NX');
74
+ }
75
+ else if (opts?.xx) {
76
+ args.push('XX');
77
+ }
78
+ if (opts?.keepttl) {
79
+ args.push('KEEPTTL');
80
+ }
81
+ return redis.set(key, valStr, ...args);
82
+ },
83
+ del: (...keys) => {
84
+ if (keys.length === 0)
85
+ return Promise.resolve(0);
86
+ return redis.del(...keys);
87
+ },
88
+ quit: () => (typeof redis.quit === 'function' ? redis.quit() : Promise.resolve()),
89
+ close: () => (typeof redis.quit === 'function' ? redis.quit() : Promise.resolve()),
90
+ disconnect: () => {
91
+ if (typeof redis.disconnect === 'function')
92
+ redis.disconnect();
93
+ },
94
+ };
95
+ }
96
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import { type AppendOptions, type ChatMessage, type Conversation, type ConversationStore, type ConversationSummary, type ListQuery } from '@chatcops/core';
2
+ import type { RedisLike } from './types.js';
3
+ export declare class RedisConversationStore implements ConversationStore {
4
+ private readonly redis;
5
+ private readonly zsetKey;
6
+ private readonly slotPrefix;
7
+ private readonly ttlMs;
8
+ constructor(redis: RedisLike, prefix: string, ttlMs: number);
9
+ private metaKey;
10
+ private messagesKey;
11
+ private serializeMetadata;
12
+ get(id: string): Promise<Conversation | null>;
13
+ save(conversation: Conversation): Promise<void>;
14
+ append(id: string, messages: ChatMessage[], options?: AppendOptions): Promise<{
15
+ version: number;
16
+ }>;
17
+ updateMessage(id: string, message: ChatMessage): Promise<boolean>;
18
+ removeMessage(id: string, messageId: string): Promise<boolean>;
19
+ delete(id: string): Promise<void>;
20
+ list(query: ListQuery): Promise<{
21
+ items: ConversationSummary[];
22
+ cursor?: string;
23
+ }>;
24
+ purge(before: number): Promise<number>;
25
+ }
26
+ //# sourceMappingURL=conversations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversations.d.ts","sourceRoot":"","sources":["../src/conversations.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,WAAW,EAEhB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AA6W5C,qBAAa,sBAAuB,YAAW,iBAAiB;IAM5D,OAAO,CAAC,QAAQ,CAAC,KAAK;IALxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAGZ,KAAK,EAAE,SAAS,EACjC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM;IAOf,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,iBAAiB;IAWnB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAwD7C,IAAI,CAAC,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAe/C,MAAM,CACV,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IA2BzB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IAajE,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAY9D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQjC,IAAI,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IA+GlF,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAsC7C"}