firestore-advanced-patterns
This repository contains a complete **Cursor (Claude AI)** infrastructure that provides **auto-activating skills**, **specialized agents**, and **powerful commands** to help you build better applications faster.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-22
Download the latest version (v1.0.0) from this page.
SKILL.md
# Firestore Advanced Patterns
**Skill Version:** 1.0.0
**Stack:** Firebase Firestore, TypeScript
**Purpose:** Advanced Firestore patterns including atomic operations, sub-collections, and activity logging
## Overview
This skill provides production-tested patterns for advanced Firestore operations, including atomic transactions for unique ID generation, sub-collection organization, activity logging, and batch operations for performance.
## When to Use This Skill
Load this skill when working on:
- Firestore transaction operations
- Sub-collection data modeling
- Activity/audit logging
- Batch operations
- Unique ID generation (invoices, orders, etc.)
- Multi-collection patterns
## Quick Reference
### Core Patterns
1. **Atomic Operations** - Use transactions for unique counters and concurrent writes
2. **Sub-Collections** - Organize related data hierarchically
3. **Activity Logging** - Track all important actions for audit trails
4. **Batch Operations** - Optimize multiple writes
5. **Collection Constants** - Centralize collection names
## Core Guidelines
### 1. Atomic Operations with Transactions
**Use Case:** Generate unique sequential IDs (invoice numbers, order numbers)
```typescript
import {
doc,
runTransaction,
getDoc,
setDoc
} from 'firebase/firestore';
import { db } from './config';
/**
* Generate unique invoice number atomically
* Format: INV-YYYY-XXX (e.g., INV-2024-001)
*/
export async function generateInvoiceNumber(): Promise<string> {
try {
const year = new Date().getFullYear();
const counterPath = `counters/invoices-${year}`;
const invoiceNumber = await runTransaction(db, async (transaction) => {
const counterRef = doc(db, counterPath);
const counterDoc = await transaction.get(counterRef);
let nextNumber = 1;
if (counterDoc.exists()) {
nextNumber = (counterDoc.data().current || 0) + 1;
transaction.update(counterRef, {
current: nextNumber,
updatedAt: new Date().toISOString()
});
} else {
// First invoice of the year
transaction.set(counterRef, {
current: nextNumber,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
// Format: INV-2024-001
return `INV-${year}-${nextNumber.toString().padStart(3, '0')}`;
});
return invoiceNumber;
} catch (error) {
console.error('Error generating invoice number:', error);
throw new Error('Failed to generate invoice number');
}
}
/**
* Generic counter generator for any entity type
*/
export async function generateUniqueNumber(
prefix: string,
counterName: string,
includeYear: boolean = true
): Promise<string> {
try {
const year = new Date().getFullYear();
const counterPath = includeYear
? `counters/${counterName}-${year}`
: `counters/${counterName}`;
const number = await runTransaction(db, async (transaction) => {
const counterRef = doc(db, counterPath);
const counterDoc = await transaction.get(counterRef);
let nextNumber = 1;
if (counterDoc.exists()) {
nextNumber = (counterDoc.data().current || 0) + 1;
transaction.update(counterRef, { current: nextNumber });
} else {
transaction.set(counterRef, { current: nextNumber });
}
if (includeYear) {
return `${prefix}-${year}-${nextNumber.toString().padStart(3, '0')}`;
}
return `${prefix}-${nextNumber.toString().padStart(4, '0')}`;
});
return number;
} catch (error) {
console.error(`Error generating ${counterName} number:`, error);
throw error;
}
}
/**
* Example usage for different entity types
*/
// Invoice: INV-2024-001
const invoiceNum = await generateUniqueNumber('INV', 'invoices', true);
// Order: ORD-2024-123
const orderNum = await generateUniqueNumber('ORD', 'orders', true);
// Payment: PAY-0001 (no year)
const paymentNum = await generateUniqueNumber('PAY', 'payments', false);
```
**Why Transactions?**
- Guarantees uniqueness in concurrent environments
- Prevents duplicate IDs when multiple users create records simultaneously
- Atomic - either succeeds completely or fails completely
- Handles race conditions automatically
### 2. Sub-Collection Pattern
**Use Case:** Organize related data hierarchically
```typescript
import {
collection,
doc,
addDoc,
getDocs,
query,
where,
orderBy
} from 'firebase/firestore';
import { db } from './config';
// Collection structure:
// clients/{clientId}/contacts/{contactId}
// clients/{clientId}/documents/{documentId}
// clients/{clientId}/projects/{projectId}
// clients/{clientId}/invoices/{invoiceId}
// clients/{clientId}/notes/{noteId}
/**
* Add a contact to a client (sub-collection)
*/
export async function addClientContact(
clientId: string,
contactData: {
firstName: string;
lastName: string;
email: string;
phone?: string;
isPrimary: boolean;
}
) {
try {
const contactsRef = collection(db, `clients/${clientId}/contacts`);
const docRef = await addDoc(contactsRef, {
...contactData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { id: docRef.id, ...contactData };
} catch (error) {
console.error('Error adding contact:', error);
throw error;
}
}
/**
* Get all contacts for a client
*/
export async function getClientContacts(clientId: string) {
try {
const contactsRef = collection(db, `clients/${clientId}/contacts`);
const q = query(contactsRef, orderBy('createdAt', 'desc'));
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
} catch (error) {
console.error('Error getting contacts:', error);
throw error;
}
}
/**
* Get primary contact for a client
*/
export async function getPrimaryContact(clientId: string) {
try {
const contactsRef = collection(db, `clients/${clientId}/contacts`);
const q = query(contactsRef, where('isPrimary', '==', true));
const snapshot = await getDocs(q);
if (snapshot.empty) return null;
const doc = snapshot.docs[0];
return { id: doc.id, ...doc.data() };
} catch (error) {
console.error('Error getting primary contact:', error);
throw error;
}
}
```
**Sub-Collection Benefits:**
- ✅ Better data organization (hierarchical structure)
- ✅ Easier to query related data
- ✅ Automatic cleanup when parent deleted (if configured)
- ✅ Scales better than embedded arrays
- ✅ Supports complex queries on child data
**When to Use Sub-Collections vs Embedded Data:**
| Use Sub-Collection | Use Embedded Data |
|-------------------|-------------------|
| Many items (>50) | Few items (<10) |
| Items grow over time | Fixed small set |
| Need to query items | Just display with parent |
| Items have relationships | Simple data |
| Example: contacts, invoices | Example: address, metadata |
### 3. Collection Constants Pattern
**Use Case:** Centralize collection names to avoid typos and enable refactoring
```typescript
// lib/firebase/collections.ts
/**
* Centralized collection names
* Benefits:
* - No typos in collection names
* - Easy refactoring if names change
* - Autocomplete support
* - Clear overview of data structure
*/
export const COLLECTIONS = {
// User management
USERS: 'users',
USER_PROFILES: 'user_profiles',
// Client management
CLIENTS: 'clients',
CLIENT_CONTACTS: 'client_contacts',
CLIENT_DOCUMENTS: 'client_documents',
CLIENT_INVOICES: 'client_invoices',
CLIENT_PROJECTS: 'client_projects',
CLIENT_NOTES: 'client_notes',
CLIENT_PAYMENTS: 'client_payments',
CLIENT_ACTIVITIES: 'client_activities',
// Contractor management
CONTRACTORS: 'contractors',
CONTRACTOR_PAYMENTS: 'contractor_payments',
// Financial
EXPENSES: 'expenses',
CAPITAL: 'capital_contributions',
INTERNAL_PROJECTS: 'internal_projects',
// System
COUNTERS: 'counters',
ACTIVITY_LOGS: 'activity_logs',
NOTIFICATIONS: 'notifications',
} as const;
// Usage
import { COLLECTIONS } from './collections';
const clientsRef = collection(db, COLLECTIONS.CLIENTS);
const contactsRef = collection(db, `${COLLECTIONS.CLIENTS}/${clientId}/contacts`);
```
### 4. Activity Logging Pattern
**Use Case:** Track all important actions for audit trails and user activity history
```typescript
// lib/firebase/activity-logger.ts
import { collection, addDoc } from 'firebase/firestore';
import { db } from './config';
import { COLLECTIONS } from './collections';
export type ActivityType =
| 'client-created'
| 'client-updated'
| 'client-deleted'
| 'document-uploaded'
| 'document-deleted'
| 'invoice-created'
| 'invoice-updated'
| 'invoice-deleted'
| 'invoice-sent'
| 'invoice-paid'
| 'project-created'
| 'project-updated'
| 'payment-received'
| 'note-added';
export interface ActivityLog {
id?: string;
clientId?: string;
type: ActivityType;
title: string;
description?: string;
relatedEntityType?: 'document' | 'invoice' | 'project' | 'note' | 'payment';
relatedEntityId?: string;
changes?: Record<string, { old: any; new: any }>;
createdAt: string;
createdBy: string;
userName?: string;
metadata?: Record<string, any>;
}
/**
* Log an activity
*/
export async function logActivity(
activity: Omit<ActivityLog, 'id' | 'createdAt'>
): Promise<string> {
try {
const activityRef = collection(db, COLLECTIONS.CLIENT_ACTIVITIES);
const docRef = await addDoc(activityRef, {
...activity,
createdAt: new Date().toISOString(),
});
return docRef.id;
} catch (error) {
console.error('Error logging activity:', error);
// Don't throw - activity logging shouldn't break main flow
return '';
}
}
/**
* Helper to track field changes
*/
export function getFieldChanges<T extends Record<string, any>>(
oldData: T,
newData: T
): Record<string, { old: any; new: any }> {
const changes: Record<string, { old: any; new: any }> = {};
Object.keys(newData).forEach(key => {
if (oldData[key] !== newData[key]) {
changes[key] = {
old: oldData[key],
new: newData[key],
};
}
});
return changes;
}
/**
* Example: Log client update
*/
async function updateClient(clientId: string, oldData: Client, newData: Client, userId: string) {
// Update client
await updateDoc(doc(db, COLLECTIONS.CLIENTS, clientId), newData);
// Log activity
const changes = getFieldChanges(oldData, newData);
await logActivity({
clientId,
type: 'client-updated',
title: `Client ${newData.name} updated`,
description: `Updated ${Object.keys(changes).join(', ')}`,
changes,
createdBy: userId,
userName: 'John Doe', // Get from user context
});
}
/**
* Get activities for a client
*/
export async function getClientActivities(
clientId: string,
limitCount: number = 50
) {
try {
const activitiesRef = collection(db, COLLECTIONS.CLIENT_ACTIVITIES);
const q = query(
activitiesRef,
where('clientId', '==', clientId),
orderBy('createdAt', 'desc'),
limit(limitCount)
);
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as ActivityLog[];
} catch (error) {
console.error('Error getting activities:', error);
throw error;
}
}
```
### 5. Batch Operations
**Use Case:** Optimize multiple writes for better performance
```typescript
import { writeBatch, doc } from 'firebase/firestore';
import { db } from './config';
/**
* Batch update multiple documents
* Use when: updating 2-500 documents at once
* Limitation: Max 500 operations per batch
*/
export async function batchUpdateClients(
updates: Array<{ id: string; data: Partial<Client> }>
) {
try {
const batch = writeBatch(db);
updates.forEach(({ id, data }) => {
const clientRef = doc(db, COLLECTIONS.CLIENTS, id);
batch.update(clientRef, {
...data,
updatedAt: new Date().toISOString()
});
});
await batch.commit();
console.log(`Successfully updated ${updates.length} clients`);
} catch (error) {
console.error('Error batch updating clients:', error);
throw error;
}
}
/**
* Batch delete with activity logging
*/
export async function batchDeleteInvoices(
invoiceIds: string[],
userId: string
) {
try {
// Firestore batch limit: 500 operations
const chunkSize = 500;
for (let i = 0; i < invoiceIds.length; i += chunkSize) {
const chunk = invoiceIds.slice(i, i + chunkSize);
const batch = writeBatch(db);
chunk.forEach(invoiceId => {
const invoiceRef = doc(db, COLLECTIONS.CLIENT_INVOICES, invoiceId);
batch.delete(invoiceRef);
});
await batch.commit();
}
// Log activity
await logActivity({
type: 'invoice-deleted',
title: `Deleted ${invoiceIds.length} invoices`,
createdBy: userId,
});
console.log(`Successfully deleted ${invoiceIds.length} invoices`);
} catch (error) {
console.error('Error batch deleting invoices:', error);
throw error;
}
}
/**
* Batch create with generated IDs
*/
export async function batchCreateExpenses(
expenses: Array<Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>>,
userId: string
) {
try {
const batch = writeBatch(db);
const expenseIds: string[] = [];
expenses.forEach(expense => {
const expenseRef = doc(collection(db, COLLECTIONS.EXPENSES));
expenseIds.push(expenseRef.id);
batch.set(expenseRef, {
...expense,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: userId,
});
});
await batch.commit();
return expenseIds;
} catch (error) {
console.error('Error batch creating expenses:', error);
throw error;
}
}
```
**Batch Operation Guidelines:**
- ✅ Use for 2-500 operations
- ✅ All operations succeed or all fail (atomic)
- ✅ Better performance than individual writes
- ❌ Max 500 operations per batch
- ❌ Can't read documents within batch
- ❌ For reads + writes, use transactions
### 6. Query Optimization Patterns
```typescript
/**
* Efficient pagination with cursor-based approach
*/
export async function getClientsPaginated(
pageSize: number = 20,
lastClientId?: string
) {
try {
const clientsRef = collection(db, COLLECTIONS.CLIENTS);
let q = query(
clientsRef,
orderBy('createdAt', 'desc'),
limit(pageSize)
);
if (lastClientId) {
const lastDoc = await getDoc(doc(db, COLLECTIONS.CLIENTS, lastClientId));
if (lastDoc.exists()) {
q = query(q, startAfter(lastDoc));
}
}
const snapshot = await getDocs(q);
return {
clients: snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })),
lastClientId: snapshot.docs[snapshot.docs.length - 1]?.id,
hasMore: snapshot.docs.length === pageSize,
};
} catch (error) {
console.error('Error getting clients:', error);
throw error;
}
}
/**
* Composite queries with multiple filters
*/
export async function searchInvoices(filters: {
status?: string;
minAmount?: number;
startDate?: Date;
endDate?: Date;
}) {
try {
const invoicesRef = collection(db, COLLECTIONS.CLIENT_INVOICES);
const constraints: QueryConstraint[] = [];
if (filters.status) {
constraints.push(where('status', '==', filters.status));
}
if (filters.minAmount) {
constraints.push(where('totalAmount', '>=', filters.minAmount));
}
if (filters.startDate) {
constraints.push(where('issueDate', '>=', filters.startDate.toISOString()));
}
if (filters.endDate) {
constraints.push(where('issueDate', '<=', filters.endDate.toISOString()));
}
constraints.push(orderBy('issueDate', 'desc'));
const q = query(invoicesRef, ...constraints);
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
} catch (error) {
console.error('Error searching invoices:', error);
throw error;
}
}
```
## Best Practices
### Do's ✅
- **Use transactions** for operations requiring uniqueness or atomic updates
- **Use sub-collections** for one-to-many relationships with many items
- **Log activities** for audit trails and user history
- **Use batch operations** for multiple writes (2-500 operations)
- **Centralize collection names** with constants
- **Handle transaction conflicts** with retry logic
- **Keep activity logging non-blocking** (don't throw errors)
### Don'ts ❌
- **Don't use transactions** for simple single-document operations
- **Don't embed large arrays** that could exceed Firestore's 1MB limit
- **Don't forget to add indexes** for complex queries
- **Don't batch more than 500 operations** at once
- **Don't read then write** without transactions for critical data
- **Don't ignore Firestore limits** (1MB doc size, 500 batch operations)
## Performance Tips
1. **Index Optimization**
- Create composite indexes for multi-field queries
- Check Firestore console for index suggestions
2. **Document Size**
- Keep documents under 1MB
- Use sub-collections for large datasets
- Avoid large embedded arrays
3. **Query Efficiency**
- Use cursor-based pagination (not offset)
- Limit results appropriately
- Order by indexed fields
4. **Batch Operations**
- Use batches for 2-500 operations
- Chunk large operations
- Consider background processing for very large batches
---
**Last Updated:** 2025-01-10
**Maintained By:** Development Team