How do you force a Firestore client app to maintain a correct document count for a collection?

Solution 1:

Imagine you have a collection "messages" that holds messages that clients can add and remove. Also imagine a document in a different collection with the path "messages-stats/data" with a field called "count" that maintains an accurate count of the documents in messages. If the client app performs a transaction like this to add a document:

async function addDocumentTransaction() {
    try {
        const ref = firestore.collection("messages").doc()
        const statsRef = firestore.collection("messages-stats").doc("data")
        await firestore.runTransaction(transaction => {
            transaction.set(ref, {
                foo: "bar"
            })
            transaction.update(statsRef, {
                count: firebase.firestore.FieldValue.increment(1),
                messageId: ref.id
            })
            return Promise.resolve()
        })
        console.log(`Added message ${ref.id}`)
    }
    catch (error) {
        console.error(error)
    }
}

Or a batch like this:

async function addDocumentBatch() {
    try {
        const batch = firestore.batch()
        const ref = firestore.collection("messages").doc()
        const statsRef = firestore.collection("messages-stats").doc("data")
        batch.set(ref, {
            foo: "bar"
        })
        batch.update(statsRef, {
            count: firebase.firestore.FieldValue.increment(1),
            messageId: ref.id
        })
        await batch.commit()
        console.log(`Added message ${ref.id}`)
    }
    catch (error) {
        console.error(error)
    }
}

And like this to delete a document using a transaction:

async function deleteDocumentTransaction(id) {
    try {
        const ref = firestore.collection("messages").doc(id)
        const statsRef = firestore.collection("messages-stats").doc("data")
        await firestore.runTransaction(transaction => {
            transaction.delete(ref)
            transaction.update(statsRef, {
                count: firebase.firestore.FieldValue.increment(-1),
                messageId: ref.id
            })
            return Promise.resolve()
        })
        console.log(`Deleted message ${ref.id}`)
    }
    catch (error) {
        console.error(error)
    }
}

Or like this with a batch:

async function deleteDocumentBatch(id) {
    try {
        const batch = firestore.batch()
        const ref = firestore.collection("messages").doc(id)
        const statsRef = firestore.collection("messages-stats").doc("data")
        batch.delete(ref)
        batch.update(statsRef, {
            count: firebase.firestore.FieldValue.increment(-1),
            messageId: ref.id
        })
        await batch.commit()
        console.log(`Deleted message ${ref.id}`)
    }
    catch (error) {
        console.error(error)
    }
}

Then you can use security rules to require that both the document being added or removed can only be changed at the same time as the document with the count field. Minimally:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /messages/{id} {
      allow read;
      allow create: if
        getAfter(/databases/$(database)/documents/messages-stats/data).data.count ==
             get(/databases/$(database)/documents/messages-stats/data).data.count + 1;
      allow delete: if
        getAfter(/databases/$(database)/documents/messages-stats/data).data.count ==
             get(/databases/$(database)/documents/messages-stats/data).data.count - 1;
    }

    match /messages-stats/data {
      allow read;
      allow update: if (
        request.resource.data.count == resource.data.count + 1 &&
        existsAfter(/databases/$(database)/documents/messages/$(request.resource.data.messageId)) &&
           ! exists(/databases/$(database)/documents/messages/$(request.resource.data.messageId))
      ) || (
        request.resource.data.count == resource.data.count - 1 &&
        ! existsAfter(/databases/$(database)/documents/messages/$(request.resource.data.messageId)) &&
               exists(/databases/$(database)/documents/messages/$(request.resource.data.messageId))
      );
    }

  }
}

Note that the client must:

  • Increment or decrement the count in /messages-stats/data while adding or removing a document.
  • Must provide the id of the document being added or removed in the "data" document in a field called messageId.
  • Incrementing the count requires that the new document identified in messageId must not exist before the batch/transaction commits, and exist after transaction.
  • Decrementing the count requires that the old document identified in messageId must exist before the batch/transaction commits, and must not exist after the transaction.

Note that existsAfter() checks the state of the named document after the transaction would complete, while exists() checks it before. The difference between these two functions is important to how these rules work.

Also note that this will not scale well under heavy load. If documents are being added and removed faster than 10 per second, the per-document write rate will be exceeded for the data document, and the transaction will fail.

Once you have this in place, now you can actually write security rules to limit the size of a collection like this:

match /messages/{id} {
  allow create: if
    get(/databases/$(database)/documents/messages-stats/data).data.count < 5;
}

Solution 2:

The suggested solution would still fail, as you could just add additional documents to the batch write.

But you could just add the following to the message collection rule:

&& get(/databases/$(database)/documents/messages-stats/data).data.messageId == request.resource.id;

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /messages/{id} {
      allow read;
      allow create: if
        getAfter(/databases/$(database)/documents/messages-stats/data).data.count ==
             get(/databases/$(database)/documents/messages-stats/data).data.count + 1 
             && get(/databases/$(database)/documents/messages-stats/data).data.messageId == request.resource.id;
      allow delete: if
        getAfter(/databases/$(database)/documents/messages-stats/data).data.count ==
             get(/databases/$(database)/documents/messages-stats/data).data.count - 1
             && get(/databases/$(database)/documents/messages-stats/data).data.messageId == request.resource.id;
    }

    match /messages-stats/data {
      allow read;
      allow update: if (
        request.resource.data.count == resource.data.count + 1 &&
        existsAfter(/databases/$(database)/documents/messages/$(request.resource.data.messageId)) &&
           ! exists(/databases/$(database)/documents/messages/$(request.resource.data.messageId))
      ) || (
        request.resource.data.count == resource.data.count - 1 &&
        ! existsAfter(/databases/$(database)/documents/messages/$(request.resource.data.messageId)) &&
               exists(/databases/$(database)/documents/messages/$(request.resource.data.messageId))
      );
    }

  }
}