@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
Maintainers
Keywords
Readme
@spacelabstech/firestoreorm
A type-safe, feature-rich Firestore ORM built for the Firebase Admin SDK. Designed to make backend Firestore development actually enjoyable.
Table of Contents
- The Story Behind This ORM
- Why FirestoreORM?
- Installation
- Quick Start
- Core Concepts
- Complete Feature Guide
- Dot Notation for Nested Updates
- Framework Integration
- Best Practices
- Understanding Performance Costs
- Real-World Examples
- API Reference
- Contributing
- License
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 zodyarn add @spacelabstech/firestoreorm firebase-admin zodpnpm add @spacelabstech/firestoreorm firebase-admin zodPeer Dependencies
firebase-admin: ^12.0.0 || ^13.0.0zod: ^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 validationSoft 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 docsUnder 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 schemaupdate()validates againstschema.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-deletedBulk 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:
No
afterHooks: Lifecycle hooks likeafterCreate,afterUpdate,afterDeletedo NOT run inside transactions. Onlybeforehooks execute. This is a Firestore limitation since transactions need to be atomic and cannot have side effects that might fail.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 });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 preservedDeep 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 string3. Firestore Limitations
- Undefined values are automatically filtered out (Firestore doesn't accept
undefined) - Use
nullif 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 RequestNotFoundError→ 404 Not FoundConflictError→ 409 ConflictFirestoreIndexError→ 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 100Why: 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 undone6. 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:
- Document reads - Every document returned from a query
- Document writes - Every create, update, or delete
- Document deletes - Separate charge from writes
- Storage - Data stored in your database
- 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();- ORM adds
.where('deletedAt', '==', null)automatically - Firestore executes query with both conditions
- Returns up to 10 documents
- Cost: 10 reads (or fewer if less than 10 matches)
Pagination
const { items, nextCursorId } = await userRepo.query()
.orderBy('createdAt', 'desc')
.paginate(20, cursorId);- If
cursorIdprovided, fetches that document first (1 read) - Executes query starting after cursor document
- Returns up to 20 documents
- Cost: 21 reads (20 results + 1 cursor lookup)
Bulk Create
await userRepo.bulkCreate(users); // 500 users- Validates all 500 documents against schema
- Splits into batches of 500 operations (Firestore limit)
- Commits each batch sequentially
- Cost: 500 writes
Query Update
await orderRepo.query()
.where('status', '==', 'pending')
.update({ status: 'shipped' }); // 150 matches- Executes query to find matching documents (150 reads)
- Batches updates in groups of 500
- Commits all updates
- Cost: 150 reads + 150 writes
Soft Delete
await userRepo.softDelete(userId);- Fetches document to verify existence (1 read)
- Updates
deletedAtfield (1 write) - 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 });
});- Reads both documents within transaction (2 reads)
- Locks both documents until transaction completes
- Commits both updates atomically (2 writes)
- Cost: 2 reads + 2 writes
Cost Optimization Tips
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;Limit query results
// Always add reasonable limits await userRepo.query().limit(100).get();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;Select specific fields to reduce bandwidth
// Reduces network transfer (still charges for full document read) const emails = await userRepo.query() .select('email') .get();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 errorsmessage: 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 indexfields: string[]- Fields requiring indexingtoString(): 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 RequestNotFoundError→ 404 Not FoundConflictError→ 409 ConflictFirestoreIndexError→ 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) => {
