Tracking timestamps with Firestore
Firestore is a NoSQL database offered through Google Firebase. Unlike the traditional paradigm of hiding your database behind your webserver, Firestore is designed to be queried directly from your frontend application very efficiently at scale. It enables this by allowing you to define a ruleset of what queries (reads, writes, deletes etc.) are allowed to be performed and under what conditions.
Firestore provides a lot of nice benefits — not least being able to write a completely serverless application — however, one of the big puzzles I had starting out, is the problem of timestamps e.g. createdAt
and updatedAt
. Firestore doesn’t index any kind of timestamp field out of the box and so it is on the developer to manage and solve this themselves. If you want to be able to query ordering by creation time or last updated you’re going to need a solution.
Let’s go through a couple of solutions to this problem and compare.
Solution One: Firebase Functions
One of the advantages of using Firestore is that it integrates seamlessly with Firebase’s serverless functions solution Firebase Functions. You can easily attach a function to be triggered by any document change create/update/delete.
With this in mind, we can write a function that will set the createdAt
and updatedAt
field on document creation and update updatedAt
on update. Here is what that looks like in Node JS (typescript):
import { DocumentSnapshot, Timestamp } from '@google-cloud/firestore';
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import { Change } from 'firebase-functions';
admin.initializeApp();const _isCreate = (change: Change<DocumentSnapshot>) =>
change.after.exists && !change.before.exists;const _isUpdate = (change: Change<DocumentSnapshot>) => change.before.exists && change.after.exists;const _isTimestampUpdate = (change: Change<DocumentSnapshot>) => {
const createdAtBefore = change.before.get('createdAt');
const createdAtAfter = change.after.get('createdAt');
const updatedAtBefore = change.before.get('updatedAt');
const updatedAtAfter = change.after.get('updatedAt');
return (
(!!createdAtAfter && !createdAtAfter.isEqual(createdAtBefore)) ||
(!!updatedAtAfter && !updatedAtAfter.isEqual(updatedAtBefore))
);
};export const setTimestamps = functions.firestore
.document('{collectionId}/{docId}')
.onWrite((change) => {
const now = Timestamp.now();
if (_isCreate(change)) {
const update = {
createdAt: now,
updatedAt: now,
};
return change.after.ref.update(update);
}
if (_isUpdate(change) && !_isTimestampUpdate(change)) {
const update = {
updatedAt: now,
};
return change.after.ref.update(update);
}
});
Let’s break this down…
First we create and export a function called setTimestamps
that will trigger for any document {docId}
, under any root collection {collectionId}
and on any type of change onWrite
: create, update, delete:
export const setTimestamps =functions.firestore
.document('{collectionId}/{docId}')
.onWrite((change)
Note: we could create two functions bound specifically to onCreate
and onUpdate
to handle the independent types of update. This could make more sense if you expect an equal amount of delete operations to create/update. Otherwise, having a single function can be a more efficient use of function runs if you consider cold-start times and the fact that functions can be reused, particularly if you have a lot of updates coming in.
If the incoming write is a creation event we simply initialize the createdAt
and updatedAt
on the affected document and return:
if (_isCreate(change)) {
const update = {
createdAt: now,
updatedAt: now,
};
return change.after.ref.update(update);
}
Otherwise, if the incoming event is an update event (and not a delete) we check that it isn’t an update we triggered by setting the timestamps in a previous trigger.
if (_isUpdate(change) && !_isTimestampUpdate(change)) {
const update = {
updatedAt: now,
};
return change.after.ref.update(update);
}
Attention: It can be easy to overlook but checking the incoming update isn’t a timestamp update is incredibly important here. If this check wasn’t here the function would trigger itself endlessly possibly costing big $$$ if left unchecked. I recommend always testing your code using firebase emulators before deploying your code to any production environment.
Benefits
- Easy to apply to all your collections without thinking about the problem again
- Easy to turn off if you decide life’s too short to be tracking time
Caveats
- Unavoidably, the function trigger will trigger itself once more on each write causing +1 redundant execution per write
- You’ll need to bind a separate function trigger for sub-collection document changes
- Timestamps will be a little out as they are recording the time at which the function triggered rather recording than the time of the actual write that caused the trigger
Solution Two: Firestore Rules
This solution shifts the problem to the client or whoever’s writing the data by requiring clients performing writes to firestore to include the createdAt
and updatedAt
fields with their updates.
Sound scary? How can we trust that a malicious user hasn’t changed our client code and started setting the timestamp to whenever? Fortunately we can overcome this with Firestore Rules by enforcing that the field be set to the server timestamp. Here’s what that can look like:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents { match /mycollection/{docId} {
allow create: if createCondition();
allow update: if updateCondition();
} function createCondition() {
return fieldExists('createdAt')
&& fieldExists('updatedAt')
&& request.resource.data['createdAt'] == request.time
&& request.resource.data['updatedAt'] == request.time;
} function updateCondition() {
return !fieldExists('createdAt')
&& fieldExists('updatedAt')
&& request.resource.data['updatedAt'] == request.time;
}
function fieldExists(field) {
return field in request.resource.data;
}
}
}
Let’s break it down…
For creates, we are defining the function createCondition()
that is checking that the incoming request has set both the createdAt
and updatedAt
fields and that they are set to the server timestamp request.time
.
Similarly, for updates, we are defining the function updateCondition()
that is checking that the incoming request has set updatedAt
field to the server timestamp but also that the request explicitly isn’t attempting to overwrite createdAt
which we do not want to allow outside of creates.
Here’s an example of what the clientside code could look like with React Native:
import firestore from '@react-native-firebase/firestore';...
// Example create
firestore().collection('mycollection').doc().set({
message: 'example create',
createdAt: firestore.FieldValue.serverTimestamp(),
updatedAt: firestore.FieldValue.serverTimestamp()
});// Example update
firestore().collection('mycollection').doc('1').update({
message: 'example update',
updatedAt: firestore.FieldValue.serverTimestamp()
});
FieldValue.serverTimestamp()
is a special value that tells Firestore to insert the server timestamp into the field when it processes the operation and is what our Firestore rules are checking for the existance of. Any writes from the client that don’t include the appropriate createdAt/updateAt
fields and serverTimestamp()
value will fail.
Benefits
- Very cheap to enforce as we don’t need to trigger any functions
- Accurate as the timestamp should be exactly when the write is received by firestore
Caveats
- Need to apply to all collection rules independently — no blanket rule
- Server side code that uses firebase-admin privileges to write to Firestore will bypass firestore rules so you need to make sure you’re writing the appropriate timestamps yourself
- Client-side code needs to be aware of writing timestamps
Conclusion
If you are looking for a fast and loose solution, using Firebase Functions is a decent solution but you’ll pay the cost both in money spent on function triggers and accuracy of timestamp values. If you want accuracy and good economy, for a little more work, I very much recommend using Firestore Rules for a robust solution (as long as you keep your server side code in check!). You could even use a combination of these solutions.
I hope you found this helpful, if you have any questions regarding this subject please leave a comment and I’ll try my best answer them below.