@kuralle-agents/redis-store 0.14.0 → 0.15.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.
@@ -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 callCommand(this.client, ['set'], key, payload);
96
- await setExpiration(this.client, key, this.sessionTtlSeconds);
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);
@@ -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
- export const fromUpstash = (client, options) => createStore(client, options);
4
- export const fromNodeRedis = (client, options) => createStore(client, options);
5
- export const fromIORedis = (client, options) => createStore(client, options);
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);
@@ -1,5 +1,5 @@
1
1
  import type { RedisClientLike } from './RedisSessionStore.js';
2
- export declare const callCommand: <T>(client: RedisClientLike, names: Array<keyof RedisClientLike>, ...args: unknown[]) => Promise<T>;
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>;
@@ -1,4 +1,7 @@
1
- export const callCommand = async (client, names, ...args) => {
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.14.0",
9
+ "version": "0.15.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.14.0"
21
+ "@kuralle-agents/core": "0.15.0"
22
22
  },
23
23
  "peerDependencies": {
24
- "@kuralle-agents/core": "0.14.0",
25
- "@kuralle-agents/rag": "0.14.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.14.0"
36
+ "@kuralle-agents/rag": "0.15.0"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"