From 42a3b4043cb472a6b0c5b7091400f9ca9e046e72 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Thu, 18 Jun 2026 11:35:20 +0530 Subject: [PATCH] fix(ts-sdk): preserve user metadata keys across the case-conversion round-trip (#5515) --- mem0-ts/src/client/tests/utils.test.ts | 102 +++++++++++++++++++++++++ mem0-ts/src/client/utils.ts | 23 +++++- 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 mem0-ts/src/client/tests/utils.test.ts diff --git a/mem0-ts/src/client/tests/utils.test.ts b/mem0-ts/src/client/tests/utils.test.ts new file mode 100644 index 000000000..9007a6b87 --- /dev/null +++ b/mem0-ts/src/client/tests/utils.test.ts @@ -0,0 +1,102 @@ +import { camelToSnakeKeys, snakeToCamelKeys } from "../utils"; + +describe("camelToSnakeKeys / snakeToCamelKeys", () => { + it("converts SDK-defined keys between camelCase and snake_case", () => { + expect(camelToSnakeKeys({ userId: "u1", agentId: "a1" })).toEqual({ + user_id: "u1", + agent_id: "a1", + }); + expect(snakeToCamelKeys({ user_id: "u1", agent_id: "a1" })).toEqual({ + userId: "u1", + agentId: "a1", + }); + }); + + it("preserves logical operator keys (OR/AND/NOT)", () => { + expect(camelToSnakeKeys({ OR: [{ userId: "u1" }] })).toEqual({ + OR: [{ user_id: "u1" }], + }); + }); + + describe("user-controlled metadata blob (issue #5055)", () => { + it("does not camelize snake_case keys inside metadata on read", () => { + const apiResponse = { + id: "mem-1", + user_id: "u1", + metadata: { message_id: "x", some_custom_key: "y" }, + }; + + expect(snakeToCamelKeys(apiResponse)).toEqual({ + id: "mem-1", + userId: "u1", + metadata: { message_id: "x", some_custom_key: "y" }, + }); + }); + + it("does not snake_case camelCase keys inside metadata on write", () => { + const payload = { + userId: "u1", + metadata: { messageId: "x", someCustomKey: "y" }, + }; + + expect(camelToSnakeKeys(payload)).toEqual({ + user_id: "u1", + metadata: { messageId: "x", someCustomKey: "y" }, + }); + }); + + it("round-trips arbitrary metadata keys losslessly", () => { + const metadata = { + message_id: "abc", + camelKey: 1, + nested: { deep_snake: true, deepCamel: false }, + arr: [{ inner_key: 1 }], + }; + + const roundTripped = snakeToCamelKeys( + camelToSnakeKeys({ userId: "u1", metadata }), + ); + + expect(roundTripped.metadata).toEqual(metadata); + }); + + it("preserves metadata nested inside an array of results", () => { + const apiResponse = { + results: [ + { id: "1", metadata: { message_id: "x" } }, + { id: "2", metadata: { another_key: "z" } }, + ], + }; + + expect(snakeToCamelKeys(apiResponse)).toEqual({ + results: [ + { id: "1", metadata: { message_id: "x" } }, + { id: "2", metadata: { another_key: "z" } }, + ], + }); + }); + }); + + describe("user-controlled structuredDataSchema blob (issue #5055)", () => { + it("converts the outer key but leaves user field names on write", () => { + expect( + camelToSnakeKeys({ + structuredDataSchema: { firstName: "string", lastName: "string" }, + }), + ).toEqual({ + // outer SDK key is snake_cased, user-defined field names are not + structured_data_schema: { firstName: "string", lastName: "string" }, + }); + }); + + it("converts the outer key but leaves user field names on read", () => { + expect( + snakeToCamelKeys({ + structured_data_schema: { first_name: "string", last_name: "string" }, + }), + ).toEqual({ + structuredDataSchema: { first_name: "string", last_name: "string" }, + }); + }); + }); +}); diff --git a/mem0-ts/src/client/utils.ts b/mem0-ts/src/client/utils.ts index 5580b0585..817dd02cf 100644 --- a/mem0-ts/src/client/utils.ts +++ b/mem0-ts/src/client/utils.ts @@ -14,9 +14,27 @@ function snakeToCamel(str: string): string { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } +/** + * Keys whose values are user-controlled, opaque blobs. Their nested keys must + * be passed through verbatim — converting them would silently rewrite the + * user's own keys and break round-trips (see issue #5055). + * + * The check runs against the source key, so a multi-word key must be listed in + * both casings to be covered in both directions: the camelCase form for the + * outbound `camelToSnakeKeys` path and the snake_case form for the inbound + * `snakeToCamelKeys` path. `metadata` is spelled identically in both, so one + * entry suffices; `structuredDataSchema` needs both. + */ +const OPAQUE_VALUE_KEYS = new Set([ + "metadata", + "structuredDataSchema", + "structured_data_schema", +]); + /** * Recursively converts all keys of an object from camelCase to snake_case. * Used for converting user-facing camelCase params to API snake_case payloads. + * Values under {@link OPAQUE_VALUE_KEYS} (e.g. `metadata`) are left untouched. */ export function camelToSnakeKeys(obj: any): any { if (obj === null || obj === undefined || typeof obj !== "object") return obj; @@ -26,7 +44,7 @@ export function camelToSnakeKeys(obj: any): any { return Object.fromEntries( Object.entries(obj).map(([key, value]) => [ camelToSnake(key), - camelToSnakeKeys(value), + OPAQUE_VALUE_KEYS.has(key) ? value : camelToSnakeKeys(value), ]), ); } @@ -34,6 +52,7 @@ export function camelToSnakeKeys(obj: any): any { /** * Recursively converts all keys of an object from snake_case to camelCase. * Used for converting API snake_case responses to user-facing camelCase. + * Values under {@link OPAQUE_VALUE_KEYS} (e.g. `metadata`) are left untouched. */ export function snakeToCamelKeys(obj: any): any { if (obj === null || obj === undefined || typeof obj !== "object") return obj; @@ -43,7 +62,7 @@ export function snakeToCamelKeys(obj: any): any { return Object.fromEntries( Object.entries(obj).map(([key, value]) => [ snakeToCamel(key), - snakeToCamelKeys(value), + OPAQUE_VALUE_KEYS.has(key) ? value : snakeToCamelKeys(value), ]), ); }