@kuralle-agents/redis-store 0.14.0 → 0.16.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/dist/RedisSessionStore.d.ts +9 -0
- package/dist/RedisSessionStore.js +44 -11
- package/dist/adapters.d.ts +3 -0
- package/dist/adapters.js +10 -3
- package/dist/redisHelpers.d.ts +1 -1
- package/dist/redisHelpers.js +4 -1
- package/package.json +5 -5
|
@@ -2,6 +2,15 @@ import { type AuditListOptions, type ConversationAuditEntry, type Session, type
|
|
|
2
2
|
type RedisResult<T = unknown> = T | null;
|
|
3
3
|
type RedisCommand = (...args: any[]) => Promise<RedisResult>;
|
|
4
4
|
export type RedisClientLike = {
|
|
5
|
+
/**
|
|
6
|
+
* Atomic script execution, normalised.
|
|
7
|
+
*
|
|
8
|
+
* Every real client exposes `eval`, but with a different argument shape (ioredis takes
|
|
9
|
+
* positional `numKeys`, node-redis takes an options object, Upstash takes two arrays).
|
|
10
|
+
* The adapters in `adapters.ts` map their client's native form onto this one, so the
|
|
11
|
+
* store can express an atomic compare-and-swap without knowing which client it holds.
|
|
12
|
+
*/
|
|
13
|
+
evalScript?: (script: string, keys: string[], args: string[]) => Promise<unknown>;
|
|
5
14
|
get?: RedisCommand;
|
|
6
15
|
set?: RedisCommand;
|
|
7
16
|
del?: RedisCommand;
|
|
@@ -32,6 +32,32 @@ const reviveSession = (raw) => {
|
|
|
32
32
|
]));
|
|
33
33
|
return session;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Atomic compare-and-swap for one session, run server-side so nothing can interleave.
|
|
37
|
+
*
|
|
38
|
+
* The previous implementation did GET -> compare in JS -> SET: three round-trips, so two
|
|
39
|
+
* clients could both read version 5, both pass the check, and both write. The check read
|
|
40
|
+
* as protection and provided none. (Our test double hid this by performing the version
|
|
41
|
+
* check inside its own `set`, which a real Redis does not do.)
|
|
42
|
+
*
|
|
43
|
+
* Returns -1 on success, otherwise the version actually stored, so the caller can raise a
|
|
44
|
+
* truthful StaleWriteError. Versions only ever increase from 0, so -1 is a safe sentinel.
|
|
45
|
+
*
|
|
46
|
+
* The stored version is read out of the session payload rather than a companion key, so
|
|
47
|
+
* this works against data written by earlier versions with no migration.
|
|
48
|
+
*/
|
|
49
|
+
const CAS_SCRIPT = `
|
|
50
|
+
local cur = redis.call('GET', KEYS[1])
|
|
51
|
+
local stored = 0
|
|
52
|
+
if cur then
|
|
53
|
+
local ok, obj = pcall(cjson.decode, cur)
|
|
54
|
+
if ok and obj and obj.version then stored = tonumber(obj.version) or 0 end
|
|
55
|
+
end
|
|
56
|
+
if stored ~= tonumber(ARGV[2]) then return stored end
|
|
57
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
58
|
+
if tonumber(ARGV[3]) > 0 then redis.call('EXPIRE', KEYS[1], ARGV[3]) end
|
|
59
|
+
return -1
|
|
60
|
+
`;
|
|
35
61
|
export class RedisSessionStore {
|
|
36
62
|
client;
|
|
37
63
|
prefix;
|
|
@@ -75,25 +101,32 @@ export class RedisSessionStore {
|
|
|
75
101
|
}
|
|
76
102
|
}
|
|
77
103
|
async save(session) {
|
|
104
|
+
const evalScript = this.client.evalScript;
|
|
105
|
+
if (typeof evalScript !== 'function') {
|
|
106
|
+
throw new Error('RedisSessionStore requires a client that can run Lua scripts. Wrap yours with ' +
|
|
107
|
+
'fromIORedis / fromNodeRedis / fromUpstash from @kuralle-agents/redis-store, or ' +
|
|
108
|
+
'supply `evalScript`. Without atomic compare-and-swap a concurrent write is ' +
|
|
109
|
+
'silently lost, which corrupts the durable journal.');
|
|
110
|
+
}
|
|
111
|
+
// Read only to reconcile the conversation index below — never to gate the write.
|
|
112
|
+
// The version check that actually guards the write happens inside the Lua script.
|
|
78
113
|
const previous = await this.get(session.id);
|
|
79
114
|
const expectedVersion = session.version ?? 0;
|
|
80
|
-
if (previous !== null) {
|
|
81
|
-
const storedVersion = previous.version ?? 0;
|
|
82
|
-
if (storedVersion !== expectedVersion) {
|
|
83
|
-
throw new StaleWriteError(session.id, expectedVersion, storedVersion);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
else if (expectedVersion !== 0) {
|
|
87
|
-
throw new StaleWriteError(session.id, expectedVersion, 0);
|
|
88
|
-
}
|
|
89
115
|
session.updatedAt = new Date();
|
|
90
116
|
session.conversationId = session.conversationId ?? session.id;
|
|
91
117
|
session.channelId = session.channelId ?? 'web';
|
|
92
118
|
session.version = expectedVersion + 1;
|
|
93
119
|
const key = this.sessionKey(session.id);
|
|
94
120
|
const payload = JSON.stringify(session);
|
|
95
|
-
await
|
|
96
|
-
|
|
121
|
+
const outcome = Number(await evalScript.call(this.client, CAS_SCRIPT, [key], [
|
|
122
|
+
payload,
|
|
123
|
+
String(expectedVersion),
|
|
124
|
+
String(this.sessionTtlSeconds ?? 0),
|
|
125
|
+
]));
|
|
126
|
+
if (outcome !== -1) {
|
|
127
|
+
session.version = expectedVersion; // leave the caller's object as it found it
|
|
128
|
+
throw new StaleWriteError(session.id, expectedVersion, outcome);
|
|
129
|
+
}
|
|
97
130
|
await addMembers(this.client, this.sessionIndexKey(), session.id);
|
|
98
131
|
if (session.userId) {
|
|
99
132
|
await addMembers(this.client, this.userIndexKey(session.userId), session.id);
|
package/dist/adapters.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { RedisSessionStore } from './RedisSessionStore.js';
|
|
2
2
|
import type { RedisClientLike, RedisStoreOptions } from './RedisSessionStore.js';
|
|
3
3
|
export type RedisAdapterOptions = Omit<RedisStoreOptions, 'client'>;
|
|
4
|
+
/** Upstash REST: `eval(script, keys, args)`. */
|
|
4
5
|
export declare const fromUpstash: (client: RedisClientLike, options?: RedisAdapterOptions) => RedisSessionStore;
|
|
6
|
+
/** node-redis v4+: `eval(script, { keys, arguments })`. */
|
|
5
7
|
export declare const fromNodeRedis: (client: RedisClientLike, options?: RedisAdapterOptions) => RedisSessionStore;
|
|
8
|
+
/** ioredis: `eval(script, numKeys, ...keys, ...args)`. */
|
|
6
9
|
export declare const fromIORedis: (client: RedisClientLike, options?: RedisAdapterOptions) => RedisSessionStore;
|
package/dist/adapters.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { RedisSessionStore } from './RedisSessionStore.js';
|
|
2
|
+
const withEvalScript = (client, shape) => ({
|
|
3
|
+
...client,
|
|
4
|
+
evalScript: (script, keys, args) => shape(client, script, keys, args),
|
|
5
|
+
});
|
|
2
6
|
const createStore = (client, options) => new RedisSessionStore({ client, ...(options ?? {}) });
|
|
3
|
-
|
|
4
|
-
export const
|
|
5
|
-
|
|
7
|
+
/** Upstash REST: `eval(script, keys, args)`. */
|
|
8
|
+
export const fromUpstash = (client, options) => createStore(withEvalScript(client, (c, script, keys, args) => c.eval(script, keys, args)), options);
|
|
9
|
+
/** node-redis v4+: `eval(script, { keys, arguments })`. */
|
|
10
|
+
export const fromNodeRedis = (client, options) => createStore(withEvalScript(client, (c, script, keys, args) => c.eval(script, { keys, arguments: args })), options);
|
|
11
|
+
/** ioredis: `eval(script, numKeys, ...keys, ...args)`. */
|
|
12
|
+
export const fromIORedis = (client, options) => createStore(withEvalScript(client, (c, script, keys, args) => c.eval(script, keys.length, ...keys, ...args)), options);
|
package/dist/redisHelpers.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RedisClientLike } from './RedisSessionStore.js';
|
|
2
|
-
export declare const callCommand: <T>(client: RedisClientLike, names: Array<keyof RedisClientLike
|
|
2
|
+
export declare const callCommand: <T>(client: RedisClientLike, names: Array<Exclude<keyof RedisClientLike, "evalScript">>, ...args: unknown[]) => Promise<T>;
|
|
3
3
|
export declare const getMembers: (client: RedisClientLike, key: string) => Promise<string[]>;
|
|
4
4
|
export declare const addMembers: (client: RedisClientLike, key: string, ...members: string[]) => Promise<void>;
|
|
5
5
|
export declare const removeMembers: (client: RedisClientLike, key: string, ...members: string[]) => Promise<void>;
|
package/dist/redisHelpers.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export const callCommand = async (client,
|
|
1
|
+
export const callCommand = async (client,
|
|
2
|
+
// `evalScript` is our normalised script hook, not a Redis command — it has its own
|
|
3
|
+
// signature and is called directly, never through this generic dispatcher.
|
|
4
|
+
names, ...args) => {
|
|
2
5
|
for (const name of names) {
|
|
3
6
|
const fn = client[name];
|
|
4
7
|
if (typeof fn === 'function') {
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"url": "git+https://github.com/kuralle/kuralle-agents.git",
|
|
7
7
|
"directory": "packages/redis-store"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.16.0",
|
|
10
10
|
"description": "Redis-backed SessionStore for Kuralle (supports Upstash and common Redis clients)",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"main": "dist/index.js",
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@kuralle-agents/core": "0.
|
|
21
|
+
"@kuralle-agents/core": "0.16.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@kuralle-agents/core": "0.
|
|
25
|
-
"@kuralle-agents/rag": "0.
|
|
24
|
+
"@kuralle-agents/core": "0.x",
|
|
25
|
+
"@kuralle-agents/rag": "0.x"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@ai-sdk/openai": "^3.0.0",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"tsx": "^4.7.0",
|
|
34
34
|
"typescript": "^5.3.0",
|
|
35
35
|
"zod": "^4.0.0",
|
|
36
|
-
"@kuralle-agents/rag": "0.
|
|
36
|
+
"@kuralle-agents/rag": "0.16.0"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|