@langchain/langgraph-checkpoint-redis 0.0.2 → 1.0.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/shallow.js CHANGED
@@ -1,536 +1,406 @@
1
- import { BaseCheckpointSaver, uuid6, } from "@langchain/langgraph-checkpoint";
1
+ import { BaseCheckpointSaver, uuid6 } from "@langchain/langgraph-checkpoint";
2
2
  import { createClient } from "redis";
3
- // Helper function for deterministic object comparison
3
+
4
+ //#region src/shallow.ts
4
5
  function deterministicStringify(obj) {
5
- if (obj === null || typeof obj !== "object") {
6
- return JSON.stringify(obj);
7
- }
8
- if (Array.isArray(obj)) {
9
- return JSON.stringify(obj.map((item) => deterministicStringify(item)));
10
- }
11
- const sortedObj = {};
12
- const sortedKeys = Object.keys(obj).sort();
13
- for (const key of sortedKeys) {
14
- sortedObj[key] = obj[key];
15
- }
16
- return JSON.stringify(sortedObj, (_, value) => {
17
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
18
- const sorted = {};
19
- const keys = Object.keys(value).sort();
20
- for (const k of keys) {
21
- sorted[k] = value[k];
22
- }
23
- return sorted;
24
- }
25
- return value;
26
- });
6
+ if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
7
+ if (Array.isArray(obj)) return JSON.stringify(obj.map((item) => deterministicStringify(item)));
8
+ const sortedObj = {};
9
+ const sortedKeys = Object.keys(obj).sort();
10
+ for (const key of sortedKeys) sortedObj[key] = obj[key];
11
+ return JSON.stringify(sortedObj, (_, value) => {
12
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
13
+ const sorted = {};
14
+ const keys = Object.keys(value).sort();
15
+ for (const k of keys) sorted[k] = value[k];
16
+ return sorted;
17
+ }
18
+ return value;
19
+ });
27
20
  }
28
- const SCHEMAS = [
29
- {
30
- index: "checkpoints",
31
- prefix: "checkpoint:",
32
- schema: {
33
- "$.thread_id": { type: "TAG", AS: "thread_id" },
34
- "$.checkpoint_ns": { type: "TAG", AS: "checkpoint_ns" },
35
- "$.checkpoint_id": { type: "TAG", AS: "checkpoint_id" },
36
- "$.parent_checkpoint_id": { type: "TAG", AS: "parent_checkpoint_id" },
37
- "$.checkpoint_ts": { type: "NUMERIC", AS: "checkpoint_ts" },
38
- "$.has_writes": { type: "TAG", AS: "has_writes" },
39
- "$.source": { type: "TAG", AS: "source" },
40
- "$.step": { type: "NUMERIC", AS: "step" },
41
- },
42
- },
43
- {
44
- index: "checkpoint_writes",
45
- prefix: "checkpoint_write:",
46
- schema: {
47
- "$.thread_id": { type: "TAG", AS: "thread_id" },
48
- "$.checkpoint_ns": { type: "TAG", AS: "checkpoint_ns" },
49
- "$.checkpoint_id": { type: "TAG", AS: "checkpoint_id" },
50
- "$.task_id": { type: "TAG", AS: "task_id" },
51
- "$.idx": { type: "NUMERIC", AS: "idx" },
52
- "$.channel": { type: "TAG", AS: "channel" },
53
- "$.type": { type: "TAG", AS: "type" },
54
- },
55
- },
56
- ];
21
+ const SCHEMAS = [{
22
+ index: "checkpoints",
23
+ prefix: "checkpoint:",
24
+ schema: {
25
+ "$.thread_id": {
26
+ type: "TAG",
27
+ AS: "thread_id"
28
+ },
29
+ "$.checkpoint_ns": {
30
+ type: "TAG",
31
+ AS: "checkpoint_ns"
32
+ },
33
+ "$.checkpoint_id": {
34
+ type: "TAG",
35
+ AS: "checkpoint_id"
36
+ },
37
+ "$.parent_checkpoint_id": {
38
+ type: "TAG",
39
+ AS: "parent_checkpoint_id"
40
+ },
41
+ "$.checkpoint_ts": {
42
+ type: "NUMERIC",
43
+ AS: "checkpoint_ts"
44
+ },
45
+ "$.has_writes": {
46
+ type: "TAG",
47
+ AS: "has_writes"
48
+ },
49
+ "$.source": {
50
+ type: "TAG",
51
+ AS: "source"
52
+ },
53
+ "$.step": {
54
+ type: "NUMERIC",
55
+ AS: "step"
56
+ }
57
+ }
58
+ }, {
59
+ index: "checkpoint_writes",
60
+ prefix: "checkpoint_write:",
61
+ schema: {
62
+ "$.thread_id": {
63
+ type: "TAG",
64
+ AS: "thread_id"
65
+ },
66
+ "$.checkpoint_ns": {
67
+ type: "TAG",
68
+ AS: "checkpoint_ns"
69
+ },
70
+ "$.checkpoint_id": {
71
+ type: "TAG",
72
+ AS: "checkpoint_id"
73
+ },
74
+ "$.task_id": {
75
+ type: "TAG",
76
+ AS: "task_id"
77
+ },
78
+ "$.idx": {
79
+ type: "NUMERIC",
80
+ AS: "idx"
81
+ },
82
+ "$.channel": {
83
+ type: "TAG",
84
+ AS: "channel"
85
+ },
86
+ "$.type": {
87
+ type: "TAG",
88
+ AS: "type"
89
+ }
90
+ }
91
+ }];
57
92
  /**
58
- * ShallowRedisSaver - A Redis checkpoint saver that only keeps the latest checkpoint per thread.
59
- *
60
- * This is a memory-optimized variant that:
61
- * - Only stores the most recent checkpoint for each thread
62
- * - Stores channel values inline (no separate blob storage)
63
- * - Automatically cleans up old checkpoints and writes when new ones are added
64
- * - Reduces storage usage for applications that don't need checkpoint history
65
- */
66
- export class ShallowRedisSaver extends BaseCheckpointSaver {
67
- client;
68
- ttlConfig;
69
- constructor(client, ttlConfig) {
70
- super();
71
- this.client = client;
72
- this.ttlConfig = ttlConfig;
73
- }
74
- static async fromUrl(url, ttlConfig) {
75
- const client = createClient({ url });
76
- await client.connect();
77
- const saver = new ShallowRedisSaver(client, ttlConfig);
78
- await saver.ensureIndexes();
79
- return saver;
80
- }
81
- async get(config) {
82
- const tuple = await this.getTuple(config);
83
- return tuple?.checkpoint;
84
- }
85
- async put(config, checkpoint, metadata, _newVersions) {
86
- await this.ensureIndexes();
87
- const threadId = config.configurable?.thread_id;
88
- const checkpointNs = config.configurable?.checkpoint_ns ?? "";
89
- const parentCheckpointId = config.configurable?.checkpoint_id;
90
- if (!threadId) {
91
- throw new Error("thread_id is required");
92
- }
93
- const checkpointId = checkpoint.id || uuid6(0);
94
- // In shallow mode, we use a single key per thread (no checkpoint_id in key)
95
- const key = `checkpoint:${threadId}:${checkpointNs}:shallow`;
96
- // Get the previous checkpoint to know what to clean up
97
- let prevCheckpointData = null;
98
- let prevCheckpointId = null;
99
- try {
100
- prevCheckpointData = await this.client.json.get(key);
101
- if (prevCheckpointData && typeof prevCheckpointData === "object") {
102
- prevCheckpointId = prevCheckpointData.checkpoint_id;
103
- }
104
- }
105
- catch (error) {
106
- // Key doesn't exist yet, that's fine
107
- }
108
- // Clean up old checkpoint and related data if it exists
109
- if (prevCheckpointId && prevCheckpointId !== checkpointId) {
110
- await this.cleanupOldCheckpoint(threadId, checkpointNs, prevCheckpointId);
111
- }
112
- // Store channel values inline - no blob storage in shallow mode
113
- const checkpointCopy = {
114
- ...checkpoint,
115
- channel_values: checkpoint.channel_values || {},
116
- // Remove channel_blobs if present
117
- channel_blobs: undefined,
118
- };
119
- // Structure matching Python implementation
120
- const jsonDoc = {
121
- thread_id: threadId,
122
- checkpoint_ns: checkpointNs,
123
- checkpoint_id: checkpointId,
124
- parent_checkpoint_id: parentCheckpointId || null,
125
- checkpoint: checkpointCopy,
126
- metadata: this.sanitizeMetadata(metadata),
127
- checkpoint_ts: Date.now(),
128
- has_writes: "false",
129
- };
130
- // Store metadata fields at top-level for searching
131
- this.addSearchableMetadataFields(jsonDoc, metadata);
132
- // Use Redis JSON commands
133
- await this.client.json.set(key, "$", jsonDoc);
134
- // Apply TTL if configured
135
- if (this.ttlConfig?.defaultTTL) {
136
- await this.applyTTL(key);
137
- }
138
- return {
139
- configurable: {
140
- thread_id: threadId,
141
- checkpoint_ns: checkpointNs,
142
- checkpoint_id: checkpointId,
143
- },
144
- };
145
- }
146
- async getTuple(config) {
147
- const threadId = config.configurable?.thread_id;
148
- const checkpointNs = config.configurable?.checkpoint_ns ?? "";
149
- const checkpointId = config.configurable?.checkpoint_id;
150
- if (!threadId) {
151
- return undefined;
152
- }
153
- // In shallow mode, we use a single key per thread
154
- const key = `checkpoint:${threadId}:${checkpointNs}:shallow`;
155
- const jsonDoc = await this.client.json.get(key);
156
- if (!jsonDoc) {
157
- return undefined;
158
- }
159
- // If a specific checkpoint_id was requested, check if it matches
160
- if (checkpointId && jsonDoc.checkpoint_id !== checkpointId) {
161
- return undefined;
162
- }
163
- // Refresh TTL if configured
164
- if (this.ttlConfig?.refreshOnRead && this.ttlConfig?.defaultTTL) {
165
- await this.applyTTL(key);
166
- }
167
- // Channel values are stored inline in shallow mode
168
- const checkpoint = {
169
- ...jsonDoc.checkpoint,
170
- channel_values: jsonDoc.checkpoint.channel_values || {},
171
- };
172
- // Load pending writes if they exist
173
- let pendingWrites;
174
- if (jsonDoc.has_writes === "true") {
175
- pendingWrites = await this.loadPendingWrites(jsonDoc.thread_id, jsonDoc.checkpoint_ns, jsonDoc.checkpoint_id);
176
- }
177
- return this.createCheckpointTuple(jsonDoc, checkpoint, pendingWrites);
178
- }
179
- async *list(config, options) {
180
- await this.ensureIndexes();
181
- // In shallow mode, we only return the latest checkpoint per thread
182
- if (config?.configurable?.thread_id) {
183
- // Single thread case
184
- const tuple = await this.getTuple(config);
185
- if (tuple) {
186
- // Apply filter if provided
187
- if (options?.filter) {
188
- if (this.checkMetadataFilterMatch(tuple.metadata, options.filter)) {
189
- yield tuple;
190
- }
191
- }
192
- else {
193
- yield tuple;
194
- }
195
- }
196
- }
197
- else {
198
- // All threads case - use search
199
- const queryParts = [];
200
- // Add metadata filters
201
- if (options?.filter) {
202
- for (const [key, value] of Object.entries(options.filter)) {
203
- if (value === undefined) {
204
- // Skip undefined filters
205
- }
206
- else if (value === null) {
207
- // Skip null values for RediSearch query, will handle in post-processing
208
- }
209
- else if (typeof value === "string") {
210
- queryParts.push(`(@${key}:{${value}})`);
211
- }
212
- else if (typeof value === "number") {
213
- queryParts.push(`(@${key}:[${value} ${value}])`);
214
- }
215
- }
216
- }
217
- if (queryParts.length === 0) {
218
- queryParts.push("*");
219
- }
220
- const query = queryParts.join(" ");
221
- const limit = options?.limit ?? 10;
222
- try {
223
- const results = await this.client.ft.search("checkpoints", query, {
224
- LIMIT: { from: 0, size: limit * 2 }, // Get more since we'll deduplicate
225
- SORTBY: { BY: "checkpoint_ts", DIRECTION: "DESC" },
226
- });
227
- // In shallow mode, deduplicate by thread_id
228
- const seenThreads = new Set();
229
- let yieldCount = 0;
230
- for (const doc of results.documents) {
231
- if (yieldCount >= limit)
232
- break;
233
- const jsonDoc = doc.value;
234
- const threadKey = `${jsonDoc.thread_id}:${jsonDoc.checkpoint_ns}`;
235
- // Skip if we've already seen this thread
236
- if (seenThreads.has(threadKey)) {
237
- continue;
238
- }
239
- seenThreads.add(threadKey);
240
- // Check null filters manually if needed
241
- if (options?.filter) {
242
- if (!this.checkMetadataFilterMatch(jsonDoc.metadata, options.filter)) {
243
- continue;
244
- }
245
- }
246
- // Channel values are inline in shallow mode
247
- const checkpoint = {
248
- ...jsonDoc.checkpoint,
249
- channel_values: jsonDoc.checkpoint.channel_values || {},
250
- };
251
- yield this.createCheckpointTuple(jsonDoc, checkpoint);
252
- yieldCount++;
253
- }
254
- }
255
- catch (error) {
256
- if (error.message?.includes("no such index")) {
257
- // Index doesn't exist yet, fall back to scanning all shallow checkpoints
258
- const pattern = `checkpoint:*:*:shallow`;
259
- const keys = await this.client.keys(pattern);
260
- if (keys.length === 0) {
261
- return;
262
- }
263
- // Sort keys to have consistent ordering
264
- keys.sort().reverse();
265
- // Get unique threads
266
- const seenThreads = new Set();
267
- let yieldCount = 0;
268
- const limit = options?.limit ?? 10;
269
- for (const key of keys) {
270
- if (yieldCount >= limit)
271
- break;
272
- const jsonDoc = await this.client.json.get(key);
273
- if (!jsonDoc)
274
- continue;
275
- const threadKey = `${jsonDoc.thread_id}:${jsonDoc.checkpoint_ns}`;
276
- // Skip if we've already seen this thread
277
- if (seenThreads.has(threadKey)) {
278
- continue;
279
- }
280
- seenThreads.add(threadKey);
281
- // Check filter if provided
282
- if (options?.filter) {
283
- if (!this.checkMetadataFilterMatch(jsonDoc.metadata, options.filter)) {
284
- continue;
285
- }
286
- }
287
- // Channel values are inline in shallow mode
288
- const checkpoint = {
289
- ...jsonDoc.checkpoint,
290
- channel_values: jsonDoc.checkpoint.channel_values || {},
291
- };
292
- yield this.createCheckpointTuple(jsonDoc, checkpoint);
293
- yieldCount++;
294
- }
295
- return;
296
- }
297
- throw error;
298
- }
299
- }
300
- }
301
- async putWrites(config, writes, taskId) {
302
- await this.ensureIndexes();
303
- const threadId = config.configurable?.thread_id;
304
- const checkpointNs = config.configurable?.checkpoint_ns ?? "";
305
- const checkpointId = config.configurable?.checkpoint_id;
306
- if (!threadId || !checkpointId) {
307
- throw new Error("thread_id and checkpoint_id are required");
308
- }
309
- // In shallow mode, we overwrite all writes for the task
310
- // First, clean up old writes for this task
311
- const writePattern = `checkpoint_write:${threadId}:${checkpointNs}:${checkpointId}:${taskId}:*`;
312
- const oldWriteKeys = await this.client.keys(writePattern);
313
- if (oldWriteKeys.length > 0) {
314
- await this.client.del(oldWriteKeys);
315
- }
316
- // Store new writes
317
- const writeKeys = [];
318
- for (let idx = 0; idx < writes.length; idx++) {
319
- const [channel, value] = writes[idx];
320
- const writeKey = `checkpoint_write:${threadId}:${checkpointNs}:${checkpointId}:${taskId}:${idx}`;
321
- writeKeys.push(writeKey);
322
- const writeDoc = {
323
- thread_id: threadId,
324
- checkpoint_ns: checkpointNs,
325
- checkpoint_id: checkpointId,
326
- task_id: taskId,
327
- idx: idx,
328
- channel: channel,
329
- type: typeof value === "object" ? "json" : "string",
330
- value: value,
331
- };
332
- await this.client.json.set(writeKey, "$", writeDoc);
333
- }
334
- // Register write keys in sorted set for efficient retrieval
335
- if (writeKeys.length > 0) {
336
- const zsetKey = `write_keys_zset:${threadId}:${checkpointNs}:${checkpointId}`;
337
- // Clear existing entries for this task and add new ones
338
- const zaddArgs = {};
339
- writeKeys.forEach((key, idx) => {
340
- zaddArgs[key] = idx;
341
- });
342
- await this.client.zAdd(zsetKey, Object.entries(zaddArgs).map(([key, score]) => ({ score, value: key })));
343
- // Apply TTL to write keys and zset if configured
344
- if (this.ttlConfig?.defaultTTL) {
345
- await this.applyTTL(...writeKeys, zsetKey);
346
- }
347
- }
348
- // Update checkpoint to indicate it has writes
349
- const checkpointKey = `checkpoint:${threadId}:${checkpointNs}:shallow`;
350
- const checkpointExists = await this.client.exists(checkpointKey);
351
- if (checkpointExists) {
352
- const currentDoc = await this.client.json.get(checkpointKey);
353
- if (currentDoc) {
354
- currentDoc.has_writes = "true";
355
- await this.client.json.set(checkpointKey, "$", currentDoc);
356
- }
357
- }
358
- }
359
- async deleteThread(threadId) {
360
- // Delete shallow checkpoints
361
- const checkpointPattern = `checkpoint:${threadId}:*:shallow`;
362
- const checkpointKeys = await this.client.keys(checkpointPattern);
363
- if (checkpointKeys.length > 0) {
364
- await this.client.del(checkpointKeys);
365
- }
366
- // Delete writes
367
- const writesPattern = `checkpoint_write:${threadId}:*`;
368
- const writesKeys = await this.client.keys(writesPattern);
369
- if (writesKeys.length > 0) {
370
- await this.client.del(writesKeys);
371
- }
372
- // Delete write registries
373
- const zsetPattern = `write_keys_zset:${threadId}:*`;
374
- const zsetKeys = await this.client.keys(zsetPattern);
375
- if (zsetKeys.length > 0) {
376
- await this.client.del(zsetKeys);
377
- }
378
- }
379
- async end() {
380
- await this.client.quit();
381
- }
382
- // Helper method to add searchable metadata fields
383
- addSearchableMetadataFields(jsonDoc, metadata) {
384
- if (!metadata)
385
- return;
386
- // Add common searchable fields at top level
387
- if ("source" in metadata) {
388
- jsonDoc.source = metadata.source;
389
- }
390
- if ("step" in metadata) {
391
- jsonDoc.step = metadata.step;
392
- }
393
- if ("writes" in metadata) {
394
- // Writes field needs to be JSON stringified for TAG search
395
- jsonDoc.writes =
396
- typeof metadata.writes === "object"
397
- ? JSON.stringify(metadata.writes)
398
- : metadata.writes;
399
- }
400
- if ("score" in metadata) {
401
- jsonDoc.score = metadata.score;
402
- }
403
- }
404
- // Helper method to create checkpoint tuple from json document
405
- createCheckpointTuple(jsonDoc, checkpoint, pendingWrites) {
406
- return {
407
- config: {
408
- configurable: {
409
- thread_id: jsonDoc.thread_id,
410
- checkpoint_ns: jsonDoc.checkpoint_ns,
411
- checkpoint_id: jsonDoc.checkpoint_id,
412
- },
413
- },
414
- checkpoint,
415
- metadata: jsonDoc.metadata,
416
- parentConfig: jsonDoc.parent_checkpoint_id
417
- ? {
418
- configurable: {
419
- thread_id: jsonDoc.thread_id,
420
- checkpoint_ns: jsonDoc.checkpoint_ns,
421
- checkpoint_id: jsonDoc.parent_checkpoint_id,
422
- },
423
- }
424
- : undefined,
425
- pendingWrites,
426
- };
427
- }
428
- // Helper method to apply TTL to keys
429
- async applyTTL(...keys) {
430
- if (!this.ttlConfig?.defaultTTL)
431
- return;
432
- const ttlSeconds = Math.floor(this.ttlConfig.defaultTTL * 60);
433
- const results = await Promise.allSettled(keys.map((key) => this.client.expire(key, ttlSeconds)));
434
- // Log any failures but don't throw - TTL is best effort
435
- for (let i = 0; i < results.length; i++) {
436
- if (results[i].status === "rejected") {
437
- console.warn(`Failed to set TTL for key ${keys[i]}:`, results[i].reason);
438
- }
439
- }
440
- }
441
- // Helper method to load pending writes
442
- async loadPendingWrites(threadId, checkpointNs, checkpointId) {
443
- const zsetKey = `write_keys_zset:${threadId}:${checkpointNs}:${checkpointId}`;
444
- const writeKeys = await this.client.zRange(zsetKey, 0, -1);
445
- if (writeKeys.length === 0) {
446
- return undefined;
447
- }
448
- const pendingWrites = [];
449
- for (const writeKey of writeKeys) {
450
- const writeDoc = await this.client.json.get(writeKey);
451
- if (writeDoc) {
452
- pendingWrites.push([
453
- writeDoc.task_id,
454
- writeDoc.channel,
455
- writeDoc.value,
456
- ]);
457
- }
458
- }
459
- return pendingWrites;
460
- }
461
- // Helper method to check metadata filter matches
462
- checkMetadataFilterMatch(metadata, filter) {
463
- for (const [key, value] of Object.entries(filter)) {
464
- const metadataValue = metadata?.[key];
465
- if (value === null) {
466
- if (!(key in (metadata || {})) || metadataValue !== null) {
467
- return false;
468
- }
469
- }
470
- else if (typeof value === "object" && !Array.isArray(value)) {
471
- // Deep comparison for objects with deterministic key ordering
472
- if (typeof metadataValue !== "object" || metadataValue === null) {
473
- return false;
474
- }
475
- if (deterministicStringify(value) !==
476
- deterministicStringify(metadataValue)) {
477
- return false;
478
- }
479
- }
480
- else if (metadataValue !== value) {
481
- return false;
482
- }
483
- }
484
- return true;
485
- }
486
- async cleanupOldCheckpoint(threadId, checkpointNs, oldCheckpointId) {
487
- // Clean up old writes
488
- const writePattern = `checkpoint_write:${threadId}:${checkpointNs}:${oldCheckpointId}:*`;
489
- const oldWriteKeys = await this.client.keys(writePattern);
490
- if (oldWriteKeys.length > 0) {
491
- await this.client.del(oldWriteKeys);
492
- }
493
- // Clean up write registry
494
- const zsetKey = `write_keys_zset:${threadId}:${checkpointNs}:${oldCheckpointId}`;
495
- await this.client.del(zsetKey);
496
- // Note: We don't clean up blob keys in shallow mode since we store inline
497
- // But for completeness, clean up any legacy blob keys if they exist
498
- const blobPattern = `checkpoint_blob:${threadId}:${checkpointNs}:${oldCheckpointId}:*`;
499
- const oldBlobKeys = await this.client.keys(blobPattern);
500
- if (oldBlobKeys.length > 0) {
501
- await this.client.del(oldBlobKeys);
502
- }
503
- }
504
- sanitizeMetadata(metadata) {
505
- if (!metadata)
506
- return {};
507
- const sanitized = {};
508
- for (const [key, value] of Object.entries(metadata)) {
509
- // Remove null characters from keys and string values
510
- // eslint-disable-next-line no-control-regex
511
- const sanitizedKey = key.replace(/\x00/g, "");
512
- sanitized[sanitizedKey] =
513
- // eslint-disable-next-line no-control-regex
514
- typeof value === "string" ? value.replace(/\x00/g, "") : value;
515
- }
516
- return sanitized;
517
- }
518
- async ensureIndexes() {
519
- for (const schema of SCHEMAS) {
520
- try {
521
- // Try to create the index
522
- await this.client.ft.create(schema.index, schema.schema, {
523
- ON: "JSON",
524
- PREFIX: schema.prefix,
525
- });
526
- }
527
- catch (error) {
528
- // Ignore if index already exists
529
- if (!error.message?.includes("Index already exists")) {
530
- console.error(`Failed to create index ${schema.index}:`, error.message);
531
- }
532
- }
533
- }
534
- }
535
- }
93
+ * ShallowRedisSaver - A Redis checkpoint saver that only keeps the latest checkpoint per thread.
94
+ *
95
+ * This is a memory-optimized variant that:
96
+ * - Only stores the most recent checkpoint for each thread
97
+ * - Stores channel values inline (no separate blob storage)
98
+ * - Automatically cleans up old checkpoints and writes when new ones are added
99
+ * - Reduces storage usage for applications that don't need checkpoint history
100
+ */
101
+ var ShallowRedisSaver = class ShallowRedisSaver extends BaseCheckpointSaver {
102
+ client;
103
+ ttlConfig;
104
+ constructor(client, ttlConfig) {
105
+ super();
106
+ this.client = client;
107
+ this.ttlConfig = ttlConfig;
108
+ }
109
+ static async fromUrl(url, ttlConfig) {
110
+ const client = createClient({ url });
111
+ await client.connect();
112
+ const saver = new ShallowRedisSaver(client, ttlConfig);
113
+ await saver.ensureIndexes();
114
+ return saver;
115
+ }
116
+ async get(config) {
117
+ const tuple = await this.getTuple(config);
118
+ return tuple?.checkpoint;
119
+ }
120
+ async put(config, checkpoint, metadata, _newVersions) {
121
+ await this.ensureIndexes();
122
+ const threadId = config.configurable?.thread_id;
123
+ const checkpointNs = config.configurable?.checkpoint_ns ?? "";
124
+ const parentCheckpointId = config.configurable?.checkpoint_id;
125
+ if (!threadId) throw new Error("thread_id is required");
126
+ const checkpointId = checkpoint.id || uuid6(0);
127
+ const key = `checkpoint:${threadId}:${checkpointNs}:shallow`;
128
+ let prevCheckpointData = null;
129
+ let prevCheckpointId = null;
130
+ try {
131
+ prevCheckpointData = await this.client.json.get(key);
132
+ if (prevCheckpointData && typeof prevCheckpointData === "object") prevCheckpointId = prevCheckpointData.checkpoint_id;
133
+ } catch (error) {}
134
+ if (prevCheckpointId && prevCheckpointId !== checkpointId) await this.cleanupOldCheckpoint(threadId, checkpointNs, prevCheckpointId);
135
+ const checkpointCopy = {
136
+ ...checkpoint,
137
+ channel_values: checkpoint.channel_values || {},
138
+ channel_blobs: void 0
139
+ };
140
+ const jsonDoc = {
141
+ thread_id: threadId,
142
+ checkpoint_ns: checkpointNs,
143
+ checkpoint_id: checkpointId,
144
+ parent_checkpoint_id: parentCheckpointId || null,
145
+ checkpoint: checkpointCopy,
146
+ metadata: this.sanitizeMetadata(metadata),
147
+ checkpoint_ts: Date.now(),
148
+ has_writes: "false"
149
+ };
150
+ this.addSearchableMetadataFields(jsonDoc, metadata);
151
+ await this.client.json.set(key, "$", jsonDoc);
152
+ if (this.ttlConfig?.defaultTTL) await this.applyTTL(key);
153
+ return { configurable: {
154
+ thread_id: threadId,
155
+ checkpoint_ns: checkpointNs,
156
+ checkpoint_id: checkpointId
157
+ } };
158
+ }
159
+ async getTuple(config) {
160
+ const threadId = config.configurable?.thread_id;
161
+ const checkpointNs = config.configurable?.checkpoint_ns ?? "";
162
+ const checkpointId = config.configurable?.checkpoint_id;
163
+ if (!threadId) return void 0;
164
+ const key = `checkpoint:${threadId}:${checkpointNs}:shallow`;
165
+ const jsonDoc = await this.client.json.get(key);
166
+ if (!jsonDoc) return void 0;
167
+ if (checkpointId && jsonDoc.checkpoint_id !== checkpointId) return void 0;
168
+ if (this.ttlConfig?.refreshOnRead && this.ttlConfig?.defaultTTL) await this.applyTTL(key);
169
+ const checkpoint = {
170
+ ...jsonDoc.checkpoint,
171
+ channel_values: jsonDoc.checkpoint.channel_values || {}
172
+ };
173
+ let pendingWrites;
174
+ if (jsonDoc.has_writes === "true") pendingWrites = await this.loadPendingWrites(jsonDoc.thread_id, jsonDoc.checkpoint_ns, jsonDoc.checkpoint_id);
175
+ return this.createCheckpointTuple(jsonDoc, checkpoint, pendingWrites);
176
+ }
177
+ async *list(config, options) {
178
+ await this.ensureIndexes();
179
+ if (config?.configurable?.thread_id) {
180
+ const tuple = await this.getTuple(config);
181
+ if (tuple) if (options?.filter) {
182
+ if (this.checkMetadataFilterMatch(tuple.metadata, options.filter)) yield tuple;
183
+ } else yield tuple;
184
+ } else {
185
+ const queryParts = [];
186
+ if (options?.filter) {
187
+ for (const [key, value] of Object.entries(options.filter)) if (value === void 0) {} else if (value === null) {} else if (typeof value === "string") queryParts.push(`(@${key}:{${value}})`);
188
+ else if (typeof value === "number") queryParts.push(`(@${key}:[${value} ${value}])`);
189
+ }
190
+ if (queryParts.length === 0) queryParts.push("*");
191
+ const query = queryParts.join(" ");
192
+ const limit = options?.limit ?? 10;
193
+ try {
194
+ const results = await this.client.ft.search("checkpoints", query, {
195
+ LIMIT: {
196
+ from: 0,
197
+ size: limit * 2
198
+ },
199
+ SORTBY: {
200
+ BY: "checkpoint_ts",
201
+ DIRECTION: "DESC"
202
+ }
203
+ });
204
+ const seenThreads = /* @__PURE__ */ new Set();
205
+ let yieldCount = 0;
206
+ for (const doc of results.documents) {
207
+ if (yieldCount >= limit) break;
208
+ const jsonDoc = doc.value;
209
+ const threadKey = `${jsonDoc.thread_id}:${jsonDoc.checkpoint_ns}`;
210
+ if (seenThreads.has(threadKey)) continue;
211
+ seenThreads.add(threadKey);
212
+ if (options?.filter) {
213
+ if (!this.checkMetadataFilterMatch(jsonDoc.metadata, options.filter)) continue;
214
+ }
215
+ const checkpoint = {
216
+ ...jsonDoc.checkpoint,
217
+ channel_values: jsonDoc.checkpoint.channel_values || {}
218
+ };
219
+ yield this.createCheckpointTuple(jsonDoc, checkpoint);
220
+ yieldCount++;
221
+ }
222
+ } catch (error) {
223
+ if (error.message?.includes("no such index")) {
224
+ const pattern = `checkpoint:*:*:shallow`;
225
+ const keys = await this.client.keys(pattern);
226
+ if (keys.length === 0) return;
227
+ keys.sort().reverse();
228
+ const seenThreads = /* @__PURE__ */ new Set();
229
+ let yieldCount = 0;
230
+ const limit$1 = options?.limit ?? 10;
231
+ for (const key of keys) {
232
+ if (yieldCount >= limit$1) break;
233
+ const jsonDoc = await this.client.json.get(key);
234
+ if (!jsonDoc) continue;
235
+ const threadKey = `${jsonDoc.thread_id}:${jsonDoc.checkpoint_ns}`;
236
+ if (seenThreads.has(threadKey)) continue;
237
+ seenThreads.add(threadKey);
238
+ if (options?.filter) {
239
+ if (!this.checkMetadataFilterMatch(jsonDoc.metadata, options.filter)) continue;
240
+ }
241
+ const checkpoint = {
242
+ ...jsonDoc.checkpoint,
243
+ channel_values: jsonDoc.checkpoint.channel_values || {}
244
+ };
245
+ yield this.createCheckpointTuple(jsonDoc, checkpoint);
246
+ yieldCount++;
247
+ }
248
+ return;
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+ }
254
+ async putWrites(config, writes, taskId) {
255
+ await this.ensureIndexes();
256
+ const threadId = config.configurable?.thread_id;
257
+ const checkpointNs = config.configurable?.checkpoint_ns ?? "";
258
+ const checkpointId = config.configurable?.checkpoint_id;
259
+ if (!threadId || !checkpointId) throw new Error("thread_id and checkpoint_id are required");
260
+ const writePattern = `checkpoint_write:${threadId}:${checkpointNs}:${checkpointId}:${taskId}:*`;
261
+ const oldWriteKeys = await this.client.keys(writePattern);
262
+ if (oldWriteKeys.length > 0) await this.client.del(oldWriteKeys);
263
+ const writeKeys = [];
264
+ for (let idx = 0; idx < writes.length; idx++) {
265
+ const [channel, value] = writes[idx];
266
+ const writeKey = `checkpoint_write:${threadId}:${checkpointNs}:${checkpointId}:${taskId}:${idx}`;
267
+ writeKeys.push(writeKey);
268
+ const writeDoc = {
269
+ thread_id: threadId,
270
+ checkpoint_ns: checkpointNs,
271
+ checkpoint_id: checkpointId,
272
+ task_id: taskId,
273
+ idx,
274
+ channel,
275
+ type: typeof value === "object" ? "json" : "string",
276
+ value
277
+ };
278
+ await this.client.json.set(writeKey, "$", writeDoc);
279
+ }
280
+ if (writeKeys.length > 0) {
281
+ const zsetKey = `write_keys_zset:${threadId}:${checkpointNs}:${checkpointId}`;
282
+ const zaddArgs = {};
283
+ writeKeys.forEach((key, idx) => {
284
+ zaddArgs[key] = idx;
285
+ });
286
+ await this.client.zAdd(zsetKey, Object.entries(zaddArgs).map(([key, score]) => ({
287
+ score,
288
+ value: key
289
+ })));
290
+ if (this.ttlConfig?.defaultTTL) await this.applyTTL(...writeKeys, zsetKey);
291
+ }
292
+ const checkpointKey = `checkpoint:${threadId}:${checkpointNs}:shallow`;
293
+ const checkpointExists = await this.client.exists(checkpointKey);
294
+ if (checkpointExists) {
295
+ const currentDoc = await this.client.json.get(checkpointKey);
296
+ if (currentDoc) {
297
+ currentDoc.has_writes = "true";
298
+ await this.client.json.set(checkpointKey, "$", currentDoc);
299
+ }
300
+ }
301
+ }
302
+ async deleteThread(threadId) {
303
+ const checkpointPattern = `checkpoint:${threadId}:*:shallow`;
304
+ const checkpointKeys = await this.client.keys(checkpointPattern);
305
+ if (checkpointKeys.length > 0) await this.client.del(checkpointKeys);
306
+ const writesPattern = `checkpoint_write:${threadId}:*`;
307
+ const writesKeys = await this.client.keys(writesPattern);
308
+ if (writesKeys.length > 0) await this.client.del(writesKeys);
309
+ const zsetPattern = `write_keys_zset:${threadId}:*`;
310
+ const zsetKeys = await this.client.keys(zsetPattern);
311
+ if (zsetKeys.length > 0) await this.client.del(zsetKeys);
312
+ }
313
+ async end() {
314
+ await this.client.quit();
315
+ }
316
+ addSearchableMetadataFields(jsonDoc, metadata) {
317
+ if (!metadata) return;
318
+ if ("source" in metadata) jsonDoc.source = metadata.source;
319
+ if ("step" in metadata) jsonDoc.step = metadata.step;
320
+ if ("writes" in metadata) jsonDoc.writes = typeof metadata.writes === "object" ? JSON.stringify(metadata.writes) : metadata.writes;
321
+ if ("score" in metadata) jsonDoc.score = metadata.score;
322
+ }
323
+ createCheckpointTuple(jsonDoc, checkpoint, pendingWrites) {
324
+ return {
325
+ config: { configurable: {
326
+ thread_id: jsonDoc.thread_id,
327
+ checkpoint_ns: jsonDoc.checkpoint_ns,
328
+ checkpoint_id: jsonDoc.checkpoint_id
329
+ } },
330
+ checkpoint,
331
+ metadata: jsonDoc.metadata,
332
+ parentConfig: jsonDoc.parent_checkpoint_id ? { configurable: {
333
+ thread_id: jsonDoc.thread_id,
334
+ checkpoint_ns: jsonDoc.checkpoint_ns,
335
+ checkpoint_id: jsonDoc.parent_checkpoint_id
336
+ } } : void 0,
337
+ pendingWrites
338
+ };
339
+ }
340
+ async applyTTL(...keys) {
341
+ if (!this.ttlConfig?.defaultTTL) return;
342
+ const ttlSeconds = Math.floor(this.ttlConfig.defaultTTL * 60);
343
+ const results = await Promise.allSettled(keys.map((key) => this.client.expire(key, ttlSeconds)));
344
+ for (let i = 0; i < results.length; i++) if (results[i].status === "rejected") console.warn(`Failed to set TTL for key ${keys[i]}:`, results[i].reason);
345
+ }
346
+ async loadPendingWrites(threadId, checkpointNs, checkpointId) {
347
+ const zsetKey = `write_keys_zset:${threadId}:${checkpointNs}:${checkpointId}`;
348
+ const writeKeys = await this.client.zRange(zsetKey, 0, -1);
349
+ if (writeKeys.length === 0) return void 0;
350
+ const pendingWrites = [];
351
+ for (const writeKey of writeKeys) {
352
+ const writeDoc = await this.client.json.get(writeKey);
353
+ if (writeDoc) pendingWrites.push([
354
+ writeDoc.task_id,
355
+ writeDoc.channel,
356
+ writeDoc.value
357
+ ]);
358
+ }
359
+ return pendingWrites;
360
+ }
361
+ checkMetadataFilterMatch(metadata, filter) {
362
+ for (const [key, value] of Object.entries(filter)) {
363
+ const metadataValue = metadata?.[key];
364
+ if (value === null) {
365
+ if (!(key in (metadata || {})) || metadataValue !== null) return false;
366
+ } else if (typeof value === "object" && !Array.isArray(value)) {
367
+ if (typeof metadataValue !== "object" || metadataValue === null) return false;
368
+ if (deterministicStringify(value) !== deterministicStringify(metadataValue)) return false;
369
+ } else if (metadataValue !== value) return false;
370
+ }
371
+ return true;
372
+ }
373
+ async cleanupOldCheckpoint(threadId, checkpointNs, oldCheckpointId) {
374
+ const writePattern = `checkpoint_write:${threadId}:${checkpointNs}:${oldCheckpointId}:*`;
375
+ const oldWriteKeys = await this.client.keys(writePattern);
376
+ if (oldWriteKeys.length > 0) await this.client.del(oldWriteKeys);
377
+ const zsetKey = `write_keys_zset:${threadId}:${checkpointNs}:${oldCheckpointId}`;
378
+ await this.client.del(zsetKey);
379
+ const blobPattern = `checkpoint_blob:${threadId}:${checkpointNs}:${oldCheckpointId}:*`;
380
+ const oldBlobKeys = await this.client.keys(blobPattern);
381
+ if (oldBlobKeys.length > 0) await this.client.del(oldBlobKeys);
382
+ }
383
+ sanitizeMetadata(metadata) {
384
+ if (!metadata) return {};
385
+ const sanitized = {};
386
+ for (const [key, value] of Object.entries(metadata)) {
387
+ const sanitizedKey = key.replace(/\x00/g, "");
388
+ sanitized[sanitizedKey] = typeof value === "string" ? value.replace(/\x00/g, "") : value;
389
+ }
390
+ return sanitized;
391
+ }
392
+ async ensureIndexes() {
393
+ for (const schema of SCHEMAS) try {
394
+ await this.client.ft.create(schema.index, schema.schema, {
395
+ ON: "JSON",
396
+ PREFIX: schema.prefix
397
+ });
398
+ } catch (error) {
399
+ if (!error.message?.includes("Index already exists")) console.error(`Failed to create index ${schema.index}:`, error.message);
400
+ }
401
+ }
402
+ };
403
+
404
+ //#endregion
405
+ export { ShallowRedisSaver };
536
406
  //# sourceMappingURL=shallow.js.map