In a Firebase Database Cloud Function, how can you add/replace an item in a list - node.js

My database structure is:
- Scores
- r5rwuerpepdsoazdf (user UID)
- scoreString: "5,12"
- j6fdasdfsdfs08fdd
- scoreString: "1,4,7"
and I have a cloud function that updates the Score for a particular user UID when a value is written in another node (ScoreStacks):
exports.updateScoreString =
functions.database.ref('/ScoreStacks/{uid}/{levelKey}/Score')
.onWrite((dataSnapshot, context) => {
const score = dataSnapshot.after.val();
const scoreStringRef = dataSnapshot.after.ref.root.child('Scores/' + context.params.uid + '/scoreString');
return scoreStringRef.transaction(scoreString => {
if (!scoreString) // transactions return null first time
{
return scoreString;
}
return scoreStringWithAddedScore(scoreString, score);
});
});
This is all working fine apart from if the UID in the Scores node does not yet exist. Then nothing happens.
I guess that is fair enough because scoreStringRef is refering to a path that doesn't exist.
So how can I ensure a new entry is made in the Scores node if the UID does not exist yet? It all has to be done atomically.

It's hard to be sure without seeing scoreStringWithAddedScore, but I suspect you want this:
return scoreStringRef.transaction(scoreString => {
if (!scoreString) // transactions return null first time
{
scoreString = "";
}
return scoreStringWithAddedScore(scoreString, score);
});
A more concise (but somewhat harder to parse if you're new to this) version:
return scoreStringRef.transaction(scoreString => {
return scoreStringWithAddedScore(scoreString || "", score);
});

Related

Error: Value for argument "documentPath" is not a valid resource path. Path must be a non-empty string. /*what is empty or not a string*/

this is the error that I get. I checked multiple times that the paths that I indicate are actually pointing at something in the database. I'm kinda going crazy about why this is not working, so help will be appreciated. (the same error is given by both functions, two times every invocation of the function)
this is my code:
exports.onCreatePost = functions.firestore
.document('/time/{userid}/date/{postId}')
.onCreate (async (snapshot, context) => {
const postCreated = snapshot.data();
const userID = context.params.userid;
const postID = context.params.postId;
//get all the followers who made the post
const userFollowerRef = admin.firestore().collection('time').doc(userID).collection('followers');
const querySnap = await userFollowerRef.get();
//add the post in each follower timeline
querySnap.forEach(doc => {
const followerid = doc.id;
admin.firestore().collection('time').doc(followerid).collection('timelinePosts').doc(postID).set(postCreated);
})
});
//when a post is updated
exports.onUpdatePost = functions.firestore
.document('/time/{userid}/date/{postid}')
.onUpdate(async (change, context) => {
const postUpdated = change.after.data();
const userID = context.params.userid;
const postID = context.params.postId;
//get all the followers who made the post
const userFollowerRef = admin.firestore().collection('time').doc(userID).collection('followers');
const querySnap = await userFollowerRef.get();
//Update the post in each follower timeline
querySnap.forEach(doc => {
const follower = doc.id;
admin.firestore().collection('time').doc(follower).collection('timelinePosts').doc(postID)
.get().then(doc => {
if (doc.exists) {
doc.ref.update(postUpdated);
}
});
});
});
I personally don't know how to log each variable and did not find how to do it online. I'll keep searching but in the mindtime I can share my extensive logs that from my interpretation are not very useful but maybe is just because I'm inexperienced.
this is the error log
enter image description here
In function exports.onUpdatePost ...you're likely trying to access documentPath null (or something alike that). Add logging, this permits to log custom debug information into the log which you've screenshotted. When logging every step of the procedure, it's a whole lot easier to determine what is happening and why - or why not, when skipping something. Alike this you should be able to solve the issue on your own. My functions logging actually utilizes emojis, because UTF-8 is being supported: ✅❌ (visual indicators make the log more readable).
The cause seems to be one of these instructions:
admin.firestore().collection('time') // it is being assumed that it exists
.doc(userID) // argument or return value may be null
.collection('followers') // return value may be null
Or:
admin.firestore().collection('time') // it is being assumed that it exists
.doc(follower) // argument or return value may be null
.collection('timelinePosts') // return value may be null
.doc(postID) // argument or return value may be null
eg. one first has to check if follower != null or empty and if the desired document even exists. The same goes for userID and .doc(userID) (the seemingly "own" timeline).
if (follower != null && follower.length > 0) {
admin.firestore().collection('time').doc(follower).get().then(timeline => {
functions.logger.log('timeline: ' follower + ', ' + timeline.exists);
if (timeline.exists) {
} else {
}
});
}
documentPath == null comes from .doc() + userID, followerid, follower or postID.

Firebase Cloud Function Increment Counter

Per Firebase cloud functions docs, "Events are delivered at least once, but a single event may result in multiple function invocations. Avoid depending on exactly-once mechanics, and write idempotent functions."
When looking at a firestore cloud function doc example below of a restaurant rating, they are using an increment counter to calculate the total number of ratings. What are some of the best ways to maintain the accuracy of this counter by using an idempotent function?
Is it reasonable to store the context.eventId in a subcollection document field and only execute the function if the new context.eventId is different?
function addRating(restaurantRef, rating) {
// Create a reference for a new rating, for use inside the transaction
var ratingRef = restaurantRef.collection('ratings').doc();
// In a transaction, add the new rating and update the aggregate totals
return db.runTransaction((transaction) => {
return transaction.get(restaurantRef).then((res) => {
if (!res.exists) {
throw "Document does not exist!";
}
// Compute new number of ratings
var newNumRatings = res.data().numRatings + 1;
// Compute new average rating
var oldRatingTotal = res.data().avgRating * res.data().numRatings;
var newAvgRating = (oldRatingTotal + rating) / newNumRatings;
// Commit to Firestore
transaction.update(restaurantRef, {
numRatings: newNumRatings,
avgRating: newAvgRating
});
transaction.set(ratingRef, { rating: rating });
});
});
}
Is it reasonable to store the context.eventId in a subcollection
document field and only execute the function if the new
context.eventId is different?
Yes, for your use case, using the Cloud Function eventId is the best solution to make you Cloud Function idempotent. I guess you have already watched this Firebase video.
In the Firebase doc from which you have taken the code in your question, you will find at the bottom, the similar code for a Cloud Function. I've adapted this code as follows, in order to check if a doc with ID = eventId exists in a dedicated ratingUpdateIds subcollection:
exports.aggregateRatings = functions.firestore
.document('restaurants/{restId}/ratings/{ratingId}')
.onWrite(async (change, context) => {
try {
// Get value of the newly added rating
const ratingVal = change.after.data().rating;
const ratingUpdateId = context.eventId;
// Get a reference to the restaurant
const restRef = db.collection('restaurants').doc(context.params.restId);
// Get a reference to the ratingUpdateId doc
const ratingUpdateIdRef = restRef.collection("ratingUpdateIds").doc(ratingUpdateId);
// Update aggregations in a transaction
await db.runTransaction(async (transaction) => {
const ratingUpdateIdDoc = await transaction.get(ratingUpdateIdRef);
if (ratingUpdateIdDoc.exists) {
// The CF is retried
throw "The CF is being retried";
}
const restDoc = await transaction.get(restRef);
// Compute new number of ratings
const newNumRatings = restDoc.data().numRatings + 1;
// Compute new average rating
const oldRatingTotal = restDoc.data().avgRating * restDoc.data().numRatings;
const newAvgRating = (oldRatingTotal + ratingVal) / newNumRatings;
// Update restaurant info and set ratingUpdateIdDoc
transaction
.update(restRef, {
avgRating: newAvgRating,
numRatings: newNumRatings
})
.set(ratingUpdateIdRef, { ratingUpdateId })
});
return null;
} catch (error) {
console.log(error);
return null;
}
});
PS: I made the assumption that the Cloud Function eventId can be used as a Firestore document ID. I didn't find any doc or info stating the opposite. In case using the eventId as an ID would be a problem, since you execute the transaction in a Cloud Function (and therefore use the Admin SDK), you could query the document based on a field value (where you store the eventId) instead of getting it through a Reference based on its ID.

Model.findOne() function not working when passed a variable as the value

I am new to node.js.
I am trying to create function, where a randomly generated String is queried to check if it exists or not. If it already exists, the String is randomly generated till it is unique.
let validID = false;
console.log(temp); //temp is the randomly generated String.
while(!validID){
Website.findOne({shortcut: temp},function(err,docs){
if(docs==null){
validID = true;
console.log("The shortcut for the url is" + temp);
}else{
console.log("FOUND");
temp = generateId();
}
});
}
When run, the code is stuck in an infinite while loop.
I tried to see whether the code works with a String value ( not a variable ) passed in as the query inside findOne(). It worked. I am assuming that the fact that temp is a variable is causing the problem. Can variables be passed in as a value in a query? If so, what is the correct way?
Website.findOne operates asynchronously, i.e. the callback-function you passed to it, will be run once the results from the mongodb are fetched. However, node will not be able to actually process this callback, since your callstack never gets emptied due to your while-loop. If you're interested, you can find out more about this here.
One way to solve this is to wrap your Mongo-DB call in a promise, wait for it to resolve, then return if the ID is unique and continue by calling it recursively otherwise (note that this can be highly simplified by using async/await but for understanding how this works using promised are beneficial imo):
function findIdPromise(temp) {
return new Promise((resolve, reject) => {
Website.findOne({
shortcut: temp
}, function (err, docs) {
if (err) {
return reject(err);
}
resolve(docs);
});
});
}
function getNextIsUniqueIdPromise(shortcut) {
return findIdPromise()
.then(docs => {
if (docs == null) {
return shortcut;
}
return getNextIsUniqueIdPromise(generateId());
});
}
// call it initially with
getNextIsUniqueIdPromise(firstShortcutToCheck)
.then(shortcut => {
console.log("The shortcut for the url is" + shortcut):
})
.catch(err => {
console.log("an error occured", err):
});

How to limit command to the server where the command was called?

I created a Discord bot which has some commands that generates riddels and riddels answers.
I discovered a bug that was triggered: if I invite the bot in two different servers and try to run one command in one of this two servers, it will run it in the second one and that's actually bad because I wanted each server to be independent. If the command is run on one server, it shouldn't be run on another.
I tried to save server id and tried to write some conditions with it but didn't work.
on the first server:
on the second server:
I want to make the command works per server not get shared in all of them
let CommandRunned = CommandRunnedWithSpace.trim().toLowerCase()
let argsWithOutSpace = receivedMess.content.toLowerCase().slice(mentionNumber).trim().split(' ');
const cmd = argsWithOutSpace.shift().toLowerCase();
const args = argsWithOutSpace.join(' ');
if (CommandRunned === 'startriddle') {
if (RiddleMap.get('check') === true) {
receivedMess.reply("Riddle Already Started (can't start two riddels at the sametime)");
} else {
currentCluesArray = [];
clueI = 0;
getRndNum = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
let randomNum = getRndNum(0, RiddlesApi.Riddles.length - 1)
RiddlesApi.Riddles.filter((Riddel) => {
if (Riddel.id === randomNum) {
RiddleMessage = Riddel.Riddle
answer = Riddel.Answer
clues = Riddel.clues
}
})
StartRiddleModuel.startRiddle(receivedMess, RiddleMessage);
RiddleMap.set('check', true);
}
}
if (cmd === 'answer') {
if (answerCooldown.has(receivedMess.author.id)) {
receivedMess.delete();
receivedMess.reply('You have to wait ' + answercdseconds + ' seconds befor answering again')
} else {
if (RiddleMap.get('check') === true) {
if (args === answer) {
RiddleMap.set('check', false);
receivedMess.reply("Correct :hushed: ")
receivedMess.react('✅');
} else if (args === '') {
receivedMess.reply("Well you didnt enter anything.How you want me to know your answer then.")
receivedMess.react('💢');
} else {
receivedMess.reply("That wasnt the right answer try again :smirk: ")
receivedMess.react('❌');
}
answerCooldown.add(receivedMess.author.id);
} else {
receivedMess.reply("No Riddles did started");
}
}
}
Without having complete code it's pretty hard to point out which lines of code should be changed. However, the general idea is to compare guild ID each time you run the command. Under Message object, you can find guild ID like this:
msg.guild.id
When user requests a riddle, you should save the guild ID and then compare it with the ID from the answer command.
However, there's another solution which could better fit your needs: awaitMessages method from TextChannel class.
The most important benefit is that it's used per channel, so you don't need to add any logic like the one I described above.
Example from documentation should help you to properly incorporate it in your script (comments and minor changes mine):
// Here you need to create a filter to 'catch' only desired messages
// E.g. filter I use to check if my bot was pinged is as below
// message.mentions.users.some(mention => mention.id === client.user.id)
const filter = m => m.content.includes('answer');
// Here you specify max: 1 as you want to catch and respond to each message
channel.awaitMessages(filter, { max: 1, time: 60000, errors: ['time'] })
.then(collected => console.log('If correct command was sent, program enters here'))
.catch(collected => console.log('In case errors or timeout, put commands here'));
The only thing missing is to loop awaitMessages (so that it doesn't end after one message is found) and set separate timer so you know when the time for answering ends.

Firebase functions returns RangeError at Function.MapValues

I've run into an issue with a firebase function, written in TypeScript for the Node.js environment. I have a function with https-endpoint where the client can send data that needs to be stored in the database. In order to know which objects already has been added to the database, it first reads a path ("lookup") that has a simplified registry of the object (lookup/:objectId/true). Then it makes the values that should be updated at the actual object path and updates these in the database.
The function is as follows:
export const scrapeAssignments = functions.https.onCall((data, context) => {
const htmlString = data.htmlString
// const htmlString = fs.readFileSync(testPath.join(__dirname, "./assignmentListExample.html"), { encoding: 'utf8' })
if (!(typeof htmlString === 'string') || htmlString.length === 0) {
throw new functions.https.HttpsError('invalid-argument', 'The function must be called with one argument "htmlString"');
}
const userId = getUserIdFromCallableContext(context)
console.log("userId", userId)
let newAssignments: ScrapedAssignment[] = []
try {
newAssignments = parseAssignment(htmlString)
} catch (e) {
const error = <Error>e
throw new functions.https.HttpsError('not-found', 'parsing error: ' + error.message)
}
return admin.database().ref("lookup").child(userId).child("assignments")
.once("value", lookupSnapshot => {
const oldAssignmentsLookup = lookupSnapshot.val() || {}
const newAssignmentsLookup = makeLookup(newAssignments)
// 1. Create update values for scraped assignment data
let scrapedAssignmentUpdateValues = newAssignments.reduce((prev, current) => {
const prefixed = prefixObject(current.id + "/", current)
return { ...prev, ...prefixed }
}, {})
// 2. Use the diff from the two lookups to find old assignments to delete
const removeAssignmentsValues = {}
Object.keys(oldAssignmentsLookup).forEach(assignmentId => {
if (isUndefined(newAssignmentsLookup[assignmentId]))
removeAssignmentsValues[assignmentId] = null
})
// 3. Add other user values to newly found assignments
Object.keys(newAssignmentsLookup).forEach(assignmentId => {
if (isUndefined(oldAssignmentsLookup[assignmentId])) {
const doneKey = assignmentId + "/done"
scrapedAssignmentUpdateValues[doneKey] = false
}
})
const combinedValues = { ...scrapedAssignmentUpdateValues, ...removeAssignmentsValues }
return admin.database().ref("userAssignments").child(userId).update(combinedValues)
}).catch(reason => {
throw new functions.https.HttpsError('internal', 'Database reason: ' + reason)
})
})
I see that the data is written to the right place and everything seems to go as expected, except that when I call the function from an iOS app, It returns an "Internal"-error
When I check the function logs in the cloud console, I see the following error:
assignment-scrapeAssignments uolk47opctna Unhandled error RangeError:
Maximum call stack size exceeded at Function.mapValues
(/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:13395:23)
at encode
(/user_code/node_modules/firebase-functions/lib/providers/https.js:204:18)
at
/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:13400:38
at
/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:4925:15
at baseForOwn
(/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:3010:24)
at Function.mapValues
(/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:13399:7)
at encode
(/user_code/node_modules/firebase-functions/lib/providers/https.js:204:18)
at
/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:13400:38
at
/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:4925:15
at baseForOwn
(/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:3010:24)
at Function.mapValues
(/user_code/node_modules/firebase-functions/node_modules/lodash/lodash.js:13399:7)
All I can read out of this, is that it's an "RangeError: Maximum call stack size exceeded"-error, and something happens at Function.mapValues. As I can read from this SO question it seems to be an issue with a reading and writing to the same location at the same time. But I'm quite sure that I'm not doing that here.. Plus everything seems behave like it should, except for the actual error.
When combinedValues is updated, it is an object with ~300 key/value pairs, is that a problem?
Looks like your function run out of memory, every cloud function has allocated memory to its execution.
You can try to increase the stack size from its default value (256MB) up to 2GB by going to:
Functions-> Dashboard, then go to your problematic function and click on the right menu "Detailed usage stats":
then on your function details in google cloud dashboard click edit:
then increase the value of "memory allocated" to 2GB (or lower sufficient value):
note: you should keep in mind that when your data growth you may exceed the highest limit so consider this when querying in the cloud functions

Resources