Home
>
Firestore Server Side Counter

Firestore Server Side Counter

10 min read
Jonathan Gamble

jdgamble555 on Monday, February 12, 2024 (last modified on Thursday, February 22, 2024)

Sometimes you may want to build your own Database API. This certain helps make complex cases easier. You may also could use a mixture of both. If you want optimistic updates, offline mode, and realtime updates on the client, you may stick with the Firebase Client SDK. However, if you want to write more complex security than Firestore Rules is capable of doing alone, you may just disable all your client side writes, and put them on the server side.

TL;DR

Method ✅ Best For ⚠️ Avoid When
Server Side Increment Large Collection counts. Sorting by counters. You want to avoid complex rules. Saving price on counting. You need to display a count once on small collections. You prefer dealing with Firestore rules over Firebase Functions. You worry about accuracy.
Server Side Server Count Large Collection counts. Sorting by counters. You want to avoid complex rules. You need accuracy. You need to display a count once on small collections. You prefer dealing with Firestore rules over Firebase Functions. You don’t want to be charged for counting.

Setup

Disable Client Side Writes

	rules_version = '2';

service cloud.firestore {

  match /databases/{database}/documents {

    match /users/{document} {
      allow read;
      allow write: if false;
    }

    match /todos/{document} {
      allow read;
      allow update;
      allow create, delete: if false;
    }

    match /_counters/{document} { 
      allow read;
      allow write: if false;
    } 

  }
}

Or perhaps you want to allow updates, but not creates and deletes on the client side. This comes in handy when you need to require a batch or transaction with your create and delete. This certainly makes sense for a counter.

Callable Function

Generally speaking, I recommend using firebase-admin your app itself, like SvelteKit or NextJS. You could use custom endpoints or Server Actions to write to your database how you want to. This also allows you to run quicker Firestore updates from the Edge, instead of serverless Cloud Run architecture.

However, there are plenty of cases when you want to just call a Firebase Function directly to have your code run on the server. This is great for Flutter, React Native, or native applications. Either way the premise will be the same. Start up your firebase-admin, use transactions, and you can do what you want.

Firebase Functions

Create two Firebase functions for the delete and create cases. You want to update the count for each use case, while simultaneously adding or deleting a new record.

	import { firestore } from 'firebase-admin';
import { getApps, initializeApp } from 'firebase-admin/app';
import { onCall } from 'firebase-functions/v2/https';

if (!getApps.length) {
    initializeApp();
}

const db = firestore();

export const addTodo = onCall(todoChange);
export const deleteTodo = onCall(todoChange);

Note: Make sure to import from firebase-admin/app while testing.

Client Call

From your client code you’re going to run the two callable functions addTodo and deleteTodo. You will pass in either the id or the text field depending on your use case.

	import { httpsCallable } from "firebase/functions";
...

export const addTodo = async (text: string) => {
    httpsCallable(functions, 'addTodo')({
        text
    });
};

export const deleteTodo = (id: string) => {
    httpsCallable(functions, 'deleteTodo')({
        id
    });
};

Server Count

The user login credentials are automatically passed to the request object as request.auth. If the user is not logged in, we must throw an error.

This code runs a transaction that gets the current count values for todoCount on the user document and count on the counter document. See Firestore Cloud Functions Counter if you need more understanding of why. This will keep a count of your todos and the total todos. We use the server count method here, although we could use regular increments. Unlike a Firestore Trigger Function, these functions could be called more than once without problems. Worst case scenario you get two todo items that are equal with different ids. The count would have to be correct due to the transaction nature of adding an item with the count increment. If you want to save on counting costs, an increment might be the better way to go in this scenario.

	const todoChange = (request: https.CallableRequest) => {

    if (!request.auth) {
        throw new https.HttpsError(
            'unauthenticated',
            'The function must be called while authenticated.'
        );
    }

    const uid = request.auth.uid;
    const create = !!request.data.text;

    return db.runTransaction(async (transaction) => {

        // get current doc collection count
        const docCountQuery = db.collection('todos').count();
        const docCountDoc = await transaction.get(docCountQuery);

        // get current user doc count
        const userCountQuery = db
            .collection('todos')
            .where('uid', '==', uid)
            .count();
        const userCountDoc = await transaction.get(userCountQuery);

        // create or delete todo
        if (create) {
            const todoRef = db.collection('todos').doc();
            transaction.create(todoRef, {
                uid,
                text: request.data.text,
                complete: false,
                createdAt: FieldValue.serverTimestamp()
            });
        } else {
            const todoRef = db.doc('todos/' + request.data.id);
            transaction.delete(todoRef);
        }
        const i = create ? 1 : -1;

        // update doc count
        const countRef = db.doc('_counters/todos');
        transaction.set(countRef, {
            count: docCountDoc.data().count + i
        }, { merge: true });

        // update user doc count
        const userRef = db.doc('users/' + uid);
        transaction.set(userRef, {
            todoCount: userCountDoc.data().count + i
        }, { merge: true });
    });
}

Realtime Optimistic Problem

I wanted to present this option, as it could be the way to go in many apps. However, keep in mind you will not have optimistic updates when you call from the server, so your app will only get updated realtime once the process has completed and gets sent back to the client. The same goes for offline mode. In any other database you would have to build your own optimistic update system anyway.

Example App

Firestore Server Side Counter

Repo: GitHub

Repo Functions: index.ts

An identical version of this app was also built for my Firestore Secure Batch Increment post and my Firestore Cloud Functions Counter post. However, the counter mechanisms vary slightly.


Related Posts

© 2024 Code.Build