npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@spacelabstech/firestoreorm

v1.1.0

Published

Type-safe Firestore ORM with validation, soft deletes, lifecycle hooks, and powerful query builder. Built for Firebase Admin SDK with full TypeScript support.

Downloads

47

Readme

@spacelabstech/firestoreorm

A type-safe, feature-rich Firestore ORM built for the Firebase Admin SDK. Designed to make backend Firestore development actually enjoyable.

npm version License: MIT TypeScript

Table of Contents

The Story Behind This ORM

Hi, I'm Happy Banda (HBFL3Xx), and I've been working with Firestore on the backend for years. If you've ever built a production Node.js application with Firestore, you know the pain points:

  • Writing the same boilerplate CRUD operations over and over
  • Managing soft deletes manually across every collection
  • Implementing pagination consistently
  • Dealing with Firestore's composite index errors at runtime
  • Validating data before writes without a clean pattern
  • No lifecycle hooks for logging, auditing, or side effects
  • Query builders that don't feel natural or type-safe

I tried other Firestore ORMs. Some were abandoned, others lacked essential features like soft deletes or transactions, and many used patterns that didn't fit real-world backend development. Some required too much ceremony, while others were too minimal to be practical.

So I built this. FirestoreORM is the tool I wish I had from day one. It's designed for developers who want to move fast without sacrificing code quality, type safety, or maintainability. Whether you're building a startup MVP or scaling an enterprise application, this ORM grows with you.

Why FirestoreORM?

Built for Real Production Use

  • Type-Safe Everything - Full TypeScript support with intelligent inference
  • Zod Validation - Schema validation that integrates seamlessly with your data layer
  • Soft Deletes Built-In - Never lose data accidentally; recover when you need to
  • Lifecycle Hooks - Add logging, analytics, or side effects without cluttering your business logic
  • Powerful Query Builder - Intuitive, chainable queries with pagination, aggregation, and streaming
  • Transaction Support - ACID guarantees for critical operations
  • Subcollection Support - Navigate document hierarchies naturally
  • Dot Notation Updates - Update nested fields without replacing entire objects
  • Zero Vendor Lock-In - Built on Firebase Admin SDK; works with any Node.js framework

Framework Agnostic

Works seamlessly with:

  • Express.js
  • NestJS (with DTOs and dependency injection)
  • Fastify
  • Koa
  • Next.js API routes
  • Any Node.js environment

Installation

npm install @spacelabstech/firestoreorm firebase-admin zod
yarn add @spacelabstech/firestoreorm firebase-admin zod
pnpm add @spacelabstech/firestoreorm firebase-admin zod

Peer Dependencies

  • firebase-admin: ^12.0.0 || ^13.0.0
  • zod: ^3.0.0 || ^4.0.0

Quick Start

1. Initialize Firebase Admin

import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';

const app = initializeApp({
  credential: cert('./serviceAccountKey.json')
});

export const db = getFirestore(app);

2. Define Your Schema

import { z } from 'zod';

export const userSchema = z.object({
  id: z.string().optional(), // include id in every schema you create but it can be optional
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email address'),
  age: z.number().int().positive().optional(),
  status: z.enum(['active', 'inactive', 'suspended']).default('active'),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
});

export type User = z.infer<typeof userSchema>;

3. Create Your Repository

import { FirestoreRepository } from '@spacelabstech/firestoreorm';
import { db } from './firebase';
import { userSchema, User } from './schemas';

export const userRepo = FirestoreRepository.withSchema<User>(
  db,
  'users',
  userSchema
);

4. Start Building

// Create a user
const user = await userRepo.create({
  name: 'John Doe',
  email: '[email protected]',
  age: 30,
  status: 'active',
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString()
});

// Query users
const activeUsers = await userRepo.query()
  .where('status', '==', 'active')
  .where('age', '>', 18)
  .orderBy('createdAt', 'desc')
  .limit(10)
  .get();

// Update a user
await userRepo.update(user.id, {
  status: 'inactive',
  updatedAt: new Date().toISOString()
});

// Soft delete (recoverable)
await userRepo.softDelete(user.id);

// Restore if needed
await userRepo.restore(user.id);

Core Concepts

Repository Pattern

The repository abstracts Firestore operations behind a clean, consistent API. Each collection gets its own repository instance.

// Initialize once, use everywhere
const userRepo = FirestoreRepository.withSchema<User>(db, 'users', userSchema);
const orderRepo = FirestoreRepository.withSchema<Order>(db, 'orders', orderSchema);
const productRepo = new FirestoreRepository<Product>(db, 'products'); // Without validation

Soft Deletes

Soft deletes are enabled by default. When you delete a document, it's marked with a deletedAt timestamp instead of being permanently removed. This allows you to:

  • Recover accidentally deleted data
  • Maintain referential integrity
  • Audit deletion history
  • Comply with data retention policies
// Soft delete - document stays in Firestore
await userRepo.softDelete('user-123');

// Document is excluded from queries by default
const user = await userRepo.getById('user-123'); // null

// But can be retrieved with includeDeleted flag
const deletedUser = await userRepo.getById('user-123', true);

// Restore when needed
await userRepo.restore('user-123');

// Or permanently delete later
await userRepo.purgeDelete(); // Removes all soft-deleted docs

Under the Hood: Every repository operation adds a deletedAt: null field on creation. Queries automatically add .where('deletedAt', '==', null) unless you explicitly include deleted documents.

Schema Validation

Validation happens automatically before any write operation using Zod schemas.

const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive().optional()
});

const userRepo = FirestoreRepository.withSchema<User>(db, 'users', userSchema);

try {
  await userRepo.create({
    name: '',
    email: 'not-an-email',
    age: -5
  });
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.issues);
    // [
    //   { path: ['name'], message: 'String must not be empty' },
    //   { path: ['email'], message: 'Invalid email' },
    //   { path: ['age'], message: 'Must be positive' }
    // ]
  }
}

Validation Behavior:

  • create() validates against the full schema
  • update() validates against schema.partial() (all fields optional)
  • Validation errors are thrown before any Firestore write occurs

Lifecycle Hooks

Hooks allow you to inject custom logic at specific points in the data lifecycle without cluttering your business logic.

// Log all user creations
userRepo.on('afterCreate', async (user) => {
  console.log(`User created: ${user.id}`);
  await auditLog.record('user_created', user);
});

// Send welcome email
userRepo.on('afterCreate', async (user) => {
  await sendWelcomeEmail(user.email);
});

// Validate business rules before update
orderRepo.on('beforeUpdate', (data) => {
  if (data.status === 'shipped' && !data.trackingNumber) {
    throw new Error('Tracking number required for shipped orders');
  }
});

// Clean up related data after deletion
userRepo.on('afterDelete', async (user) => {
  await orderRepo.query().where('userId', '==', user.id).delete();
});

Available Hooks:

  • Single operations: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeSoftDelete, afterSoftDelete, beforeRestore, afterRestore
  • Bulk operations: beforeBulkCreate, afterBulkCreate, beforeBulkUpdate, afterBulkUpdate, beforeBulkDelete, afterBulkDelete, beforeBulkSoftDelete, afterBulkSoftDelete, beforeBulkRestore, afterBulkRestore

Query Builder

The query builder provides a fluent, type-safe interface for complex queries.

const results = await orderRepo.query()
  .where('status', '==', 'pending')
  .where('total', '>', 100)
  .where('createdAt', '>=', startOfMonth)
  .orderBy('total', 'desc')
  .limit(50)
  .get();

Performance Note: Firestore charges you per document read. Use limit() and pagination to control costs on large collections.

Complete Feature Guide

CRUD Operations

// CREATE
const user = await userRepo.create({
  name: 'Alice',
  email: '[email protected]'
});

// READ
const user = await userRepo.getById('user-123');
const users = await userRepo.list(20); // First 20 docs
const usersByEmail = await userRepo.findByField('email', '[email protected]');

// UPDATE
await userRepo.update('user-123', {
  name: 'Alice Updated'
});

// UPSERT (create if doesn't exist, update if exists)
await userRepo.upsert('user-123', {
  name: 'Alice',
  email: '[email protected]'
});

// DELETE
await userRepo.delete('user-123'); // Hard delete
await userRepo.softDelete('user-123'); // Soft delete
await userRepo.restore('user-123'); // Restore soft-deleted

Bulk Operations

Bulk operations use Firestore batch writes (max 500 operations per batch). The ORM automatically chunks operations if you exceed this limit.

// Bulk create
const users = await userRepo.bulkCreate([
  { name: 'Alice', email: '[email protected]' },
  { name: 'Bob', email: '[email protected]' },
  { name: 'Charlie', email: '[email protected]' }
]);

// Bulk update
await userRepo.bulkUpdate([
  { id: 'user-1', data: { status: 'active' } },
  { id: 'user-2', data: { status: 'inactive' } }
]);

// Bulk delete
await userRepo.bulkDelete(['user-1', 'user-2', 'user-3']);

// Bulk soft delete
await userRepo.bulkSoftDelete(['user-4', 'user-5']);

Performance Tip: For simple bulk updates on query results, use query().update() instead:

// More efficient - single query + batched writes
await orderRepo.query()
  .where('status', '==', 'pending')
  .update({ status: 'shipped' });

// Less efficient - fetches all IDs first, then updates
const orders = await orderRepo.query().where('status', '==', 'pending').get();
await orderRepo.bulkUpdate(orders.map(o => ({ id: o.id, data: { status: 'shipped' } })));

Advanced Queries

// Filtering
const results = await userRepo.query()
  .where('age', '>', 18)
  .where('status', 'in', ['active', 'verified'])
  .where('tags', 'array-contains', 'premium')
  .get();

// Sorting
const sorted = await productRepo.query()
  .orderBy('price', 'desc')
  .orderBy('name', 'asc')
  .get();

// Pagination (cursor-based, recommended)
const { items, nextCursorId } = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20);

// Next page
const nextPage = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20, nextCursorId);

// Offset pagination (less efficient for large datasets)
const page2 = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .offsetPaginate(2, 20);

// Aggregations
const totalRevenue = await orderRepo.query()
  .where('status', '==', 'completed')
  .aggregate('total', 'sum');

const avgRating = await reviewRepo.query()
  .where('productId', '==', 'prod-123')
  .aggregate('rating', 'avg');

// Count
const activeCount = await userRepo.query()
  .where('status', '==', 'active')
  .count();

// Exists check
const hasOrders = await orderRepo.query()
  .where('userId', '==', 'user-123')
  .exists();

// Distinct values
const categories = await productRepo.query().distinctValues('category');

// Select specific fields
const userEmails = await userRepo.query()
  .where('subscribed', '==', true)
  .select('email', 'name')
  .get();

Query Operations

// Update all matching documents
const updatedCount = await orderRepo.query()
  .where('status', '==', 'pending')
  .update({ status: 'processing' });

// Delete all matching documents
const deletedCount = await userRepo.query()
  .where('lastLogin', '<', oneYearAgo)
  .delete();

// Soft delete matching documents
await orderRepo.query()
  .where('status', '==', 'cancelled')
  .where('createdAt', '<', sixMonthsAgo)
  .softDelete();

Streaming for Large Datasets

When processing large datasets, streaming prevents memory issues by processing documents one at a time.

// Stream all users without loading into memory
for await (const user of userRepo.query().stream()) {
  await sendEmail(user.email);
  console.log(`Processed user ${user.id}`);
}

// Stream with filters
for await (const order of orderRepo.query()
  .where('status', '==', 'pending')
  .stream()) {
  await processOrder(order);
}

Performance Cost: Streaming still reads all matching documents, so you're charged for every document read. Use with appropriate filters and limits.

Real-Time Subscriptions

// Subscribe to query results
const unsubscribe = await orderRepo.query()
  .where('status', '==', 'active')
  .onSnapshot(
    (orders) => {
      console.log(`Active orders: ${orders.length}`);
      updateDashboard(orders);
    },
    (error) => {
      console.error('Snapshot error:', error);
    }
  );

// Stop listening when done
unsubscribe();

Cost Warning: Real-time listeners charge you for every document that matches your query, plus additional reads when documents change. Use narrow filters and consider polling for less critical data.

Transactions

Transactions ensure atomic operations across multiple documents. Use them when consistency is critical (e.g., transferring balances, inventory management).

await accountRepo.runInTransaction(async (tx, repo) => {
  const from = await repo.getForUpdate(tx, 'account-1');
  const to = await repo.getForUpdate(tx, 'account-2');

  if (!from || from.balance < 100) {
    throw new Error('Insufficient funds');
  }

  await repo.updateInTransaction(tx, from.id, {
    balance: from.balance - 100
  });
  
  await repo.updateInTransaction(tx, to.id, {
    balance: to.balance + 100
  });
});

Important Transaction Limitations:

  1. No after Hooks: Lifecycle hooks like afterCreate, afterUpdate, afterDelete do NOT run inside transactions. Only before hooks execute. This is a Firestore limitation since transactions need to be atomic and cannot have side effects that might fail.

  2. Use Cases for Transaction Hooks:

    // WORKS - beforeUpdate runs before transaction commits
    orderRepo.on('beforeUpdate', (data) => {
      if (data.quantity < 0) {
        throw new Error('Negative quantity not allowed');
      }
    });
    
    // DOES NOT WORK - afterUpdate won't run in transaction
    orderRepo.on('afterUpdate', async (order) => {
      await sendEmail(order.email); // This will NOT execute
    });
  3. Solution for Post-Transaction Side Effects:

    const result = await accountRepo.runInTransaction(async (tx, repo) => {
      // ... transaction logic
      return { from, to };
    });
    
    // Run side effects AFTER transaction succeeds
    await auditLog.record('transfer_completed', result);
    await sendEmail(result.from.email);

Subcollections

Navigate document hierarchies naturally:

// Access user's orders
const userOrders = userRepo.subcollection<Order>('user-123', 'orders', orderSchema);

// Create order in subcollection
const order = await userOrders.create({
  product: 'Widget',
  price: 99.99
});

// Query subcollection
const recentOrders = await userOrders.query()
  .where('status', '==', 'completed')
  .orderBy('createdAt', 'desc')
  .limit(10)
  .get();

// Nested subcollections
const comments = postRepo
  .subcollection<Comment>('post-123', 'comments')
  .subcollection<Reply>('comment-456', 'replies');

// Get parent ID
const parentId = userOrders.getParentId(); // 'user-123'

Dot Notation for Nested Updates

FirestoreORM supports Firestore's dot notation syntax for updating nested fields without replacing entire objects. This allows you to update specific nested properties while preserving other fields.

Basic Nested Update

// Without dot notation - replaces entire address object
await userRepo.update('user-123', {
  address: {
    city: 'Los Angeles'
  }
});
// Result: { address: { city: 'Los Angeles' } }
// street, zipCode, and other fields are lost

// With dot notation - updates only city, preserves other fields
await userRepo.update('user-123', {
  'address.city': 'Los Angeles'
} as any);
// Result: { address: { city: 'Los Angeles', street: '123 Main', zipCode: '90001' } }
// street and zipCode are preserved

Deep Nested Updates

// Update deeply nested settings
await userRepo.update('user-123', {
  'profile.settings.notifications.email': true,
  'profile.settings.theme': 'dark'
} as any);

// Creates nested structure if it doesn't exist
await userRepo.update('user-123', {
  'metadata.preferences.language': 'en',
  'metadata.preferences.timezone': 'UTC'
} as any);

Mixed Updates

// Combine regular fields with dot notation
await userRepo.update('user-123', {
  name: 'John Doe',                    // Regular field
  'address.city': 'New York',          // Nested field
  'address.zipCode': '10001',          // Another nested field
  'profile.verified': true             // Different nested object
} as any);

Bulk Updates with Dot Notation

// Bulk update nested fields
await userRepo.bulkUpdate([
  { 
    id: 'user-1', 
    data: { 
      'profile.verified': true,
      'settings.notifications': false 
    } as any 
  },
  { 
    id: 'user-2', 
    data: { 
      'profile.verified': true 
    } as any 
  }
]);

Query Updates with Dot Notation

// Update nested fields for all matching documents
await userRepo.query()
  .where('role', '==', 'admin')
  .update({
    'permissions.canDelete': true,
    'permissions.canEdit': true
  } as any);

// Update deeply nested analytics
await postRepo.query()
  .where('published', '==', true)
  .update({
    'analytics.impressions': 0,
    'analytics.lastUpdated': new Date().toISOString()
  } as any);

Transactions with Dot Notation

await userRepo.runInTransaction(async (tx, repo) => {
  // Read the document first (required for transactions)
  const user = await repo.getForUpdate(tx, 'user-123');
  
  if (!user) {
    throw new Error('User not found');
  }
  
  // Update nested fields - pass existing data as third parameter
  await repo.updateInTransaction(tx, 'user-123', {
    'settings.theme': 'dark',
    'profile.lastLogin': new Date().toISOString()
  } as any, user);
});

Important Notes

1. Type Casting Required

TypeScript requires as any for dot notation keys since they're dynamic strings:

// Required type assertion
await userRepo.update('user-123', {
  'address.city': 'NYC'
} as any);

2. Path Validation

Dot notation paths are validated to prevent errors:

// Invalid paths (will throw error)
'address..city'     // Empty parts (consecutive dots)
'.address.city'     // Starts with dot
'address.city.'     // Ends with dot
''                  // Empty string

3. Firestore Limitations

  • Undefined values are automatically filtered out (Firestore doesn't accept undefined)
  • Use null if you need to explicitly clear a field value
// Undefined is filtered out, original value preserved
await userRepo.update('user-123', {
  'address.city': undefined
} as any);

// Use null to clear a field
await userRepo.update('user-123', {
  'address.city': null
} as any);

4. Transaction Requirements

When using dot notation in transactions, you must:

  • Call getForUpdate() first to read the document
  • Pass the existing data as the third parameter to updateInTransaction()
  • This ensures proper read-before-write transaction semantics
// Correct - read first, pass existing data
await repo.runInTransaction(async (tx, repo) => {
  const doc = await repo.getForUpdate(tx, 'doc-123');
  await repo.updateInTransaction(tx, 'doc-123', {
    'nested.field': 'value'
  } as any, doc);
});

// Wrong - will throw error
await repo.runInTransaction(async (tx, repo) => {
  await repo.updateInTransaction(tx, 'doc-123', {
    'nested.field': 'value'
  } as any); // Missing existing data parameter
});

Use Cases

User Preferences

Update specific settings without replacing all preferences:

await userRepo.update('user-123', {
  'preferences.emailNotifications': true,
  'preferences.theme': 'dark'
} as any);

Nested Configurations

Modify individual config values in complex objects:

await configRepo.update('app-config', {
  'features.darkMode.enabled': true,
  'features.darkMode.autoSwitch': true,
  'features.analytics.trackingId': 'GA-123456'
} as any);

Analytics Counters

Update nested counter fields:

await postRepo.update('post-123', {
  'analytics.views': 150,
  'analytics.likes': 42,
  'analytics.shares': 8
} as any);

Status Updates

Update status in nested workflow objects:

await orderRepo.update('order-123', {
  'workflow.payment.status': 'completed',
  'workflow.payment.completedAt': new Date().toISOString(),
  'workflow.fulfillment.status': 'pending'
} as any);

Partial Address Updates

Update only changed address fields:

await userRepo.update('user-123', {
  'shippingAddress.street': '456 New Street',
  'shippingAddress.apt': '10B'
  // city, state, zipCode remain unchanged
} as any);

Error Handling

import { 
  ValidationError, 
  NotFoundError, 
  ConflictError,
  FirestoreIndexError 
} from '@spacelabstech/firestoreorm';

try {
  await userRepo.create(invalidData);
} catch (error) {
  if (error instanceof ValidationError) {
    // Handle validation errors
    error.issues.forEach(issue => {
      console.log(`${issue.path}: ${issue.message}`);
    });
  } else if (error instanceof NotFoundError) {
    // Handle not found
    console.log('Document not found');
  } else if (error instanceof FirestoreIndexError) {
    // Handle missing composite index
    console.log(error.toString()); // Includes link to create index
  }
}

Express Error Handler

The ORM includes a pre-built Express middleware for consistent error responses:

import { errorHandler } from '@spacelabstech/firestoreorm';
import express from 'express';

const app = express();

// ... your routes

// Register as last middleware
app.use(errorHandler);

This automatically maps errors to HTTP status codes:

  • ValidationError → 400 Bad Request
  • NotFoundError → 404 Not Found
  • ConflictError → 409 Conflict
  • FirestoreIndexError → 400 Bad Request (with index URL)
  • Others → 500 Internal Server Error

Framework Integration

Express.js

Basic Setup

// repositories/user.repository.ts
import { FirestoreRepository } from '@spacelabstech/firestoreorm';
import { db } from '../config/firebase';
import { userSchema, User } from '../schemas/user.schema';

export const userRepo = FirestoreRepository.withSchema<User>(
  db,
  'users',
  userSchema
);
// routes/user.routes.ts
import express from 'express';
import { userRepo } from '../repositories/user.repository';
import { ValidationError, NotFoundError } from '@spacelabstech/firestoreorm';

const router = express.Router();

router.post('/users', async (req, res, next) => {
  try {
    const user = await userRepo.create({
      ...req.body,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    });
    res.status(201).json(user);
  } catch (error) {
    next(error); // errorHandler middleware will process this
  }
});

router.get('/users', async (req, res, next) => {
  try {
    const { page = 1, limit = 20, status } = req.query;
    
    let query = userRepo.query();
    
    if (status) {
      query = query.where('status', '==', status);
    }
    
    const result = await query
      .orderBy('createdAt', 'desc')
      .offsetPaginate(Number(page), Number(limit));
    
    res.json(result);
  } catch (error) {
    next(error);
  }
});

router.get('/users/:id', async (req, res, next) => {
  try {
    const user = await userRepo.getById(req.params.id);
    
    if (!user) {
      throw new NotFoundError(`User with id ${req.params.id} not found`);
    }
    
    res.json(user);
  } catch (error) {
    next(error);
  }
});

router.patch('/users/:id', async (req, res, next) => {
  try {
    const user = await userRepo.update(req.params.id, {
      ...req.body,
      updatedAt: new Date().toISOString()
    });
    res.json(user);
  } catch (error) {
    next(error);
  }
});

router.delete('/users/:id', async (req, res, next) => {
  try {
    await userRepo.softDelete(req.params.id);
    res.status(204).send();
  } catch (error) {
    next(error);
  }
});

export default router;
// app.ts
import express from 'express';
import { errorHandler } from '@spacelabstech/firestoreorm';
import userRoutes from './routes/user.routes';

const app = express();

app.use(express.json());
app.use('/api', userRoutes);
app.use(errorHandler); // Must be last

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

NestJS Integration

NestJS users often work with DTOs for request validation. Here's how to integrate with the ORM's Zod schemas:

Shared Schema Strategy

// schemas/user.schema.ts
import { z } from 'zod';

export const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
  status: z.enum(['active', 'inactive', 'suspended']),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
});

export type User = z.infer<typeof userSchema>;

// DTOs for NestJS (derived from same schema)
export const createUserSchema = userSchema.omit({ createdAt: true, updatedAt: true });
export const updateUserSchema = createUserSchema.partial();

export type CreateUserDto = z.infer<typeof createUserSchema>;
export type UpdateUserDto = z.infer<typeof updateUserSchema>;

Repository Module

// modules/database/database.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore, Firestore } from 'firebase-admin/firestore';

@Global()
@Module({
  providers: [
    {
      provide: 'FIRESTORE',
      useFactory: (config: ConfigService) => {
        const app = initializeApp({
          credential: cert(config.get('firebase.serviceAccount'))
        });
        return getFirestore(app);
      },
      inject: [ConfigService]
    }
  ],
  exports: ['FIRESTORE']
})
export class DatabaseModule {}
// modules/user/user.repository.ts
import { Injectable, Inject } from '@nestjs/common';
import { Firestore } from 'firebase-admin/firestore';
import { FirestoreRepository } from '@spacelabstech/firestoreorm';
import { User, userSchema } from '../../schemas/user.schema';

@Injectable()
export class UserRepository {
  private repo: FirestoreRepository<User>;

  constructor(@Inject('FIRESTORE') private firestore: Firestore) {
    this.repo = FirestoreRepository.withSchema<User>(
      firestore,
      'users',
      userSchema
    );

    // Setup hooks
    this.setupHooks();
  }

  private setupHooks() {
    this.repo.on('afterCreate', async (user) => {
      console.log(`User created: ${user.id}`);
    });
  }

  async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>) {
    return this.repo.create({
      ...data,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    });
  }

  async findById(id: string) {
    return this.repo.getById(id);
  }

  async update(id: string, data: Partial<User>) {
    return this.repo.update(id, {
      ...data,
      updatedAt: new Date().toISOString()
    });
  }

  async softDelete(id: string) {
    return this.repo.softDelete(id);
  }

  query() {
    return this.repo.query();
  }
}

Service Layer

// modules/user/user.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { CreateUserDto, UpdateUserDto } from '../../schemas/user.schema';
import { NotFoundError } from '@spacelabstech/firestoreorm';

@Injectable()
export class UserService {
  constructor(private userRepository: UserRepository) {}

  async create(dto: CreateUserDto) {
    return this.userRepository.create(dto);
  }

  async findOne(id: string) {
    const user = await this.userRepository.findById(id);
    
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    
    return user;
  }

  async findActive(page: number = 1, limit: number = 20) {
    return this.userRepository.query()
      .where('status', '==', 'active')
      .orderBy('createdAt', 'desc')
      .offsetPaginate(page, limit);
  }

  async update(id: string, dto: UpdateUserDto) {
    try {
      return await this.userRepository.update(id, dto);
    } catch (error) {
      if (error instanceof NotFoundError) {
        throw new NotFoundException(error.message);
      }
      throw error;
    }
  }

  async remove(id: string) {
    await this.userRepository.softDelete(id);
  }
}

Controller with Validation Pipe

// modules/user/user.controller.ts
import { 
  Controller, 
  Get, 
  Post, 
  Body, 
  Param, 
  Patch, 
  Delete,
  Query,
  UsePipes
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto, UpdateUserDto } from '../../schemas/user.schema';
import { ZodValidationPipe } from '../../pipes/zod-validation.pipe';
import { createUserSchema, updateUserSchema } from '../../schemas/user.schema';

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  @UsePipes(new ZodValidationPipe(createUserSchema))
  create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto);
  }

  @Get()
  findAll(
    @Query('page') page: string = '1',
    @Query('limit') limit: string = '20'
  ) {
    return this.userService.findActive(Number(page), Number(limit));
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.userService.findOne(id);
  }

  @Patch(':id')
  @UsePipes(new ZodValidationPipe(updateUserSchema))
  update(
    @Param('id') id: string,
    @Body() updateUserDto: UpdateUserDto
  ) {
    return this.userService.update(id, updateUserDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.userService.remove(id);
  }
}

Zod Validation Pipe (Optional - since ORM validates)

// pipes/zod-validation.pipe.ts
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown) {
    try {
      return this.schema.parse(value);
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }
}

Exception Filter for ORM Errors

// filters/firestore-exception.filter.ts
import { 
  ExceptionFilter, 
  Catch, 
  ArgumentsHost, 
  HttpStatus 
} from '@nestjs/common';
import { Response } from 'express';
import { 
  ValidationError, 
  NotFoundError, 
  ConflictError 
} from '@spacelabstech/firestoreorm';

@Catch(ValidationError, NotFoundError, ConflictError)
export class FirestoreExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    if (exception instanceof ValidationError) {
      response.status(HttpStatus.BAD_REQUEST).json({
        statusCode: HttpStatus.BAD_REQUEST,
        error: 'Validation Error',
        details: exception.issues
      });
    } else if (exception instanceof NotFoundError) {
      response.status(HttpStatus.NOT_FOUND).json({
        statusCode: HttpStatus.NOT_FOUND,
        error: 'Not Found',
        message: exception.message
      });
    } else if (exception instanceof ConflictError) {
      response.status(HttpStatus.CONFLICT).json({
        statusCode: HttpStatus.CONFLICT,
        error: 'Conflict',
        message: exception.message
      });
    }
  }
}

Register Filter Globally

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { FirestoreExceptionFilter } from './filters/firestore-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.useGlobalFilters(new FirestoreExceptionFilter());
  
  await app.listen(3000);
}
bootstrap();

Best Practices

1. Initialize Repositories Once

Create repository instances once and reuse them throughout your application. Don't create new instances in every function.

// ❌ Bad - Creates new instance every time
export function getUserRepository() {
  return FirestoreRepository.withSchema<User>(db, 'users', userSchema);
}

// ✅ Good - Single instance, reused everywhere
export const userRepo = FirestoreRepository.withSchema<User>(
  db,
  'users',
  userSchema
);

Why: Repository initialization is lightweight, but creating instances repeatedly is unnecessary and makes hook management inconsistent.

2. Organize Repositories in a Centralized Module

// repositories/index.ts
import { db } from '../config/firebase';
import { FirestoreRepository } from '@spacelabstech/firestoreorm';
import * as schemas from '../schemas';

export const userRepo = FirestoreRepository.withSchema<schemas.User>(
  db,
  'users',
  schemas.userSchema
);

export const orderRepo = FirestoreRepository.withSchema<schemas.Order>(
  db,
  'orders',
  schemas.orderSchema
);

export const productRepo = FirestoreRepository.withSchema<schemas.Product>(
  db,
  'products',
  schemas.productSchema
);

// Setup common hooks
userRepo.on('afterCreate', async (user) => {
  await auditLog.record('user_created', user);
});

orderRepo.on('afterCreate', async (order) => {
  await notificationService.sendOrderConfirmation(order);
});

3. Use Cursor-Based Pagination Over Offset

For large datasets, cursor-based pagination is significantly more efficient than offset pagination.

// ✅ Good - Cursor-based (scales well)
const { items, nextCursorId } = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20, lastCursorId);

// ❌ Avoid - Offset-based (expensive for large page numbers)
const result = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .offsetPaginate(100, 20); // Skip 1980 docs to get page 100

Why: Offset pagination requires Firestore to scan and skip all documents before your offset, while cursor pagination jumps directly to the starting position.

4. Use Query Updates for Bulk Operations

When updating multiple documents based on a condition, use query().update() instead of fetching then updating.

// ✅ Good - Single query, batched writes
await orderRepo.query()
  .where('status', '==', 'pending')
  .where('createdAt', '<', cutoffDate)
  .update({ status: 'expired' });

// ❌ Less efficient - Two operations
const orders = await orderRepo.query()
  .where('status', '==', 'pending')
  .where('createdAt', '<', cutoffDate)
  .get();

await orderRepo.bulkUpdate(
  orders.map(o => ({ id: o.id, data: { status: 'expired' } }))
);

5. Leverage Soft Deletes

Use soft deletes by default unless you have a specific reason to permanently delete data.

// ✅ Default behavior - recoverable
await userRepo.softDelete(userId);

// Later, if needed
await userRepo.restore(userId);

// Only hard delete when absolutely necessary
await userRepo.delete(userId); // Permanent, cannot be undone

6. Add Timestamps Consistently

Always add createdAt and updatedAt timestamps to track data lifecycle.

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
});

// On create
await userRepo.create({
  ...data,
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString()
});

// On update
await userRepo.update(id, {
  ...data,
  updatedAt: new Date().toISOString()
});

7. Handle Composite Index Errors Gracefully

Firestore requires composite indexes for certain query combinations. The ORM provides clear error messages with links to create them.

try {
  const results = await orderRepo.query()
    .where('status', '==', 'pending')
    .where('total', '>', 100)
    .orderBy('createdAt', 'desc')
    .get();
} catch (error) {
  if (error instanceof FirestoreIndexError) {
    console.log(error.toString());
    // Logs formatted message with link to create index
    // Click link, wait 1-2 minutes, retry query
  }
}

8. Use Transactions for Critical Operations

Any operation requiring consistency across multiple documents should use transactions.

// ✅ Atomic transfer
await accountRepo.runInTransaction(async (tx, repo) => {
  const from = await repo.getForUpdate(tx, fromId);
  const to = await repo.getForUpdate(tx, toId);
  
  if (from.balance < amount) {
    throw new Error('Insufficient funds');
  }
  
  await repo.updateInTransaction(tx, fromId, {
    balance: from.balance - amount
  });
  
  await repo.updateInTransaction(tx, toId, {
    balance: to.balance + amount
  });
});

9. Use Streaming for Large Data Exports

When processing large datasets (exports, migrations, batch jobs), use streaming to avoid memory issues.

// ✅ Memory efficient
const csvStream = createWriteStream('users.csv');
csvStream.write('name,email,status\n');

for await (const user of userRepo.query().stream()) {
  csvStream.write(`${user.name},${user.email},${user.status}\n`);
}

csvStream.end();

10. Structure Hooks for Reusability

Keep hooks focused and modular. Avoid putting complex business logic directly in hooks.

// ✅ Good - Focused, testable
class UserNotificationService {
  async sendWelcomeEmail(user: User) {
    // Email logic here
  }
}

const notificationService = new UserNotificationService();

userRepo.on('afterCreate', async (user) => {
  await notificationService.sendWelcomeEmail(user);
});

// ❌ Bad - Business logic coupled to hook
userRepo.on('afterCreate', async (user) => {
  const template = await db.collection('templates').doc('welcome').get();
  const emailService = new EmailService(config);
  await emailService.send({
    to: user.email,
    subject: template.data().subject,
    body: template.data().body.replace('{{name}}', user.name)
  });
  await db.collection('email_logs').add({ userId: user.id, type: 'welcome' });
});

Understanding Performance Costs

Firestore Pricing Model

Firestore charges for:

  1. Document reads - Every document returned from a query
  2. Document writes - Every create, update, or delete
  3. Document deletes - Separate charge from writes
  4. Storage - Data stored in your database
  5. Network egress - Data transferred out of Google Cloud

Operation Costs

| Operation | Cost | Notes | |-----------|------|-------| | getById() | 1 read | Single document lookup | | list(100) | 100 reads | Reads up to 100 documents | | query().get() | 1 read per result | Charges for every matched document | | query().count() | 1 read per 1000 docs | Aggregation query (cheaper than fetching) | | create() | 1 write | Single write operation | | bulkCreate(100) | 100 writes | Batched but still counts as 100 writes | | update() | 1 write | Even if updating one field | | delete() | 1 delete | Permanently removes document | | softDelete() | 1 write | Updates deletedAt field | | query().update() | 1 write per match | Efficient batch update | | onSnapshot() | 1 read per doc initially + 1 read per change | Real-time listener costs |

What Happens Under the Hood

Simple Query

const users = await userRepo.query()
  .where('status', '==', 'active')
  .limit(10)
  .get();
  1. ORM adds .where('deletedAt', '==', null) automatically
  2. Firestore executes query with both conditions
  3. Returns up to 10 documents
  4. Cost: 10 reads (or fewer if less than 10 matches)

Pagination

const { items, nextCursorId } = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20, cursorId);
  1. If cursorId provided, fetches that document first (1 read)
  2. Executes query starting after cursor document
  3. Returns up to 20 documents
  4. Cost: 21 reads (20 results + 1 cursor lookup)

Bulk Create

await userRepo.bulkCreate(users); // 500 users
  1. Validates all 500 documents against schema
  2. Splits into batches of 500 operations (Firestore limit)
  3. Commits each batch sequentially
  4. Cost: 500 writes

Query Update

await orderRepo.query()
  .where('status', '==', 'pending')
  .update({ status: 'shipped' }); // 150 matches
  1. Executes query to find matching documents (150 reads)
  2. Batches updates in groups of 500
  3. Commits all updates
  4. Cost: 150 reads + 150 writes

Soft Delete

await userRepo.softDelete(userId);
  1. Fetches document to verify existence (1 read)
  2. Updates deletedAt field (1 write)
  3. Cost: 1 read + 1 write

Transaction

await accountRepo.runInTransaction(async (tx, repo) => {
  const from = await repo.getForUpdate(tx, 'acc-1');
  const to = await repo.getForUpdate(tx, 'acc-2');
  
  await repo.updateInTransaction(tx, 'acc-1', { balance: from.balance - 100 });
  await repo.updateInTransaction(tx, 'acc-2', { balance: to.balance + 100 });
});
  1. Reads both documents within transaction (2 reads)
  2. Locks both documents until transaction completes
  3. Commits both updates atomically (2 writes)
  4. Cost: 2 reads + 2 writes

Cost Optimization Tips

  1. Use count() instead of fetching when you only need quantity

    // ✅ Efficient
    const total = await userRepo.query().where('status', '==', 'active').count();
       
    // ❌ Expensive
    const users = await userRepo.query().where('status', '==', 'active').get();
    const total = users.length;
  2. Limit query results

    // Always add reasonable limits
    await userRepo.query().limit(100).get();
  3. Use exists() for presence checks

    // ✅ Reads at most 1 document
    const hasOrders = await orderRepo.query()
      .where('userId', '==', userId)
      .exists();
       
    // ❌ Reads all matching documents
    const orders = await orderRepo.query()
      .where('userId', '==', userId)
      .get();
    const hasOrders = orders.length > 0;
  4. Select specific fields to reduce bandwidth

    // Reduces network transfer (still charges for full document read)
    const emails = await userRepo.query()
      .select('email')
      .get();
  5. Be cautious with real-time listeners

    // Charges for every document on initial load + every change
    // Use narrow filters
    await orderRepo.query()
      .where('userId', '==', userId)
      .where('status', '==', 'active')
      .onSnapshot(callback);

Real-World Examples

Example 1: E-commerce Order System

// schemas/order.schema.ts
import { z } from 'zod';

export const orderItemSchema = z.object({
  id: z.string().optional(), 
  productId: z.string(),
  productName: z.string(),
  quantity: z.number().int().positive(),
  price: z.number().positive(),
  subtotal: z.number().positive()
});

export const orderSchema = z.object({
  id: z.string().optional(), 
  userId: z.string(),
  items: z.array(orderItemSchema),
  total: z.number().positive(),
  status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']),
  shippingAddress: z.object({
    street: z.string(),
    city: z.string(),
    state: z.string(),
    zipCode: z.string(),
    country: z.string()
  }),
  trackingNumber: z.string().optional(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
});

export type Order = z.infer<typeof orderSchema>;
// repositories/order.repository.ts
import { FirestoreRepository } from '@spacelabstech/firestoreorm';
import { db } from '../config/firebase';
import { orderSchema, Order } from '../schemas/order.schema';
import { inventoryService } from '../services/inventory.service';
import { emailService } from '../services/email.service';

export const orderRepo = FirestoreRepository.withSchema<Order>(
  db,
  'orders',
  orderSchema
);

// Validate inventory before creating order
orderRepo.on('beforeCreate', async (order) => {
  for (const item of order.items) {
    const available = await inventoryService.checkStock(
      item.productId,
      item.quantity
    );
    
    if (!available) {
      throw new Error(`Insufficient stock for product ${item.productName}`);
    }
  }
});

// Update inventory and send confirmation after order creation
orderRepo.on('afterCreate', async (order) => {
  // Reduce inventory
  for (const item of order.items) {
    await inventoryService.reduceStock(item.productId, item.quantity);
  }
  
  // Send confirmation email
  await emailService.sendOrderConfirmation(order);
  
  // Log for analytics
  await analytics.track('order_placed', {
    orderId: order.id,
    total: order.total,
    itemCount: order.items.length
  });
});

// Validate tracking number for shipped orders
orderRepo.on('beforeUpdate', (data) => {
  if (data.status === 'shipped' && !data.trackingNumber) {
    throw new Error('Tracking number required for shipped orders');
  }
});

// Send shipping notification
orderRepo.on('afterUpdate', async (order) => {
  if (order.status === 'shipped') {
    await emailService.sendShippingNotification(order);
  }
});
// services/order.service.ts
import { orderRepo } from '../repositories/order.repository';
import { userRepo } from '../repositories/user.repository';
import { ConflictError } from '@spacelabstech/firestoreorm';

export class OrderService {
  async createOrder(userId: string, items: OrderItem[]) {
    // Verify user exists
    const user = await userRepo.getById(userId);
    if (!user) {
      throw new ConflictError('User not found');
    }
    
    // Calculate total
    const total = items.reduce((sum, item) => sum + item.subtotal, 0);
    
    // Create order (hooks will handle inventory and emails)
    return orderRepo.create({
      userId,
      items,
      total,
      status: 'pending',
      shippingAddress: user.defaultAddress,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    });
  }
  
  async getUserOrders(userId: string, page: number = 1, limit: number = 20) {
    return orderRepo.query()
      .where('userId', '==', userId)
      .orderBy('createdAt', 'desc')
      .offsetPaginate(page, limit);
  }
  
  async updateOrderStatus(orderId: string, status: Order['status'], trackingNumber?: string) {
    return orderRepo.update(orderId, {
      status,
      trackingNumber,
      updatedAt: new Date().toISOString()
    });
  }
  
  async cancelOrder(orderId: string) {
    // Use transaction to ensure inventory is restored
    await orderRepo.runInTransaction(async (tx, repo) => {
      const order = await repo.getForUpdate(tx, orderId);
      
      if (!order) {
        throw new Error('Order not found');
      }
      
      if (order.status !== 'pending') {
        throw new Error('Only pending orders can be cancelled');
      }
      
      await repo.updateInTransaction(tx, orderId, {
        status: 'cancelled',
        updatedAt: new Date().toISOString()
      });
    });
    
    // Restore inventory after transaction (outside to avoid transaction limits)
    const order = await orderRepo.getById(orderId);
    for (const item of order!.items) {
      await inventoryService.restoreStock(item.productId, item.quantity);
    }
  }
  
  async getOrderStats(startDate: string, endDate: string) {
    const orders = await orderRepo.query()
      .where('status', '==', 'delivered')
      .where('createdAt', '>=', startDate)
      .where('createdAt', '<=', endDate)
      .get();
    
    const totalRevenue = await orderRepo.query()
      .where('status', '==', 'delivered')
      .where('createdAt', '>=', startDate)
      .where('createdAt', '<=', endDate)
      .aggregate('total', 'sum');
    
    const avgOrderValue = await orderRepo.query()
      .where('status', '==', 'delivered')
      .where('createdAt', '>=', startDate)
      .where('createdAt', '<=', endDate)
      .aggregate('total', 'avg');
    
    return {
      totalOrders: orders.length,
      totalRevenue,
      avgOrderValue
    };
  }
}

Example 2: Multi-Tenant SaaS Application

// schemas/tenant.schema.ts
export const tenantSchema = z.object({
  id: z.string().optional(), 
  name: z.string(),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  plan: z.enum(['free', 'pro', 'enterprise']),
  seats: z.number().int().positive(),
  usedSeats: z.number().int().nonnegative().default(0),
  features: z.array(z.string()),
  ownerId: z.string(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
});

export type Tenant = z.infer<typeof tenantSchema>;
// repositories/tenant.repository.ts
export const tenantRepo = FirestoreRepository.withSchema<Tenant>(
  db,
  'tenants',
  tenantSchema
);

// Ensure slug uniqueness
tenantRepo.on('beforeCreate', async (tenant) => {
  const existing = await tenantRepo.findByField('slug', tenant.slug);
  
  if (existing.length > 0) {
    throw new ConflictError('Tenant slug already exists');
  }
});

// Create default resources for new tenant
tenantRepo.on('afterCreate', async (tenant) => {
  // Create default workspace
  await workspaceRepo.create({
    tenantId: tenant.id,
    name: 'Default Workspace',
    ownerId: tenant.ownerId,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  });
  
  // Add owner as first member
  await memberRepo.create({
    tenantId: tenant.id,
    userId: tenant.ownerId,
    role: 'owner',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  });
});
// services/tenant.service.ts
export class TenantService {
  async createTenant(ownerId: string, name: string, slug: string) {
    return tenantRepo.create({
      name,
      slug,
      plan: 'free',
      seats: 5,
      usedSeats: 1,
      features: ['basic_analytics', 'api_access'],
      ownerId,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    });
  }
  
  async addMember(tenantId: string, userId: string, role: string) {
    // Use transaction to ensure seat limit
    await tenantRepo.runInTransaction(async (tx, repo) => {
      const tenant = await repo.getForUpdate(tx, tenantId);
      
      if (!tenant) {
        throw new Error('Tenant not found');
      }
      
      if (tenant.usedSeats >= tenant.seats) {
        throw new Error('Seat limit reached. Please upgrade your plan.');
      }
      
      await repo.updateInTransaction(tx, tenantId, {
        usedSeats: tenant.usedSeats + 1
      });
    });
    
    // Add member after transaction succeeds
    await memberRepo.create({
      tenantId,
      userId,
      role,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    });
  }
  
  async upgradePlan(tenantId: string, newPlan: Tenant['plan']) {
    const planSeats = {
      free: 5,
      pro: 25,
      enterprise: 100
    };
    
    return tenantRepo.update(tenantId, {
      plan: newPlan,
      seats: planSeats[newPlan],
      features: this.getFeaturesForPlan(newPlan),
      updatedAt: new Date().toISOString()
    });
  }
  
  private getFeaturesForPlan(plan: Tenant['plan']): string[] {
    const features = {
      free: ['basic_analytics', 'api_access'],
      pro: ['basic_analytics', 'api_access', 'advanced_analytics', 'priority_support'],
      enterprise: ['basic_analytics', 'api_access', 'advanced_analytics', 'priority_support', 'custom_domain', 'sso']
    };
    
    return features[plan];
  }
}

Example 3: Social Media Feed with Real-Time Updates

// repositories/post.repository.ts
export const postRepo = FirestoreRepository.withSchema<Post>(db, 'posts', postSchema);

// Monitor new posts in real-time
export function subscribeToUserFeed(userId: string, callback: (posts: Post[]) => void) {
  // Get list of users this user follows
  const following = await followRepo.query()
    .where('followerId', '==', userId)
    .get();
  
  const followingIds = following.map(f => f.followingId);
  
  // Subscribe to posts from followed users
  return postRepo.query()
    .where('authorId', 'in', followingIds)
    .where('status', '==', 'published')
    .orderBy('createdAt', 'desc')
    .limit(50)
    .onSnapshot(callback);
}
// services/feed.service.ts
export class FeedService {
  private unsubscribe: (() => void) | null = null;
  
  async startFeedUpdates(userId: string, onUpdate: (posts: Post[]) => void) {
    this.unsubscribe = await subscribeToUserFeed(userId, onUpdate);
  }
  
  stopFeedUpdates() {
    if (this.unsubscribe) {
      this.unsubscribe();
      this.unsubscribe = null;
    }
  }
  
  async getInitialFeed(userId: string, limit: number = 20) {
    const following = await followRepo.query()
      .where('followerId', '==', userId)
      .get();
    
    const followingIds = following.map(f => f.followingId);
    
    return postRepo.query()
      .where('authorId', 'in', followingIds)
      .where('status', '==', 'published')
      .orderBy('createdAt', 'desc')
      .paginate(limit);
  }
  
  async getMorePosts(userId: string, cursorId: string, limit: number = 20) {
    const following = await followRepo.query()
      .where('followerId', '==', userId)
      .get();
    
    const followingIds = following.map(f => f.followingId);
    
    return postRepo.query()
      .where('authorId', 'in', followingIds)
      .where('status', '==', 'published')
      .orderBy('createdAt', 'desc')
      .paginate(limit, cursorId);
  }
}

API Reference

FirestoreRepository

Static Methods

withSchema<T>(db: Firestore, collection: string, schema: ZodSchema): FirestoreRepository<T>

Create a repository with Zod schema validation.

Instance Methods

create(data: T): Promise<T & { id: ID }>

Create a new document.

bulkCreate(dataArray: T[]): Promise<(T & { id: ID })[]>

Create multiple documents in batch.

getById(id: ID, includeDeleted?: boolean): Promise<(T & { id: ID }) | null>

Get document by ID.

update(id: ID, data: Partial<T>): Promise<T & { id: ID }>

Update document with partial data. Supports dot notation for nested updates.

bulkUpdate(updates: { id: ID, data: Partial<T> }[]): Promise<(T & { id: ID })[]>

Update multiple documents in batch. Supports dot notation.

upsert(id: ID, data: T): Promise<T & { id: ID }>

Create or update document with specific ID.

delete(id: ID): Promise<void>

Permanently delete document.

bulkDelete(ids: ID[]): Promise<number>

Permanently delete multiple documents.

softDelete(id: ID): Promise<void>

Soft delete document (sets deletedAt timestamp).

bulkSoftDelete(ids: ID[]): Promise<number>

Soft delete multiple documents.

restore(id: ID): Promise<void>

Restore soft-deleted document.

bulkRestore(ids: ID[]): Promise<number>

Restore multiple soft-deleted documents.

restoreAll(): Promise<number>

Restore all soft-deleted documents in collection.

purgeDelete(): Promise<number>

Permanently delete all soft-deleted documents.

findByField<K extends keyof T>(field: K, value: T[K]): Promise<(T & { id: ID })[]>

Find documents by field value.

list(limit?: number, startAfterId?: string, includeDeleted?: boolean): Promise<(T & { id: ID })[]>

List documents with pagination.

query(): FirestoreQueryBuilder<T>

Create query builder for complex queries.

on(event: HookEvent, fn: HookFn): void

Register lifecycle hook.

subcollection<S>(parentId: ID, subcollectionName: string, schema?: ZodSchema): FirestoreRepository<S>

Access subcollection.

getParentId(): ID | null

Get parent document ID (for subcollections).

getCollectionPath(): string

Get full collection path.

runInTransaction<R>(fn: (tx: Transaction, repo: Repository) => Promise<R>): Promise<R>

Execute function within transaction.

getForUpdate(tx: Transaction, id: ID, includeDeleted?: boolean): Promise<(T & { id: ID }) | null>

Get document for update within transaction.

updateInTransaction(tx: Transaction, id: ID, data: Partial<T>, existingData?: T & { id: ID }): Promise<void>

Update document within transaction. Requires existingData parameter when using dot notation.

createInTransaction(tx: Transaction, data: T): Promise<T & { id: ID }>

Create document within transaction.

deleteInTransaction(tx: Transaction, id: ID): Promise<void>

Delete document within transaction.

softDeleteInTransaction(tx: Transaction, id: ID): Promise<void>

Soft delete document within transaction.

FirestoreQueryBuilder

where(field: string, op: Operator, value: any): this

Add where clause. Operators: ==, !=, >, >=, <, <=, in, not-in, array-contains, array-contains-any.

select(...fields: string[]): this

Select specific fields to return.

orderBy(field: string, direction?: 'asc' | 'desc'): this

Order results by field.

limit(n: number): this

Limit number of results.

startAfter(cursorId: ID): this

Start after document (for pagination).

startAt(cursorId: ID): this

Start at document (inclusive).

endBefore(cursorId: ID): this

End before document.

endAt(cursorId: ID): this

End at document (inclusive).

includeDeleted(): this

Include soft-deleted documents in results.

onlyDeleted(): this

Query only soft-deleted documents.

get(): Promise<(T & { id: ID })[]>

Execute query and return all results.

getOne(): Promise<(T & { id: ID }) | null>

Get single result (first document).

count(): Promise<number>

Count matching documents (aggregation query).

exists(): Promise<boolean>

Check if any documents match query.

paginate(limit: number, cursorId?: ID): Promise<{ items: T[], nextCursorId?: ID, prevCursorId?: ID }>

Cursor-based pagination (recommended for large datasets).

offsetPaginate(page: number, pageSize: number): Promise<PaginationResult<T>>

Offset-based pagination. Returns { items, total, page, pageSize, totalPages }.

paginateWithCount(limit: number, cursorId?: ID): Promise<{ items: T[], nextCursorId?: ID, total: number }>

Cursor pagination with total count.

update(data: Partial<T>): Promise<number>

Update all matching documents. Returns count of updated documents. Supports dot notation.

delete(): Promise<number>

Delete all matching documents. Returns count of deleted documents.

softDelete(): Promise<number>

Soft delete all matching documents. Returns count of soft-deleted documents.

aggregate(field: string, operation: 'sum' | 'avg'): Promise<number>

Perform aggregation on field.

distinctValues<K extends keyof T>(field: K): Promise<T[K][]>

Get distinct values for field.

stream(): AsyncGenerator<T & { id: ID }>

Stream results (for large datasets).

onSnapshot(callback: (items: T[]) => void, onError?: (error: Error) => void): Promise<() => void>

Subscribe to real-time updates. Returns unsubscribe function.

Error Classes

ValidationError

Thrown when Zod schema validation fails.

Properties:

  • issues: ZodIssue[] - Array of validation errors
  • message: string - Formatted error message

NotFoundError

Thrown when a requested document is not found.

Properties:

  • message: string - Error description

ConflictError

Thrown when operation conflicts with existing data.

Properties:

  • message: string - Error description

FirestoreIndexError

Thrown when query requires a composite index.

Properties:

  • indexUrl: string - URL to create the required index
  • fields: string[] - Fields requiring indexing
  • toString(): string - Returns formatted error message with instructions

Express Middleware

errorHandler(err: any, req: Request, res: Response, next: NextFunction): void

Express middleware for handling repository errors.

Maps errors to HTTP status codes:

  • ValidationError → 400 Bad Request
  • NotFoundError → 404 Not Found
  • ConflictError → 409 Conflict
  • FirestoreIndexError → 400 Bad Request (with index URL)
  • Others → 500 Internal Server Error

Advanced Patterns

Audit Logging

Track all data changes for compliance and debugging.

// services/audit-log.service.ts
class AuditLogService {
  private auditRepo = new FirestoreRepository<AuditLog>(db, 'audit_logs');

  async record(action: string, data: any, userId?: string) {
    await this.auditRepo.create({
      action,
      data,
      userId: userId || 'system',
      timestamp: new Date().toISOString(),
      ipAddress: getCurrentIpAddress(),
      userAgent: getCurrentUserAgent()
    });
  }
}

export const auditLog = new AuditLogService();

// Apply to all repositories
userRepo.on('afterCreate', async (user) => {