Back to catalog

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