@langchain/langgraph-checkpoint-redis 0.0.1

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/store.js ADDED
@@ -0,0 +1,817 @@
1
+ import { createClient, createCluster } from "redis";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { InvalidNamespaceError, } from "@langchain/langgraph-checkpoint";
4
+ // Type guard functions for operations
5
+ export function isPutOperation(op) {
6
+ return "value" in op && "namespace" in op && "key" in op;
7
+ }
8
+ export function isGetOperation(op) {
9
+ return ("namespace" in op &&
10
+ "key" in op &&
11
+ !("value" in op) &&
12
+ !("namespacePrefix" in op) &&
13
+ !("matchConditions" in op));
14
+ }
15
+ export function isSearchOperation(op) {
16
+ return "namespacePrefix" in op;
17
+ }
18
+ export function isListNamespacesOperation(op) {
19
+ return "matchConditions" in op;
20
+ }
21
+ /**
22
+ * Internal class for evaluating filters against documents.
23
+ * Supports MongoDB-style query operators.
24
+ */
25
+ class FilterBuilder {
26
+ /**
27
+ * Evaluates if a document matches the given filter criteria.
28
+ * Supports advanced operators like $gt, $lt, $in, etc.
29
+ */
30
+ static matchesFilter(doc, filter) {
31
+ for (const [key, filterValue] of Object.entries(filter)) {
32
+ if (!this.matchesFieldFilter(doc, key, filterValue)) {
33
+ return false;
34
+ }
35
+ }
36
+ return true;
37
+ }
38
+ /**
39
+ * Builds a Redis Search query string from filter criteria.
40
+ * Note: This is limited by RediSearch capabilities and may not support all operators.
41
+ */
42
+ static buildRedisSearchQuery(filter, prefix) {
43
+ let queryParts = [];
44
+ let useClientFilter = false;
45
+ // Add prefix filter if provided
46
+ if (prefix) {
47
+ const tokens = prefix.split(/[.-]/).filter((t) => t.length > 0);
48
+ if (tokens.length > 0) {
49
+ queryParts.push(`@prefix:(${tokens.join(" ")})`);
50
+ }
51
+ }
52
+ // Check if we have complex operators that require client-side filtering
53
+ for (const [_key, value] of Object.entries(filter)) {
54
+ if (typeof value === "object" &&
55
+ value !== null &&
56
+ !Array.isArray(value) &&
57
+ Object.keys(value).some((k) => k.startsWith("$"))) {
58
+ // Complex operators require client-side filtering
59
+ useClientFilter = true;
60
+ break;
61
+ }
62
+ }
63
+ // If no prefix, at least search all documents
64
+ if (queryParts.length === 0) {
65
+ queryParts.push("*");
66
+ }
67
+ return {
68
+ query: queryParts.join(" "),
69
+ useClientFilter,
70
+ };
71
+ }
72
+ static matchesFieldFilter(doc, key, filterValue) {
73
+ // Handle nested keys (e.g., "user.name")
74
+ const actualValue = this.getNestedValue(doc, key);
75
+ // Check if it's an operator object
76
+ if (typeof filterValue === "object" &&
77
+ filterValue !== null &&
78
+ !Array.isArray(filterValue) &&
79
+ Object.keys(filterValue).some((k) => k.startsWith("$"))) {
80
+ // Handle operator object
81
+ return this.matchesOperators(actualValue, filterValue);
82
+ }
83
+ else {
84
+ // Simple equality check
85
+ return this.isEqual(actualValue, filterValue);
86
+ }
87
+ }
88
+ static matchesOperators(actualValue, operators) {
89
+ for (const [operator, operatorValue] of Object.entries(operators)) {
90
+ if (!this.matchesOperator(actualValue, operator, operatorValue)) {
91
+ return false;
92
+ }
93
+ }
94
+ return true;
95
+ }
96
+ static matchesOperator(actualValue, operator, operatorValue) {
97
+ switch (operator) {
98
+ case "$eq":
99
+ return this.isEqual(actualValue, operatorValue);
100
+ case "$ne":
101
+ return !this.isEqual(actualValue, operatorValue);
102
+ case "$gt":
103
+ return (actualValue !== undefined &&
104
+ actualValue !== null &&
105
+ Number(actualValue) > Number(operatorValue));
106
+ case "$gte":
107
+ return (actualValue !== undefined &&
108
+ actualValue !== null &&
109
+ Number(actualValue) >= Number(operatorValue));
110
+ case "$lt":
111
+ return (actualValue !== undefined &&
112
+ actualValue !== null &&
113
+ Number(actualValue) < Number(operatorValue));
114
+ case "$lte":
115
+ return (actualValue !== undefined &&
116
+ actualValue !== null &&
117
+ Number(actualValue) <= Number(operatorValue));
118
+ case "$in":
119
+ if (!Array.isArray(operatorValue))
120
+ return false;
121
+ return operatorValue.some((val) => this.isEqual(actualValue, val));
122
+ case "$nin":
123
+ if (!Array.isArray(operatorValue))
124
+ return false;
125
+ return !operatorValue.some((val) => this.isEqual(actualValue, val));
126
+ case "$exists": {
127
+ const exists = actualValue !== undefined;
128
+ return operatorValue ? exists : !exists;
129
+ }
130
+ default:
131
+ // Unknown operator, return false for safety
132
+ return false;
133
+ }
134
+ }
135
+ static isEqual(a, b) {
136
+ // Handle null and undefined
137
+ if (a === b)
138
+ return true;
139
+ if (a === null || b === null)
140
+ return false;
141
+ if (a === undefined || b === undefined)
142
+ return false;
143
+ // Handle arrays
144
+ if (Array.isArray(a) && Array.isArray(b)) {
145
+ if (a.length !== b.length)
146
+ return false;
147
+ return a.every((val, idx) => this.isEqual(val, b[idx]));
148
+ }
149
+ if (Array.isArray(a) || Array.isArray(b)) {
150
+ // Check if non-array value exists in array
151
+ const arr = Array.isArray(a) ? a : b;
152
+ const val = Array.isArray(a) ? b : a;
153
+ return arr.includes(val);
154
+ }
155
+ // Handle objects
156
+ if (typeof a === "object" && typeof b === "object") {
157
+ const aKeys = Object.keys(a);
158
+ const bKeys = Object.keys(b);
159
+ if (aKeys.length !== bKeys.length)
160
+ return false;
161
+ return aKeys.every((key) => this.isEqual(a[key], b[key]));
162
+ }
163
+ // Primitive comparison (with type coercion for numbers)
164
+ return a == b;
165
+ }
166
+ static getNestedValue(obj, path) {
167
+ const keys = path.split(".");
168
+ let current = obj;
169
+ for (const key of keys) {
170
+ if (current === null || current === undefined) {
171
+ return undefined;
172
+ }
173
+ current = current[key];
174
+ }
175
+ return current;
176
+ }
177
+ }
178
+ const REDIS_KEY_SEPARATOR = ":";
179
+ const STORE_PREFIX = "store";
180
+ const STORE_VECTOR_PREFIX = "store_vectors";
181
+ const SCHEMAS = [
182
+ {
183
+ index: "store",
184
+ prefix: STORE_PREFIX + REDIS_KEY_SEPARATOR,
185
+ schema: {
186
+ "$.prefix": { type: "TEXT", AS: "prefix" },
187
+ "$.key": { type: "TAG", AS: "key" },
188
+ "$.created_at": { type: "NUMERIC", AS: "created_at" },
189
+ "$.updated_at": { type: "NUMERIC", AS: "updated_at" },
190
+ },
191
+ },
192
+ {
193
+ index: "store_vectors",
194
+ prefix: STORE_VECTOR_PREFIX + REDIS_KEY_SEPARATOR,
195
+ schema: {
196
+ "$.prefix": { type: "TEXT", AS: "prefix" },
197
+ "$.key": { type: "TAG", AS: "key" },
198
+ "$.field_name": { type: "TAG", AS: "field_name" },
199
+ "$.embedding": { type: "VECTOR", AS: "embedding" },
200
+ "$.created_at": { type: "NUMERIC", AS: "created_at" },
201
+ "$.updated_at": { type: "NUMERIC", AS: "updated_at" },
202
+ },
203
+ },
204
+ ];
205
+ export class RedisStore {
206
+ client;
207
+ indexConfig;
208
+ ttlConfig;
209
+ embeddings;
210
+ constructor(client, config) {
211
+ this.client = client;
212
+ this.indexConfig = config?.index;
213
+ this.ttlConfig = config?.ttl;
214
+ if (this.indexConfig?.embed) {
215
+ this.embeddings = this.indexConfig.embed;
216
+ }
217
+ }
218
+ static async fromConnString(connString, config) {
219
+ const client = createClient({ url: connString });
220
+ await client.connect();
221
+ const store = new RedisStore(client, config);
222
+ await store.setup();
223
+ return store;
224
+ }
225
+ static async fromCluster(rootNodes, config) {
226
+ const client = createCluster({ rootNodes });
227
+ await client.connect();
228
+ const store = new RedisStore(client, config);
229
+ await store.setup();
230
+ return store;
231
+ }
232
+ async setup() {
233
+ // Create store index
234
+ try {
235
+ await this.client.ft.create(SCHEMAS[0].index, SCHEMAS[0].schema, {
236
+ ON: "JSON",
237
+ PREFIX: SCHEMAS[0].prefix,
238
+ });
239
+ }
240
+ catch (error) {
241
+ if (!error.message?.includes("Index already exists")) {
242
+ console.error("Failed to create store index:", error.message);
243
+ }
244
+ }
245
+ // Create vector index if configured
246
+ if (this.indexConfig) {
247
+ const dims = this.indexConfig.dims;
248
+ const distanceMetric = this.indexConfig.distanceType === "cosine"
249
+ ? "COSINE"
250
+ : this.indexConfig.distanceType === "l2"
251
+ ? "L2"
252
+ : this.indexConfig.distanceType === "ip"
253
+ ? "IP"
254
+ : "COSINE";
255
+ // Build schema with correct vector syntax
256
+ const vectorSchema = {
257
+ "$.prefix": { type: "TEXT", AS: "prefix" },
258
+ "$.key": { type: "TAG", AS: "key" },
259
+ "$.field_name": { type: "TAG", AS: "field_name" },
260
+ "$.created_at": { type: "NUMERIC", AS: "created_at" },
261
+ "$.updated_at": { type: "NUMERIC", AS: "updated_at" },
262
+ };
263
+ // Add vector field with correct syntax
264
+ vectorSchema["$.embedding"] = {
265
+ type: "VECTOR",
266
+ ALGORITHM: "FLAT",
267
+ TYPE: "FLOAT32",
268
+ DIM: dims,
269
+ DISTANCE_METRIC: distanceMetric,
270
+ AS: "embedding",
271
+ };
272
+ try {
273
+ await this.client.ft.create(SCHEMAS[1].index, vectorSchema, {
274
+ ON: "JSON",
275
+ PREFIX: SCHEMAS[1].prefix,
276
+ });
277
+ }
278
+ catch (error) {
279
+ if (!error.message?.includes("Index already exists")) {
280
+ console.error("Failed to create vector index:", error.message);
281
+ }
282
+ }
283
+ }
284
+ }
285
+ async get(namespace, key, options) {
286
+ const prefix = namespace.join(".");
287
+ // For TEXT fields, we need to match all tokens (split by dots and hyphens)
288
+ const tokens = prefix.split(/[.-]/).filter((t) => t.length > 0);
289
+ const prefixQuery = tokens.length > 0 ? `@prefix:(${tokens.join(" ")})` : "*";
290
+ // For TAG fields in curly braces, escape special characters
291
+ // Handle empty string as a special case
292
+ let query;
293
+ if (key === "") {
294
+ // For empty keys, search by prefix and filter results
295
+ query = prefixQuery;
296
+ }
297
+ else {
298
+ const escapedKey = this.escapeTagValue(key);
299
+ query = `(${prefixQuery}) (@key:{${escapedKey}})`;
300
+ }
301
+ try {
302
+ const results = await this.client.ft.search("store", query, {
303
+ LIMIT: { from: 0, size: key === "" ? 100 : 1 },
304
+ });
305
+ if (!results || !results.documents || results.documents.length === 0) {
306
+ return null;
307
+ }
308
+ // For empty key, filter to find the exact match
309
+ if (key === "") {
310
+ for (const doc of results.documents) {
311
+ const jsonDoc = doc.value;
312
+ if (jsonDoc.key === "" && jsonDoc.prefix === prefix) {
313
+ const docId = doc.id;
314
+ // Refresh TTL if requested
315
+ if (options?.refreshTTL) {
316
+ await this.refreshItemTTL(docId);
317
+ }
318
+ return {
319
+ value: jsonDoc.value,
320
+ key: jsonDoc.key,
321
+ namespace: jsonDoc.prefix.split("."),
322
+ created_at: new Date(jsonDoc.created_at / 1000000),
323
+ updated_at: new Date(jsonDoc.updated_at / 1000000),
324
+ };
325
+ }
326
+ }
327
+ return null;
328
+ }
329
+ const doc = results.documents[0];
330
+ const jsonDoc = doc.value;
331
+ const docId = doc.id;
332
+ // Refresh TTL if requested
333
+ if (options?.refreshTTL) {
334
+ await this.refreshItemTTL(docId);
335
+ }
336
+ return {
337
+ value: jsonDoc.value,
338
+ key: jsonDoc.key,
339
+ namespace: jsonDoc.prefix.split("."),
340
+ created_at: new Date(jsonDoc.created_at / 1000000),
341
+ updated_at: new Date(jsonDoc.updated_at / 1000000),
342
+ };
343
+ }
344
+ catch (error) {
345
+ if (error.message?.includes("no such index")) {
346
+ return null;
347
+ }
348
+ throw error;
349
+ }
350
+ }
351
+ async put(namespace, key, value, options) {
352
+ // Validate namespace for put operations
353
+ this.validateNamespace(namespace);
354
+ const prefix = namespace.join(".");
355
+ const docId = uuidv4();
356
+ // Use high-resolution time for better timestamp precision
357
+ const now = Date.now() * 1000000 + Math.floor(performance.now() * 1000); // Microseconds + nanoseconds component
358
+ let createdAt = now; // Will be overridden if document exists
359
+ // Delete existing document if it exists
360
+ // For TEXT fields, we need to match all tokens (split by dots and hyphens)
361
+ const tokens = prefix.split(/[.-]/).filter((t) => t.length > 0);
362
+ const prefixQuery = tokens.length > 0 ? `@prefix:(${tokens.join(" ")})` : "*";
363
+ // For TAG fields in curly braces, escape special characters
364
+ const escapedKey = this.escapeTagValue(key);
365
+ const existingQuery = `(${prefixQuery}) (@key:{${escapedKey}})`;
366
+ try {
367
+ const existing = await this.client.ft.search("store", existingQuery, {
368
+ LIMIT: { from: 0, size: 1 },
369
+ });
370
+ if (existing && existing.documents && existing.documents.length > 0) {
371
+ const oldDocId = existing.documents[0].id;
372
+ // Preserve the original created_at timestamp
373
+ const existingDoc = await this.client.json.get(oldDocId);
374
+ if (existingDoc &&
375
+ typeof existingDoc === "object" &&
376
+ "created_at" in existingDoc) {
377
+ createdAt = existingDoc.created_at;
378
+ }
379
+ await this.client.del(oldDocId);
380
+ // Also delete associated vector if it exists
381
+ if (this.indexConfig) {
382
+ const oldUuid = oldDocId.split(":").pop();
383
+ const oldVectorKey = `${STORE_VECTOR_PREFIX}${REDIS_KEY_SEPARATOR}${oldUuid}`;
384
+ try {
385
+ await this.client.del(oldVectorKey);
386
+ }
387
+ catch (error) {
388
+ // Vector might not exist
389
+ }
390
+ }
391
+ }
392
+ }
393
+ catch (error) {
394
+ // Index might not exist yet
395
+ }
396
+ // Handle delete operation
397
+ if (value === null) {
398
+ return;
399
+ }
400
+ // Store the document
401
+ const storeKey = `${STORE_PREFIX}${REDIS_KEY_SEPARATOR}${docId}`;
402
+ const doc = {
403
+ prefix,
404
+ key,
405
+ value,
406
+ created_at: createdAt,
407
+ updated_at: now,
408
+ };
409
+ await this.client.json.set(storeKey, "$", doc);
410
+ // Handle embeddings if configured
411
+ if (this.indexConfig && this.embeddings && options?.index !== false) {
412
+ const fieldsToIndex = options && Array.isArray(options.index)
413
+ ? options.index
414
+ : this.indexConfig.fields || ["text"];
415
+ const textsToEmbed = [];
416
+ const fieldNames = [];
417
+ for (const field of fieldsToIndex) {
418
+ if (value[field]) {
419
+ textsToEmbed.push(value[field]);
420
+ fieldNames.push(field);
421
+ }
422
+ }
423
+ if (textsToEmbed.length > 0) {
424
+ const embeddings = await this.embeddings.embedDocuments(textsToEmbed);
425
+ for (let i = 0; i < embeddings.length; i++) {
426
+ const vectorKey = `${STORE_VECTOR_PREFIX}${REDIS_KEY_SEPARATOR}${docId}`;
427
+ const vectorDoc = {
428
+ prefix,
429
+ key,
430
+ field_name: fieldNames[i],
431
+ embedding: embeddings[i],
432
+ created_at: now,
433
+ updated_at: now,
434
+ };
435
+ await this.client.json.set(vectorKey, "$", vectorDoc);
436
+ // Apply TTL to vector key if configured
437
+ const ttlMinutes = options?.ttl || this.ttlConfig?.defaultTTL;
438
+ if (ttlMinutes) {
439
+ const ttlSeconds = Math.floor(ttlMinutes * 60);
440
+ await this.client.expire(vectorKey, ttlSeconds);
441
+ }
442
+ }
443
+ }
444
+ }
445
+ // Apply TTL if configured
446
+ const ttlMinutes = options?.ttl || this.ttlConfig?.defaultTTL;
447
+ if (ttlMinutes) {
448
+ const ttlSeconds = Math.floor(ttlMinutes * 60);
449
+ await this.client.expire(storeKey, ttlSeconds);
450
+ }
451
+ }
452
+ async delete(namespace, key) {
453
+ await this.put(namespace, key, null);
454
+ }
455
+ async search(namespacePrefix, options) {
456
+ const prefix = namespacePrefix.join(".");
457
+ const limit = options?.limit || 10;
458
+ const offset = options?.offset || 0;
459
+ // Handle vector search if query is provided
460
+ if (options?.query && this.indexConfig && this.embeddings) {
461
+ const [embedding] = await this.embeddings.embedDocuments([options.query]);
462
+ // Build KNN query
463
+ // For prefix search, use wildcard since we want to match any document starting with this prefix
464
+ let queryStr = prefix ? `@prefix:${prefix.split(/[.-]/)[0]}*` : "*";
465
+ const vectorBytes = Buffer.from(new Float32Array(embedding).buffer);
466
+ try {
467
+ // Use KNN query with proper syntax
468
+ const results = await this.client.ft.search("store_vectors", `(${queryStr})=>[KNN ${limit} @embedding $BLOB]`, {
469
+ PARAMS: {
470
+ BLOB: vectorBytes,
471
+ },
472
+ DIALECT: 2,
473
+ LIMIT: { from: offset, size: limit },
474
+ RETURN: ["prefix", "key", "__embedding_score"],
475
+ });
476
+ // Get matching store documents
477
+ const items = [];
478
+ for (const doc of results.documents) {
479
+ const docUuid = doc.id.split(":").pop();
480
+ const storeKey = `${STORE_PREFIX}${REDIS_KEY_SEPARATOR}${docUuid}`;
481
+ const storeDoc = (await this.client.json.get(storeKey));
482
+ if (storeDoc) {
483
+ // Apply advanced filter if provided
484
+ if (options.filter) {
485
+ if (!FilterBuilder.matchesFilter(storeDoc.value || {}, options.filter)) {
486
+ continue;
487
+ }
488
+ }
489
+ // Refresh TTL if requested
490
+ if (options.refreshTTL) {
491
+ await this.refreshItemTTL(storeKey);
492
+ await this.refreshItemTTL(doc.id);
493
+ }
494
+ const score = doc.value?.__embedding_score
495
+ ? this.calculateSimilarityScore(parseFloat(doc.value.__embedding_score))
496
+ : 0;
497
+ // Apply similarity threshold if specified
498
+ const threshold = options.similarityThreshold ??
499
+ this.indexConfig?.similarityThreshold;
500
+ if (threshold !== undefined && score < threshold) {
501
+ continue;
502
+ }
503
+ items.push({
504
+ value: storeDoc.value,
505
+ key: storeDoc.key,
506
+ namespace: storeDoc.prefix.split("."),
507
+ created_at: new Date(storeDoc.created_at / 1000000),
508
+ updated_at: new Date(storeDoc.updated_at / 1000000),
509
+ score,
510
+ });
511
+ }
512
+ }
513
+ return items;
514
+ }
515
+ catch (error) {
516
+ if (error.message?.includes("no such index")) {
517
+ return [];
518
+ }
519
+ throw error;
520
+ }
521
+ }
522
+ // Regular search without vectors
523
+ let queryStr = "*";
524
+ if (prefix) {
525
+ // For prefix search, we need to match all tokens from the namespace prefix
526
+ const tokens = prefix.split(/[.-]/).filter((t) => t.length > 0);
527
+ if (tokens.length > 0) {
528
+ // Match all tokens to ensure we get the right prefix
529
+ queryStr = `@prefix:(${tokens.join(" ")})`;
530
+ }
531
+ }
532
+ try {
533
+ const results = await this.client.ft.search("store", queryStr, {
534
+ LIMIT: { from: offset, size: limit },
535
+ SORTBY: { BY: "created_at", DIRECTION: "DESC" },
536
+ });
537
+ const items = [];
538
+ for (const doc of results.documents) {
539
+ const jsonDoc = doc.value;
540
+ // Apply advanced filter
541
+ if (options?.filter) {
542
+ if (!FilterBuilder.matchesFilter(jsonDoc.value || {}, options.filter)) {
543
+ continue;
544
+ }
545
+ }
546
+ // Refresh TTL if requested
547
+ if (options?.refreshTTL) {
548
+ await this.refreshItemTTL(doc.id);
549
+ }
550
+ items.push({
551
+ value: jsonDoc.value,
552
+ key: jsonDoc.key,
553
+ namespace: jsonDoc.prefix.split("."),
554
+ created_at: new Date(jsonDoc.created_at / 1000000),
555
+ updated_at: new Date(jsonDoc.updated_at / 1000000),
556
+ });
557
+ }
558
+ return items;
559
+ }
560
+ catch (error) {
561
+ if (error.message?.includes("no such index")) {
562
+ return [];
563
+ }
564
+ throw error;
565
+ }
566
+ }
567
+ async listNamespaces(options) {
568
+ let query = "*";
569
+ try {
570
+ const results = await this.client.ft.search("store", query, {
571
+ LIMIT: { from: 0, size: 1000 }, // Get many to deduplicate
572
+ RETURN: ["prefix"],
573
+ });
574
+ // Extract unique namespaces and filter
575
+ const namespaceSet = new Set();
576
+ for (const doc of results.documents) {
577
+ const prefix = doc.value.prefix;
578
+ const parts = prefix.split(".");
579
+ // Apply prefix filter if specified
580
+ if (options?.prefix) {
581
+ // Check if this namespace starts with the specified prefix
582
+ if (parts.length < options.prefix.length)
583
+ continue;
584
+ let matches = true;
585
+ for (let i = 0; i < options.prefix.length; i++) {
586
+ if (parts[i] !== options.prefix[i]) {
587
+ matches = false;
588
+ break;
589
+ }
590
+ }
591
+ if (!matches)
592
+ continue;
593
+ }
594
+ // Apply suffix filter if specified
595
+ if (options?.suffix) {
596
+ // Check if this namespace ends with the specified suffix
597
+ if (parts.length < options.suffix.length)
598
+ continue;
599
+ let matches = true;
600
+ const startIdx = parts.length - options.suffix.length;
601
+ for (let i = 0; i < options.suffix.length; i++) {
602
+ if (parts[startIdx + i] !== options.suffix[i]) {
603
+ matches = false;
604
+ break;
605
+ }
606
+ }
607
+ if (!matches)
608
+ continue;
609
+ }
610
+ // Apply max depth
611
+ if (options?.maxDepth) {
612
+ const truncated = parts.slice(0, options.maxDepth);
613
+ namespaceSet.add(truncated.join("."));
614
+ }
615
+ else {
616
+ namespaceSet.add(prefix);
617
+ }
618
+ }
619
+ // Convert to array of arrays and sort
620
+ let namespaces = Array.from(namespaceSet)
621
+ .map((ns) => ns.split("."))
622
+ .sort((a, b) => a.join(".").localeCompare(b.join(".")));
623
+ // Apply pagination
624
+ if (options?.offset || options?.limit) {
625
+ const offset = options.offset || 0;
626
+ const limit = options.limit || 10;
627
+ namespaces = namespaces.slice(offset, offset + limit);
628
+ }
629
+ return namespaces;
630
+ }
631
+ catch (error) {
632
+ if (error.message?.includes("no such index")) {
633
+ return [];
634
+ }
635
+ throw error;
636
+ }
637
+ }
638
+ async batch(ops) {
639
+ const results = new Array(ops.length).fill(null);
640
+ // Process operations in order to maintain dependencies
641
+ for (let idx = 0; idx < ops.length; idx++) {
642
+ const op = ops[idx];
643
+ // Execute operation based on type guards
644
+ if (isPutOperation(op)) {
645
+ // TypeScript now knows op is PutOperation
646
+ await this.put(op.namespace, op.key, op.value);
647
+ results[idx] = null;
648
+ }
649
+ else if (isSearchOperation(op)) {
650
+ // TypeScript now knows op is SearchOperation
651
+ results[idx] = await this.search(op.namespacePrefix, {
652
+ filter: op.filter,
653
+ query: op.query,
654
+ limit: op.limit,
655
+ offset: op.offset,
656
+ });
657
+ }
658
+ else if (isListNamespacesOperation(op)) {
659
+ // TypeScript now knows op is ListNamespacesOperation
660
+ let prefix = undefined;
661
+ let suffix = undefined;
662
+ if (op.matchConditions) {
663
+ for (const condition of op.matchConditions) {
664
+ if (condition.matchType === "prefix") {
665
+ prefix = condition.path;
666
+ }
667
+ else if (condition.matchType === "suffix") {
668
+ suffix = condition.path;
669
+ }
670
+ }
671
+ }
672
+ results[idx] = await this.listNamespaces({
673
+ prefix,
674
+ suffix,
675
+ maxDepth: op.maxDepth,
676
+ limit: op.limit,
677
+ offset: op.offset,
678
+ });
679
+ }
680
+ else if (isGetOperation(op)) {
681
+ // TypeScript now knows op is GetOperation
682
+ results[idx] = await this.get(op.namespace, op.key);
683
+ }
684
+ else {
685
+ // This should never happen with proper Operation type
686
+ throw new Error(`Unknown operation type: ${JSON.stringify(op)}`);
687
+ }
688
+ }
689
+ return results;
690
+ }
691
+ async close() {
692
+ await this.client.quit();
693
+ }
694
+ /**
695
+ * Get statistics about the store.
696
+ * Returns document counts and other metrics.
697
+ */
698
+ async getStatistics() {
699
+ const stats = {
700
+ totalDocuments: 0,
701
+ namespaceCount: 0,
702
+ };
703
+ try {
704
+ // Get total document count
705
+ const countResult = await this.client.ft.search("store", "*", {
706
+ LIMIT: { from: 0, size: 0 },
707
+ });
708
+ stats.totalDocuments = countResult.total || 0;
709
+ // Get unique namespace count
710
+ const namespaces = await this.listNamespaces({ limit: 1000 });
711
+ stats.namespaceCount = namespaces.length;
712
+ // Get vector document count if index is configured
713
+ if (this.indexConfig) {
714
+ try {
715
+ const vectorResult = await this.client.ft.search("store_vectors", "*", {
716
+ LIMIT: { from: 0, size: 0 },
717
+ });
718
+ stats.vectorDocuments = vectorResult.total || 0;
719
+ }
720
+ catch (error) {
721
+ // Vector index might not exist
722
+ stats.vectorDocuments = 0;
723
+ }
724
+ // Get index info
725
+ try {
726
+ stats.indexInfo = await this.client.ft.info("store");
727
+ }
728
+ catch (error) {
729
+ // Index info might not be available
730
+ }
731
+ }
732
+ }
733
+ catch (error) {
734
+ if (!error.message?.includes("no such index")) {
735
+ throw error;
736
+ }
737
+ }
738
+ return stats;
739
+ }
740
+ validateNamespace(namespace) {
741
+ if (namespace.length === 0) {
742
+ throw new InvalidNamespaceError("Namespace cannot be empty.");
743
+ }
744
+ for (const label of namespace) {
745
+ // Runtime check for JavaScript users (TypeScript already ensures this)
746
+ // This check is for runtime safety when called from JavaScript
747
+ // noinspection SuspiciousTypeOfGuard
748
+ if (typeof label !== "string") {
749
+ throw new InvalidNamespaceError(`Invalid namespace label '${String(label)}' found in ${namespace}. Namespace labels must be strings.`);
750
+ }
751
+ if (label.includes(".")) {
752
+ throw new InvalidNamespaceError(`Invalid namespace label '${label}' found in ${namespace}. Namespace labels cannot contain periods ('.').`);
753
+ }
754
+ if (label === "") {
755
+ throw new InvalidNamespaceError(`Namespace labels cannot be empty strings. Got ${label} in ${namespace}`);
756
+ }
757
+ }
758
+ if (namespace[0] === "langgraph") {
759
+ throw new InvalidNamespaceError(`Root label for namespace cannot be "langgraph". Got: ${namespace}`);
760
+ }
761
+ }
762
+ async refreshItemTTL(docId) {
763
+ if (this.ttlConfig?.defaultTTL) {
764
+ const ttlSeconds = Math.floor(this.ttlConfig.defaultTTL * 60);
765
+ await this.client.expire(docId, ttlSeconds);
766
+ // Also refresh vector key if it exists
767
+ const docUuid = docId.split(":").pop();
768
+ const vectorKey = `${STORE_VECTOR_PREFIX}${REDIS_KEY_SEPARATOR}${docUuid}`;
769
+ try {
770
+ await this.client.expire(vectorKey, ttlSeconds);
771
+ }
772
+ catch (error) {
773
+ // Vector key might not exist
774
+ }
775
+ }
776
+ }
777
+ escapeTagValue(value) {
778
+ // For TAG fields, we need to escape special characters
779
+ // Based on RediSearch documentation, these characters need escaping in TAG fields
780
+ // when used within curly braces: , . < > { } [ ] " ' : ; ! @ # $ % ^ & * ( ) - + = ~ | \ ? /
781
+ // Handle empty string as a special case - use a placeholder
782
+ if (value === "") {
783
+ // Use a special placeholder for empty strings
784
+ return "__EMPTY_STRING__";
785
+ }
786
+ // We'll escape the most common ones that appear in keys
787
+ return value
788
+ .replace(/\\/g, "\\\\")
789
+ .replace(/[-\s,.:<>{}[\]"';!@#$%^&*()+=~|?/]/g, "\\$&");
790
+ }
791
+ /**
792
+ * Calculate similarity score based on the distance metric.
793
+ * Converts raw distance to a normalized similarity score [0,1].
794
+ */
795
+ calculateSimilarityScore(distance) {
796
+ const metric = this.indexConfig?.distanceType || "cosine";
797
+ switch (metric) {
798
+ case "cosine":
799
+ // Cosine distance is in range [0,2], convert to similarity [0,1]
800
+ return Math.max(0, 1 - distance / 2);
801
+ case "l2":
802
+ // L2 (Euclidean) distance, use exponential decay
803
+ // Similarity = e^(-distance)
804
+ return Math.exp(-distance);
805
+ case "ip":
806
+ // Inner product can be negative, use sigmoid function
807
+ // Similarity = 1 / (1 + e^(-distance))
808
+ return 1 / (1 + Math.exp(-distance));
809
+ default:
810
+ // Default to cosine similarity
811
+ return Math.max(0, 1 - distance / 2);
812
+ }
813
+ }
814
+ }
815
+ // Export FilterBuilder for testing purposes
816
+ export { FilterBuilder };
817
+ //# sourceMappingURL=store.js.map