Nodejs Firebase Transaction - Maximum call stack size exceeded - javascript

I have a cloud function that uses a transaction to updates the players in a game. When the /players is null, i am trying to return a Map, but i get "Maximum call stack size exceeded".
Here is my cloud function:
export const addUserToGame = functions.https.onCall((data, context) => {
// Expected inputs - game_id(from data) and UID(from context)
if (context.auth == null) {
return {
"status": 403,
"message": "You are not authorized to access this feature"
};
}
const uid = context.auth.uid;
const game_id = data.game_id;
let gameIDRef = gamesRef.child(game_id);
return gameIDRef.once("value", function (snapshot) {
let players: Map<String, Number> = snapshot.child("players").val();
let max_players: Number = snapshot.child("max_players").val();
if (players != null && players.has(uid)) {
return {
"status": 403,
"message": "Player already in the game"
}
} else if (players != null && players.size >= max_players) {
return {
"status": 403,
"message": "Game is already full"
}
} else {
let playersNodeRef = gamesRef.child(game_id).child("players");
return playersNodeRef.transaction(t => {
if (t === null) {
return new Map<String, Number>().set(uid, 1);//trying to set a map with the player data, when the /players is null
} else {
let playersData: Map<String, Number> = t;
if (playersData.size >= max_players) { // rechecking
return;
} else {
playersData.set(uid, 1);
return playersData;
}
}
}).then(result => {
if (result.committed) { // if true there is a commit and the transaction went through
return {
"status": 200,
"message": "User added to game successfully"
}
} else {
return {
"status": 403,
"message": "Unable to add user at this time. Please try again"
}
}
}).catch(error => {
return {
"status": 403,
"message": error
}
});
}
});
});
Here is the stack trace:
addUserToGame
Function execution took 1423 ms, finished with status code: 500
at /workspace/node_modules/lodash/lodash.js:13401:38
at encode (/workspace/node_modules/firebase-functions/lib/providers/https.js:179:18)
at Function.mapValues (/workspace/node_modules/lodash/lodash.js:13400:7)
at baseForOwn (/workspace/node_modules/lodash/lodash.js:2990:24)
at /workspace/node_modules/lodash/lodash.js:4900:21
at keys (/workspace/node_modules/lodash/lodash.js:13307:14)
at isArrayLike (/workspace/node_modules/lodash/lodash.js:11333:58)
at isFunction (/workspace/node_modules/lodash/lodash.js:11653:17)
at baseGetTag (/workspace/node_modules/lodash/lodash.js:3067:51)
at Object (<anonymous>)
Unhandled error RangeError: Maximum call stack size exceeded
How can i set a map to /players node?
There were more than one issues with the code and as #Renaud pointed out, i have changed the 'once' callback to use promises version.
Also i had issues sending back data in the transaction. The data that i sent was using complex JS objects like Map(), but after some struggle (with the syntax) i changed it to a normal JS object (json like structure). Please see my changes below:
if (t === null) {
return [{ [uid]: { "status": 1 } }]; // if null, create an array and add an object to it
} else {
let playersData = t;
if (playersData.size >= max_players) { // rechecking
return;
} else { // if not null create an object and add to the existing array
playersData.push({
[uid]: {
"status": 1
}
});
return playersData;
}
}

Your problem most probably comes from the fact you are returning a complex JavaScript object, see https://stackoverflow.com/a/52569728/3371862.
In addition, note that you should use the promise version of the once() method, since, in a Callable Cloud Function you must return a promise that resolves with the data object to send back to the client.
Instead of doing
return gameIDRef.once("value", function (snapshot) {...});
do
return gameIDRef.once("value").then(snapshot => {...});
With this you will be able to correctly build the promise chain to be returned. Also, when dealing with the different cases around the players value, instead of returning JavaScript objects that will be handle in the .then((result) => {...}) block (which is not necessary and not really logical), throw errors that will be handled in the catch() block.
Something along the following lines:
export const addUserToGame = functions.https.onCall((data, context) => {
// Expected inputs - game_id(from data) and UID(from context)
if (context.auth == null) {
return {
status: 403,
message: 'You are not authorized to access this feature',
};
// IMHO better to do throw new functions.https.HttpsError('...', ...);
}
const uid = context.auth.uid;
const game_id = data.game_id;
let gameIDRef = gamesRef.child(game_id);
return gameIDRef
.once('value')
.then((snapshot) => {
let players: Map<String, Number> = snapshot.child('players').val();
let max_players: Number = snapshot.child('max_players').val();
if (players != null && players.has(uid)) {
throw new Error('Player already in the game');
} else if (players != null && players.size >= max_players) {
throw new Error('Game is already full');
} else {
let playersNodeRef = gamesRef.child(game_id).child('players');
return playersNodeRef.transaction((t) => {
if (t === null) {
return new Map<String, Number>().set(uid, 1); //trying to set a map with the player data, when the /players is null
} else {
let playersData: Map<String, Number> = t;
if (playersData.size >= max_players) {
// rechecking
return;
} else {
playersData.set(uid, 1);
return playersData;
}
}
});
}
})
.then((result) => {
if (result.committed) {
// if true there is a commit and the transaction went through
return {
status: 200,
message: 'User added to game successfully',
};
} else {
// probably throw an error here
return {
status: 403,
message: 'Unable to add user at this time. Please try again',
};
}
})
.catch((error) => {
if (error.message === 'Player already in the game') {
throw new functions.https.HttpsError('...', error.message);
} else if (error.message === 'Game is already full') {
throw new functions.https.HttpsError('...', error.message);
} else {
throw new functions.https.HttpsError('internal', error.message);
}
});
});
See here for more details on how to handle errors in a Callable Cloud Function.

Related

Item doesn't want to be pushed into an array

I am developing a discord bot that allows you to mine for resources, but I can't get the code to work. It's logging the actual items, but it just doesn't add the items to the array, which is why I can't map it into a string.
Code:
module.exports.addItem = async (client, user, item, amount, interaction) => {
if(!client instanceof Client) throw new Error("Client is not an instance of Discord.js Client");
if(!user instanceof User) throw new Error("User is not an instance of Discord.js User");
if(!item) throw new Error("Item is not defined");
if(!amount) throw new Error("Amount is not defined");
if(typeof amount !== "number") throw new Error("Amount is not a number");
if(!interaction) throw new Error("Interaction is not defined");
if(!interaction instanceof Interaction) throw new Error("Interaction is not an instance of Discord.js Interaction");
const userFound = await client.database.user.findOne({ userID: user.id });
if(!userFound) return interaction.followUp({ content: `You haven't started your journey yet!` });
const itemFound = userFound.inventory.find(i => i.name === item.name);
if(!itemFound) {
await client.database.user.findOneAndUpdate(
{
userID: user.id,
}, {
userID: user.id,
$push: {
inventory: {
name: item.name,
amount,
category: item.category
}
}
} , {
upsert: true,
});
} else {
await client.database.user.findOneAndUpdate(
{
userID: user.id,
}, {
userID: user.id,
$pull: {
inventory: {
name: item.name
}
}
} , {
upsert: true,
});
await client.database.user.findOneAndUpdate(
{
userID: user.id,
}, {
userID: user.id,
$push: {
inventory: {
name: item.name,
amount: item.amount + amount,
category: item.category
}
}
} , {
upsert: true,
});
}
return { name: item.name, amount, category: item.category };
}
module.exports.dropItem = async (client, user, category, interaction) => {
if(!client instanceof Client) throw new Error("Client is not an instance of Discord.js Client");
if(!user instanceof User) throw new Error("User is not an instance of Discord.js User");
if(!category) throw new Error("Category is not defined");
if(typeof category !== "string") throw new Error("Category is not a string");
let droppedItems = [];
for (const item of client.items) {
for(let i = 0; i < client.items.length; i++) {
if(!client.items[i].category === category) return;
}
const dropAmount = Math.floor((Math.random() * 3) + 1);
const dropChance = Math.floor(Math.random() * 100);
if(dropChance > item.dropChance) return;
const itemDropped = await this.addItem(client, user, item, dropAmount, interaction)
console.log(itemDropped); // This logs the item correctly
droppedItems.push(itemDropped);
}
return droppedItems; // This always returns "undefined"
}
Expected result: An array with objects, each object containing a name, amount and category value;
Actual result: Console-logging the array returns "undefined"
I have tried awaiting the push, making the array a constant and more small things like that.
Could it be because the array isn't in the loop?
Since you are awaiting a call within the for loop, you will need to await the entire loop as well. You can do this with await Promise.all(<<ARRAY OF PROMISES>>).
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
So you'd do something like
let droppedItems = [];
await Promise.all(client.items.map(async (item) => {
for(let i = 0; i < client.items.length; i++) {
if(!client.items[i].category === category) return;
}
const dropAmount = Math.floor((Math.random() * 3) + 1);
const dropChance = Math.floor(Math.random() * 100);
if(dropChance > item.dropChance) return;
const itemDropped = await this.addItem(client, user, item, dropAmount, interaction)
console.log(itemDropped); // This logs the item correctly
droppedItems.push(itemDropped);
}));
The problem is probably that you have several return statements in your dropItem function that are returning undefined.
For example if(dropChance > item.dropChance) return;
A simple function to illustrate this is:
const doSomething = () => {
const arr = [1, 2, 3]
for (const num of arr) {
if (num !== 10) return
}
return 'done'
}
You are saying that you would expect the above function to return "done", but it will actually return undefined.
I think maybe you want to use break instead of return, but not sure exactly what behaviour you are expecting.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/break

onRequest vs onCall returning null

Please help me figure out the difference in return behaviour between the onCall and onRequest google functions below.
onCall, the problem: returns null on all returns, except at the first return (as commented below). The db entries and rest of the code works fine. Just no returns problem.
onRequest, returns perfectly fine on every return. The db entries and rest of the code also works fine.
Both as you will see compare the same, but I just can't seem to get it to work at all. Any advice on how to get my returns to work for the onCall (and structure it better) would be much appreciated.
I am keen on sticking with async await (as opposed to a promise). Using Node.js 12. I am calling the onCall in Flutter, don't know if that is relevant for the question.
The onCall:
exports.applyUserDiscount = functions.https.onCall(async (data, context) => {
if (!context.auth) return {message: "Authentication Required!", code: 401};
const uid = context.auth.uid;
const discountCode = data["discountCode"];
const cartTotal = data["cartTotal"];
try {
return await db.collection("discountCodes").where("itemID", "==", discountCode).limit(1).get()
.then(async (snapshot) => {
if (snapshot.empty) {
return "doesNotExist"; // The only return that works.
} else { // Everything else from here onwards returns null.
snapshot.forEach(async (doc) => {
if (doc.data().redeemed == true) {
return "codeUsed";
} else {
const newCartTotal = cartTotal - doc.data().discountAmount;
if (newCartTotal < 0) {
return "lessThanTotal";
} else {
doc.ref.update({
redeemed: true,
uid: uid,
redeemDate: fireDateTimeNow,
});
await db.collection("userdata").doc(uid).set({
cartDiscount: admin.firestore.FieldValue.increment(-doc.data().discountAmount),
}, {merge: true});
return doc.data().discountAmount.toString();
}
}
});
}
});
} catch (error) {
console.log("Error:" + error);
return "error";
}
});
The onRequest:
exports.applyUserDiscount = functions.https.onRequest(async (req, res) => {
const uid = req.body.uid;
const discountCode = req.body.discountCode;
const cartTotal = req.body.cartTotal;
try {
return await db.collection("discountCodes").where("itemID", "==", discountCode).limit(1).get()
.then(async (snapshot) => {
if (snapshot.isempty) {
res.send("doesNotExist");
} else {
snapshot.forEach(async (doc) => {
if (doc.data().redeemed == true) {
res.send("codeUsed");
} else {
const newCartTotal = cartTotal - doc.data().discountAmount;
if (newCartTotal < 0) {
res.send("lessThanTotal");
} else {
doc.ref.update({
redeemed: true,
uid: uid,
redeemDate: fireDateTimeNow,
});
await db.collection("userdata").doc(uid).set({
cartDiscount: admin.firestore.FieldValue.increment(-doc.data().discountAmount),
}, {merge: true});
res.send(doc.data().discountAmount.toString());
}
}
});
}
});
} catch (error) {
console.log(error);
res.send("error");
}
});
There are several points to be noted when looking at your code(s):
You should not use async/await within a forEach loop. The problem is that the callback passed to forEach() is not being awaited, see more explanations here or here. HOWEVER, in your case you don't need to loop over the QuerySnapshot since it contains only one doc. You can use the docs property which return an array of all the documents in the QuerySnapshot and take the first (and unique) element.
You mix-up then() with async/await, which is not recommended.
I would advise to throw exceptions for the "error" cases, like doesNotExist, codeUsed or lessThanTotal but it's up to you to choose. The fact that, for example, the lessThanTotal case is an error or a standard business case is debatable... So if you prefer to send a "text" response, I would advise to encapsulate this response in a Object with one property: in your front-end the response will always have the same format.
So, the following should do the trick. Note that I send back on object with a response element, including for the cases that could be considered as errors. As said above you could throw an exception in these cases.
exports.applyUserDiscount = functions.https.onCall(async (data, context) => {
if (!context.auth) ... //See https://firebase.google.com/docs/functions/callable#handle_errors
const uid = context.auth.uid;
const discountCode = data["discountCode"];
const cartTotal = data["cartTotal"];
try {
const snapshot = await db.collection("discountCodes").where("itemID", "==", discountCode).limit(1).get();
if (snapshot.empty) {
//See https://firebase.google.com/docs/functions/callable#handle_errors
} else {
const uniqueDoc = snapshot.docs[0];
if (uniqueDoc.data().redeemed == true) {
return { response: "codeUsed" };
} else {
const newCartTotal = cartTotal - uniqueDoc.data().discountAmount;
if (newCartTotal < 0) {
return { response: "lessThanTotal" };
} else {
await uniqueDoc.ref.update({ // See await here!!
redeemed: true,
uid: uid,
redeemDate: fireDateTimeNow,
});
await db.collection("userdata").doc(uid).set({
cartDiscount: admin.firestore.FieldValue.increment(-uniqueDoc.data().discountAmount),
}, { merge: true });
return {
response: uniqueDoc.data().discountAmount.toString()
}
}
}
}
} catch (error) {
console.log("Error:" + error);
return "error";
}
});

Firestore query function result is void

Function validateBusinessId results in void variable businessExists. I am trying to assign different database paths depending on whether the business exists in the database or if the uploaded file is a master file. Function validateBusiness produces void result. Not sure how to fix it. Where did I go wrong?
async function validateBusinessId(businessId: string) {
db.collection('business').doc(businessId).get()
.then((docSnapshot) => {
if (docSnapshot.exists) {
return true
} else {
return false
}
})
}
async function getDatabase(fileName: string, businessId: string) {
const businessExists = await validateBusinessId(businessId)
if ((fileName) && (fileName?.includes('masterFile'))) {
console.log('A new Master database file has been detected.')
return db.collection('products')
} else if ((businessId) && (businessExists)) {
// If the business exists, return business path
console.log('A new database file has been detected for business: ' + businessId)
return db.collection('business').doc(businessId).collection('products')
} else {
return null
}
}
Help is highly appreciated!
Ok, so I found that I handled the promise of a query result incorrectly. Here's the code that worked for me.
async function validateBusinessId(businessId: string) {
const databaseDocumentSnapshot = await db.collection('business').doc(businessId).get()
if (databaseDocumentSnapshot.exists) {
return true
} else {
return false
}
}
async function getDatabase(fileName: string, businessId: string) {
const businessExists = await validateBusinessId(businessId)
if ((fileName) && (fileName?.includes('masterFile'))) {
console.log('A new Master database file has been detected.')
return db.collection('products')
} else if ((businessId) && (businessExists)) {
return db.collection('business').doc(businessId).collection('products')
} else {
console.log('No business with such ID exists: ' + businessId);
return null
}
}

Resolving error 500; "Cannot access 'object' before initialization" }

Looking for help on how to resolve this error. I am trying to get an array of monthly transaction summation based on the summation of transactions occurring within each month.
here is my code below,
exports.monthlyTotalArray = async (req, res) => {
const { userId } = req.body;
const requestMonths = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const today = new Date();
var relevantYear = today.getYear();
try {
return await Transaction.findAll({
where: {
userId: userId,
},
}).then((transactions) => {
if (transactions.length == 0) {
res.status(401).json({
message: { msgBody: "No transactions found", msgError: true },
});
} else {
const MonthlyArray = requestMonths.forEach((requestMonth) => {
transactions
.filter((i) => {
const date = new Date(i.date);
return (
date.getMonth() == requestMonth &&
date.getYear() == relevantYear
);
})
.reduce((prev, curr) => prev + parseFloat(curr.amount), 0);
res.status(200).send({
MonthlyArray,
});
});
}
});
} catch (error) {
res.status(500).send({
message: error.message || "some error occurred",
});
}
};
And I get this error when I try to run the code
{
"message": "Cannot access 'MonthlyArray' before initialization"
}
The main issue is that you're calling res.send({ MonthlyArray }) inside of the callback function for requestMonths.forEach, so that's why you're getting the error.
const MonthlyArray = requestMonths.forEach((requestMonth) => {
// you're calling it here
res.status(200).send({
MonthlyArray,
});
});
// instead, it should be here
res.status(200).send({
MonthlyArray,
});
Also, you probably don't want to use forEach because forEach doesn't return anything, so MonthlyArray will be undefined. Instead, use Array.map to get the result you need.
if (transactions.length == 0) {
// ...
} else {
const MonthlyArray = requestMonths.map((requestMonth) => {
return transactions
.filter((i) => {
const date = new Date(i.date);
return (
date.getMonth() == requestMonth && date.getYear() == relevantYear
);
})
.reduce((prev, curr) => prev + parseFloat(curr.amount), 0);
});
res.status(200).send({
MonthlyArray,
});
}
It looks like you are accessing MonthlyArray in the foreach() lambda expression. But MonthlyArray is actually initialized by the result of that foreach() so you are indeed accessing MonthlyArray before it gets initialized.
It's probably not what you intended but the part of code where you are sending the response is inside the lambda expression.
It's hard to see because the indentation of your code makes it difficult too understand where is the end of your lambda expression.
A proper indentation of your code would most likely make it obvious.

Is this a good way of breaking "THEN chain" when using promises?

I have a simple "then chain" that runs some functinality steps. If some condition is met I need to cancel the chain and exit (mainly if an error occurrs). I'm using this in a firebase cloud function, but I think it is a basic concept applicable to any node/express eviroment.
This is the code I have:
let p1 = db.collection('products').doc(productId).get();
let p2 = db.collection('user_data').doc(userUid).get();
let promises= [p1,p2];
return Promise.all(promises)
.then(values =>
{
let proudctDoc = values[0];
let userDataDoc = values[1];
if(!proudctDoc.exists)
{
console.log("The product does not exist");
response.status(404).json({error: ErrorCodes.UNKNOWN_PRODUCT, msg: "The products does not exist"});
throw("CANCEL");
}
if(!userDataDoc.exists)
{
console.log("User data block not found!");
response.status(404).json({error: ErrorCodes.UNKNOWN_USER_DATA, msg: "User data block not found!"});
throw("CANCEL");
}
variantCountryRef = db.doc('products/'+productId+'/variants/'+variantCountry);
return variantCountryRef.get();
})
.then(variantCountryDoc =>
{
....
})
.catch(err =>
{
if(err !== "CANCEL")
{
//Deal with real error
}
}
As you see, I just run 2 promises and wait for them to finish. After this I check some returned values and notify the client if an error occurs. At this time I must finish the "Then chain".
Is this a common pattern? Anything I could improve?
You could do as follows:
const p1 = db.collection('products').doc(productId).get();
const p2 = db.collection('user_data').doc(userUid).get();
const promises = [p1, p2];
return Promise.all(promises)
.then(values => {
let proudctDoc = values[0];
let userDataDoc = values[1];
if (!proudctDoc.exists) {
console.log('The product does not exist');
throw new Error(ErrorCodes.UNKNOWN_PRODUCT);
}
if (!userDataDoc.exists) {
console.log('User data block not found!');
throw new Error(ErrorCodes.UNKNOWN_USER_DATA);
}
variantCountryRef = db.doc(
'products/' + productId + '/variants/' + variantCountry
);
return variantCountryRef.get();
})
.then(variantCountryDoc => {
//Don't forget to send a response back to the client, see https://www.youtube.com/watch?v=7IkUgCLr5oA&
//...
//response.send(...);
})
.catch(err => {
if (err.message === ErrorCodes.UNKNOWN_PRODUCT) {
response.status(404).json({
error: ErrorCodes.UNKNOWN_PRODUCT,
msg: 'The products does not exist'
});
} else if (err.message === ErrorCodes.UNKNOWN_USER_DATA) {
response
.status(404)
.json({
error: ErrorCodes.UNKNOWN_USER_DATA,
msg: 'User data block not found!'
});
} else {
//Deal with other error types
}
});

Categories