From 956bf4f88efff376c1c71e08ab5f0030c198e091 Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 14 Aug 2026 16:56:27 +0530 Subject: [PATCH] fix(ts-oss): return snake_case entity ids from the redis and valkey stores (#6902) --- mem0-ts/src/oss/src/memory/index.ts | 53 ++++--------- mem0-ts/src/oss/src/utils/casing.ts | 22 ++++++ mem0-ts/src/oss/src/vector_stores/redis.ts | 19 +---- mem0-ts/src/oss/src/vector_stores/valkey.ts | 13 +-- mem0-ts/src/oss/tests/memory.add.test.ts | 10 +-- mem0-ts/src/oss/tests/memory.crud.test.ts | 10 +++ mem0-ts/src/oss/tests/redis.unit.test.ts | 84 ++++++++++++++++++++ mem0-ts/src/oss/tests/valkey.test.ts | 87 ++++++++++++++++++++- 8 files changed, 225 insertions(+), 73 deletions(-) create mode 100644 mem0-ts/src/oss/src/utils/casing.ts diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 58634cb55..1efbc3e45 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -105,6 +105,18 @@ const ENTITY_PARAMS = [ // actor_id has no camelCase alias. const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"]; +const PAYLOAD_METADATA_EXCLUDED_KEYS = new Set([ + "user_id", + "agent_id", + "run_id", + "hash", + "data", + "createdAt", + "updatedAt", + "textLemmatized", + "attributedTo", +]); + // Caller metadata must not overwrite or inject an identity scope (#6342 / #6367 / #6371). function stripIdentityKeys( metadata: Record = {}, @@ -1298,20 +1310,8 @@ export class Memory { metadata: {}, }; - // Add additional metadata - const excludedKeys = new Set([ - "userId", - "agentId", - "runId", - "hash", - "data", - "createdAt", - "updatedAt", - "textLemmatized", - "attributedTo", - ]); for (const [key, value] of Object.entries(memory.payload)) { - if (!excludedKeys.has(key)) { + if (!PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) { memoryItem.metadata![key] = value; } } @@ -1567,18 +1567,6 @@ export class Memory { ); // Step 9: Format results - const excludedKeys = new Set([ - "user_id", - "agent_id", - "run_id", - "hash", - "data", - "createdAt", - "updatedAt", - "textLemmatized", - "attributedTo", - ]); - const results = scoredResults .filter((scored) => scored.payload?.data) .map((scored) => { @@ -1591,7 +1579,7 @@ export class Memory { updatedAt: payload.updatedAt, score: scored.score, metadata: Object.entries(payload) - .filter(([key]) => !excludedKeys.has(key)) + .filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), ...(payload.user_id && { user_id: payload.user_id }), ...(payload.agent_id && { agent_id: payload.agent_id }), @@ -1897,17 +1885,6 @@ export class Memory { ? memories : memories.filter((mem) => !payloadIsExpired(mem.payload)); - const excludedKeys = new Set([ - "user_id", - "agent_id", - "run_id", - "hash", - "data", - "createdAt", - "updatedAt", - "textLemmatized", - "attributedTo", - ]); const results = visibleMemories.slice(0, topK).map((mem) => ({ id: mem.id, memory: mem.payload.data, @@ -1915,7 +1892,7 @@ export class Memory { createdAt: mem.payload.createdAt, updatedAt: mem.payload.updatedAt, metadata: Object.entries(mem.payload) - .filter(([key]) => !excludedKeys.has(key)) + .filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), ...(mem.payload.user_id && { user_id: mem.payload.user_id }), ...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }), diff --git a/mem0-ts/src/oss/src/utils/casing.ts b/mem0-ts/src/oss/src/utils/casing.ts new file mode 100644 index 000000000..c9a5b0ad3 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/casing.ts @@ -0,0 +1,22 @@ +function toCamelCase(obj: Record): Record { + if (typeof obj !== "object" || obj === null) return obj; + + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), + value, + ]), + ); +} + +export function toCamelCasePreservingIds( + payload: Record, +): Record { + const { agent_id, run_id, user_id, ...rest } = payload; + return { + ...toCamelCase(rest), + ...(agent_id !== undefined && { agent_id }), + ...(run_id !== undefined && { run_id }), + ...(user_id !== undefined && { user_id }), + }; +} diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index 8c274f7d5..ecb326ac2 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -8,6 +8,7 @@ import type { import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; import { loadPeer } from "../utils/load_peer"; +import { toCamelCasePreservingIds } from "../utils/casing"; /** * Escape RediSearch TAG filter special characters. Any punctuation in the @@ -145,18 +146,6 @@ export function buildRedisFilterExpr(filters?: SearchFilters): string { return conditions.length > 0 ? conditions.join(" ") : "*"; } -// Utility function to convert object keys to camelCase -function toCamelCase(obj: Record): Record { - if (typeof obj !== "object" || obj === null) return obj; - - return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [ - key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), - value, - ]), - ); -} - export class RedisDB implements VectorStore { private client!: RedisClientType< RedisDefaultModules & RedisModules & RedisFunctions & RedisScripts @@ -468,7 +457,7 @@ export class RedisDB implements VectorStore { return { id: doc.value.memory_id, - payload: toCamelCase(resultPayload), + payload: toCamelCasePreservingIds(resultPayload), score: Math.max(0, 1 - (Number(doc.value.__vector_score) ?? 0)), }; }); @@ -573,7 +562,7 @@ export class RedisDB implements VectorStore { return { id: vectorId, - payload: toCamelCase(payload), + payload: toCamelCasePreservingIds(payload), }; } catch (error) { console.error("Error getting vector:", error); @@ -680,7 +669,7 @@ export class RedisDB implements VectorStore { const items = results.documents.map((doc) => ({ id: doc.value.memory_id, - payload: toCamelCase({ + payload: toCamelCasePreservingIds({ hash: doc.value.hash, data: doc.value.memory, created_at: new Date(parseInt(doc.value.created_at)).toISOString(), diff --git a/mem0-ts/src/oss/src/vector_stores/valkey.ts b/mem0-ts/src/oss/src/vector_stores/valkey.ts index dc549e99f..cd4f85920 100644 --- a/mem0-ts/src/oss/src/vector_stores/valkey.ts +++ b/mem0-ts/src/oss/src/vector_stores/valkey.ts @@ -2,6 +2,7 @@ import { VectorStore } from "./base"; import { SearchFilters, VectorStoreResult } from "../types"; import { ValkeyConfig } from "../types/valkey"; import { loadPeer } from "../utils/load_peer"; +import { toCamelCasePreservingIds } from "../utils/casing"; interface ValkeyClient { call: (...args: (string | number | Buffer)[]) => Promise; @@ -45,16 +46,6 @@ function toSnakeCase(obj: Record): Record { ); } -function toCamelCase(obj: Record): Record { - if (typeof obj !== "object" || obj === null) return obj; - return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [ - key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), - value, - ]), - ); -} - interface ValkeySearchDoc { memory_id?: string; hash?: string; @@ -388,7 +379,7 @@ export class ValkeyDB implements VectorStore { return { id: doc.memory_id ?? "", - payload: toCamelCase(resultPayload), + payload: toCamelCasePreservingIds(resultPayload), score, }; } diff --git a/mem0-ts/src/oss/tests/memory.add.test.ts b/mem0-ts/src/oss/tests/memory.add.test.ts index 059a0ef12..94fee5846 100644 --- a/mem0-ts/src/oss/tests/memory.add.test.ts +++ b/mem0-ts/src/oss/tests/memory.add.test.ts @@ -269,9 +269,7 @@ describe("Memory - add()", () => { "runId", "actor_id", ]) { - if (key !== canonicalKey) { - expect(stored!.metadata).not.toHaveProperty(key); - } + expect(stored!.metadata).not.toHaveProperty(key); } }, ); @@ -307,10 +305,7 @@ describe("Memory - add()", () => { } } expect(stored!.metadata).toEqual( - expect.objectContaining({ - [filterKey]: filterValue, - ordinary: "preserved", - }), + expect.objectContaining({ ordinary: "preserved" }), ); for (const key of [ "user_id", @@ -321,7 +316,6 @@ describe("Memory - add()", () => { "runId", "actor_id", ]) { - if (key === filterKey) continue; expect(stored!.metadata).not.toHaveProperty(key); } }, diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index f3f5f5799..6e45eaa01 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -128,6 +128,16 @@ describe("Memory - get()", () => { expect(item!.createdAt).toBeDefined(); expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date"); }); + + test("does not duplicate the entity id inside metadata", async () => { + const addResult: SearchResult = await memory.add("Metadata leak test", { + userId, + }); + const item: any = await memory.get(addResult.results[0].id); + expect(item.user_id).toBe(userId); + expect(item.metadata).not.toHaveProperty("user_id"); + expect(item.metadata).not.toHaveProperty("userId"); + }); }); // ─── update() ──────────────────────────────────────────── diff --git a/mem0-ts/src/oss/tests/redis.unit.test.ts b/mem0-ts/src/oss/tests/redis.unit.test.ts index cf3d47174..03343b501 100644 --- a/mem0-ts/src/oss/tests/redis.unit.test.ts +++ b/mem0-ts/src/oss/tests/redis.unit.test.ts @@ -125,4 +125,88 @@ describe("RedisDB – entity payload handling", () => { expect(entry.created_at).toBeGreaterThan(0); expect(Number.isNaN(entry.created_at)).toBe(false); }); + + test("search returns entity ids as snake_case", async () => { + mockClient.ft.search.mockResolvedValue({ + total: 1, + documents: [ + { + id: "mem0:test:mem-1", + value: { + memory_id: "mem-1", + hash: "h1", + memory: "likes coffee", + created_at: "1700000000000", + agent_id: "agent-1", + run_id: "run-1", + user_id: "user-1", + metadata: "{}", + __vector_score: 0.1, + }, + }, + ], + }); + + const results = await store.search([0.1, 0.2, 0.3, 0.4], 5); + + expect(results[0].payload.user_id).toBe("user-1"); + expect(results[0].payload.agent_id).toBe("agent-1"); + expect(results[0].payload.run_id).toBe("run-1"); + expect(results[0].payload).not.toHaveProperty("userId"); + expect(results[0].payload).not.toHaveProperty("agentId"); + expect(results[0].payload).not.toHaveProperty("runId"); + }); + + test("get returns entity ids as snake_case", async () => { + mockClient.exists.mockResolvedValue(1); + mockClient.hGetAll.mockResolvedValue({ + memory_id: "mem-1", + hash: "h1", + memory: "likes coffee", + created_at: "1700000000000", + agent_id: "agent-1", + run_id: "run-1", + user_id: "user-1", + metadata: "{}", + }); + + const result = await store.get("mem-1"); + + expect(result?.payload.user_id).toBe("user-1"); + expect(result?.payload.agent_id).toBe("agent-1"); + expect(result?.payload.run_id).toBe("run-1"); + expect(result?.payload).not.toHaveProperty("userId"); + expect(result?.payload).not.toHaveProperty("agentId"); + expect(result?.payload).not.toHaveProperty("runId"); + }); + + test("list returns entity ids as snake_case", async () => { + mockClient.ft.search.mockResolvedValue({ + total: 1, + documents: [ + { + id: "mem0:test:mem-1", + value: { + memory_id: "mem-1", + hash: "h1", + memory: "likes coffee", + created_at: "1700000000000", + agent_id: "agent-1", + run_id: "run-1", + user_id: "user-1", + metadata: "{}", + }, + }, + ], + }); + + const [items] = await store.list(); + + expect(items[0].payload.user_id).toBe("user-1"); + expect(items[0].payload.agent_id).toBe("agent-1"); + expect(items[0].payload.run_id).toBe("run-1"); + expect(items[0].payload).not.toHaveProperty("userId"); + expect(items[0].payload).not.toHaveProperty("agentId"); + expect(items[0].payload).not.toHaveProperty("runId"); + }); }); diff --git a/mem0-ts/src/oss/tests/valkey.test.ts b/mem0-ts/src/oss/tests/valkey.test.ts index fe8b5530c..c40471122 100644 --- a/mem0-ts/src/oss/tests/valkey.test.ts +++ b/mem0-ts/src/oss/tests/valkey.test.ts @@ -166,7 +166,7 @@ describe("Valkey – mocked iovalkey client", () => { const result = await store.get("mem-1"); expect(result?.id).toBe("mem-1"); expect(result?.payload.data).toBe("hello valkey"); - expect(result?.payload.userId).toBe("alice"); + expect(result?.payload.user_id).toBe("alice"); // created_at is persisted as unix seconds and rendered back to its ISO instant. expect(result?.payload.createdAt).toBe("2024-01-01T00:00:00.000Z"); }); @@ -244,6 +244,91 @@ describe("Valkey – mocked iovalkey client", () => { expect(searchCall[2]).toContain("@user_id:{a\\|b\\ c}"); }); + it("get returns entity ids as snake_case", async () => { + const store = new ValkeyDB({ + collectionName: "test", + embeddingModelDims: 4, + valkeyUrl: "valkey://localhost:6379", + }); + await store.initialize(); + + await store.insert( + [[0.1, 0.2, 0.3, 0.4]], + ["mem-ids"], + [ + { + data: "likes tea", + user_id: "user-1", + agent_id: "agent-1", + run_id: "run-1", + }, + ], + ); + + const result = await store.get("mem-ids"); + expect(result?.payload.user_id).toBe("user-1"); + expect(result?.payload.agent_id).toBe("agent-1"); + expect(result?.payload.run_id).toBe("run-1"); + expect(result?.payload).not.toHaveProperty("userId"); + expect(result?.payload).not.toHaveProperty("agentId"); + expect(result?.payload).not.toHaveProperty("runId"); + }); + + it("search and list return entity ids as snake_case", async () => { + const store = new ValkeyDB({ + collectionName: "test", + embeddingModelDims: 4, + valkeyUrl: "valkey://localhost:6379", + }); + await store.initialize(); + + const iovalkey = require("iovalkey"); + const mockClient = iovalkey.__mockClient; + mockClient.call.mockImplementation(async (...args: any[]) => { + if (args[0] === "FT.SEARCH") { + return [ + 1, + "mem0:test:mem-search", + [ + "memory_id", + "mem-search", + "hash", + "h1", + "memory", + "likes tea", + "created_at", + "1700000000", + "agent_id", + "agent-1", + "run_id", + "run-1", + "user_id", + "user-1", + "metadata", + "{}", + "vector_score", + "0.1", + ], + ]; + } + return "OK"; + }); + + const results = await store.search([0.1, 0.2, 0.3, 0.4], 5); + expect(results[0].payload.user_id).toBe("user-1"); + expect(results[0].payload.agent_id).toBe("agent-1"); + expect(results[0].payload.run_id).toBe("run-1"); + expect(results[0].payload).not.toHaveProperty("userId"); + expect(results[0].payload).not.toHaveProperty("agentId"); + expect(results[0].payload).not.toHaveProperty("runId"); + + const [listed] = await store.list(); + expect(listed[0].payload.user_id).toBe("user-1"); + expect(listed[0].payload.agent_id).toBe("agent-1"); + expect(listed[0].payload.run_id).toBe("run-1"); + expect(listed[0].payload).not.toHaveProperty("userId"); + }); + it("does not raise an unhandled rejection when initialization fails", async () => { const iovalkey = require("iovalkey"); iovalkey.__mockClient.call.mockImplementationOnce(async () => {