FirestoreRepository<T>
Type-safe Firestore repository with validation, soft deletes, and lifecycle hooks. Provides a clean API for common database operations with built-in error handling.
Constructor
new FirestoreRepository<T>(db: Firestore, collectionPath: string, validator?: Validator<T>, parentPath?: string)
Firebase Admin Firestore instance
Path to the Firestore collection
Optional Zod validator for schema validation
Internal parameter for subcollection tracking
Static Methods
withSchema
Create a repository instance with Zod schema validation. Automatically validates all create and update operations.
static withSchema<U extends { id?: ID }>(
db: Firestore,
collection: string,
schema: z.ZodObject<any>
): FirestoreRepository<U>
Firestore database instance
Zod schema for validation
Repository instance with validation enabled
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
);
Collection Methods
subcollection
Access a subcollection under a specific parent document.
subcollection<S extends { id?: ID }>(
parentId: ID,
subcollectionName: string,
schema?: z.ZodObject<any>
): FirestoreRepository<S>
Name of the subcollection
Optional Zod schema for subcollection validation
// Access orders for a specific user
const userOrders = userRepo.subcollection<Order>('user-123', 'orders');
await userOrders.create({ product: 'Widget', price: 99 });
// Nested subcollections
const comments = postRepo
.subcollection<Comment>('post-123', 'comments')
.subcollection<Reply>('comment-456', 'replies');
getParentId
Get the parent document ID if this is a subcollection. Returns null for top-level collections.
const userOrders = userRepo.subcollection('user-123', 'orders');
console.log(userOrders.getParentId()); // 'user-123'
const topLevel = new FirestoreRepository(db, 'users');
console.log(topLevel.getParentId()); // null
getCollectionPath
Get the full Firestore path for this collection.
getCollectionPath(): string
const repo = new FirestoreRepository(db, 'users');
console.log(repo.getCollectionPath()); // 'users'
const orders = userRepo.subcollection('user-123', 'orders');
console.log(orders.getCollectionPath()); // 'users/user-123/orders'
isSubcollection
Check if this repository represents a subcollection.
isSubcollection(): boolean
const users = new FirestoreRepository(db, 'users');
console.log(users.isSubcollection()); // false
const orders = users.subcollection('user-123', 'orders');
console.log(orders.isSubcollection()); // true
Lifecycle Hooks
Register a lifecycle hook to run before or after operations. Hooks allow you to add custom logic like logging, validation, or side effects.
on(event: HookEvent, fn: HookFn<T>): void
The lifecycle event to hook into. Available events:
- Single operations:
beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeSoftDelete, afterSoftDelete, beforeRestore, afterRestore
- Bulk operations:
beforeBulkCreate, afterBulkCreate, beforeBulkUpdate, afterBulkUpdate, beforeBulkDelete, afterBulkDelete, beforeBulkSoftDelete, afterBulkSoftDelete, beforeBulkRestore, afterBulkRestore
Async or sync function to execute
// Log all creates
userRepo.on('afterCreate', (user) => {
console.log(`User created: ${user.id}`);
});
// Send email on user creation
userRepo.on('afterCreate', async (user) => {
await sendWelcomeEmail(user.email);
});
// Validate business logic before update
orderRepo.on('beforeUpdate', (data) => {
if (data.status === 'shipped' && !data.trackingNumber) {
throw new Error('Tracking number required for shipped orders');
}
});
// Bulk operation hooks
userRepo.on('afterBulkDelete', async ({ ids, documents }) => {
await auditLog.record('users_deleted', { count: ids.length });
});
CRUD Operations
create
Create a new document in the collection. Automatically adds soft delete support and runs validation if schema is configured.
async create(data: T): Promise<T & { id: ID }>
Document data (without ID)
Created document with generated ID
Throws: ValidationError if schema validation fails
// Simple create
const user = await userRepo.create({
name: 'John Doe',
email: '[email protected]'
});
console.log(user.id); // Auto-generated ID
// With validation error handling
try {
await userRepo.create({ name: '', email: 'invalid' });
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.issues); // Field-specific errors
}
}
bulkCreate
Create multiple documents in a single batched operation. More efficient than calling create() in a loop. Uses Firestore batches (500 ops per batch).
async bulkCreate(dataArray: T[]): Promise<(T & { id: ID })[]>
Array of documents to create
Array of created documents with generated IDs
Throws: ValidationError if any document fails validation
// Bulk insert users
const users = await userRepo.bulkCreate([
{ name: 'Alice', email: '[email protected]' },
{ name: 'Bob', email: '[email protected]' },
{ name: 'Charlie', email: '[email protected]' }
]);
// Import from CSV
const products = csvData.map(row => ({
name: row.name,
price: parseFloat(row.price),
sku: row.sku
}));
await productRepo.bulkCreate(products);
getById
Retrieve a document by its ID. Returns null if the document doesn’t exist or is soft-deleted (unless includeDeleted is true).
async getById(id: ID, includeDeleted = false): Promise<(T & {id: ID}) | null>
If true, return soft-deleted documents
Document with ID or null if not found
// Get active user
const user = await userRepo.getById('user-123');
if (user) {
console.log(user.name);
}
// Include soft-deleted documents
const deletedUser = await userRepo.getById('user-123', true);
if (deletedUser?.deletedAt) {
console.log('User was deleted on:', deletedUser.deletedAt);
}
update
Update an existing document with partial data. Supports both regular fields and dot notation for nested updates.
async update(id: ID, data: Partial<T>): Promise<T & {id: ID}>
Partial document data (supports dot notation like ‘address.city’)
Throws: NotFoundError if document doesn’t exist, ValidationError if validation fails
// Regular update
await userRepo.update('user-123', {
email: '[email protected]'
});
// Dot notation for nested fields
await userRepo.update('user-123', {
'address.city': 'Los Angeles',
'address.zipCode': '90001',
name: 'John Doe'
});
// Deep nesting
await repo.update('doc-123', {
'settings.notifications.email': true,
'settings.theme': 'dark'
});
bulkUpdate
Update multiple documents in a single batched operation. Supports dot notation for nested field updates.
async bulkUpdate(updates: { id: ID, data: Partial<T> }[]): Promise<(T & { id: ID })[]>
updates
{ id: ID, data: Partial<T> }[]
required
Array of update operations with ID and data
Array of updated documents
Throws: NotFoundError if any document doesn’t exist, ValidationError if any validation fails
// Regular bulk update
await userRepo.bulkUpdate([
{ id: 'user-1', data: { status: 'active' } },
{ id: 'user-2', data: { status: 'active' } }
]);
// With dot notation
await userRepo.bulkUpdate([
{ id: 'user-1', data: { 'profile.verified': true } },
{ id: 'user-2', data: { 'settings.theme': 'dark' } }
]);
upsert
Create a new document if it doesn’t exist, or update it if it does. Uses the provided ID instead of auto-generating one.
async upsert(id: ID, data: T): Promise<T & { id: ID }>
Created or updated document
Throws: ValidationError if validation fails
// Sync external data
await userRepo.upsert('external-id-123', {
name: 'John Doe',
email: '[email protected]',
source: 'external-api'
});
// Idempotent operations
await settingsRepo.upsert('app-config', {
theme: 'dark',
notifications: true
});
delete
Permanently delete a document from Firestore. This is a hard delete - the document cannot be recovered.
async delete(id: ID): Promise<void>
Throws: NotFoundError if document doesn’t exist
// Delete a user permanently
await userRepo.delete('user-123');
// Delete with error handling
try {
await userRepo.delete('user-123');
console.log('User deleted successfully');
} catch (error) {
if (error instanceof NotFoundError) {
console.log('User not found');
}
}
bulkDelete
Permanently delete multiple documents in a batched operation. This is a hard delete - documents cannot be recovered.
async bulkDelete(ids: ID[]): Promise<number>
Array of document IDs to delete
Number of documents actually deleted
// Delete multiple users
const deletedCount = await userRepo.bulkDelete([
'user-1',
'user-2',
'user-3'
]);
console.log(`Deleted ${deletedCount} users`);
// Clean up test data
const testUserIds = await userRepo.query()
.where('email', 'array-contains', '@test.com')
.get()
.then(users => users.map(u => u.id));
await userRepo.bulkDelete(testUserIds);
Soft Delete Operations
softDelete
Soft delete a document by setting its deletedAt timestamp. Document remains in Firestore but is excluded from queries by default.
async softDelete(id: ID): Promise<void>
Document ID to soft delete
Throws: NotFoundError if document doesn’t exist
// Soft delete a user (can be restored later)
await userRepo.softDelete('user-123');
// Check if user was deleted
const user = await userRepo.getById('user-123', true);
if (user?.deletedAt) {
console.log('User deleted at:', user.deletedAt);
}
bulkSoftDelete
Soft delete multiple documents in a batched operation. Documents remain in Firestore but are excluded from queries by default.
async bulkSoftDelete(ids: ID[]): Promise<number>
Array of document IDs to soft delete
Number of documents actually soft deleted
// Soft delete inactive users
const inactiveIds = await userRepo.query()
.where('lastLogin', '<', oneYearAgo)
.get()
.then(users => users.map(u => u.id));
await userRepo.bulkSoftDelete(inactiveIds);
// Archive old orders
const oldOrderIds = ['order-1', 'order-2', 'order-3'];
const archivedCount = await orderRepo.bulkSoftDelete(oldOrderIds);
console.log(`Archived ${archivedCount} orders`);
purgeDelete
Permanently delete all soft-deleted documents. Use this to clean up documents that were previously soft deleted.
async purgeDelete(): Promise<number>
Number of documents permanently deleted
// Clean up all soft-deleted users
const purgedCount = await userRepo.purgeDelete();
console.log(`Permanently deleted ${purgedCount} users`);
// Scheduled cleanup job
cron.schedule('0 0 * * 0', async () => {
const deleted = await userRepo.purgeDelete();
console.log(`Weekly cleanup: ${deleted} users purged`);
});
restore
Restore a soft-deleted document by removing its deletedAt timestamp. Document becomes accessible in normal queries again.
async restore(id: ID): Promise<void>
Throws: NotFoundError if document doesn’t exist
// Restore a deleted user
await userRepo.restore('user-123');
// Restore with verification
const user = await userRepo.getById('user-123', true);
if (user?.deletedAt) {
await userRepo.restore(user.id);
console.log('User restored successfully');
}
restoreAll
Restore all soft-deleted documents in the collection. Useful for bulk recovery operations.
async restoreAll(): Promise<number>
Number of documents restored
// Restore all deleted users
const restoredCount = await userRepo.restoreAll();
console.log(`Restored ${restoredCount} users`);
// Undo accidental bulk delete
await orderRepo.restoreAll();
Query Methods
findByField
Find documents by a specific field value. Simple equality search on a single field.
async findByField<K extends keyof T>(field: K, value: T[K]): Promise<(T & { id: ID})[]>
The field name to search on
Array of matching documents
// Find users by email
const users = await userRepo.findByField('email', '[email protected]');
// Find orders by status
const pendingOrders = await orderRepo.findByField('status', 'pending');
list
List documents with simple pagination. Uses cursor-based pagination for efficient large dataset traversal.
async list(limit = 10, startAfterId?: string, includeDeleted = false): Promise<(T & { id: ID})[]>
Maximum number of documents to return
Document ID to start after (for next page)
If true, include soft-deleted documents
// First page
const firstPage = await userRepo.list(20);
// Next page (use paginate from query for efficient pagination)
const lastId = firstPage[firstPage.length - 1]?.id;
const nextPage = await userRepo.list(20, lastId);
// Include deleted documents
const allUsers = await userRepo.list(50, undefined, true);
query
Create a query builder for complex queries. Provides a fluent API for filtering, sorting, pagination, and more.
query(): FirestoreQueryBuilder<T>
// Simple query
const activeUsers = await userRepo.query()
.where('status', '==', 'active')
.get();
// Complex query with multiple conditions
const results = await orderRepo.query()
.where('status', '==', 'pending')
.where('total', '>', 100)
.orderBy('createdAt', 'desc')
.limit(50)
.get();
// Pagination
const page = await productRepo.query()
.where('category', '==', 'electronics')
.orderBy('price', 'desc')
.paginate(20, lastCursorId);
Transaction Methods
runInTransaction
Execute a function within a Firestore transaction. Ensures atomic operations with automatic rollback on failure.
async runInTransaction<R>(
fn: (
tx: FirebaseFirestore.Transaction,
repo: FirestoreRepository<T>
) => Promise<R>
): Promise<R>
fn
(tx, repo) => Promise<R>
required
Transaction function that receives transaction and repository
Result of the transaction function
// Transfer balance between accounts
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
});
});
// Atomic counter increment
const newCount = await counterRepo.runInTransaction(async (tx, repo) => {
const counter = await repo.getForUpdate(tx, 'global-counter');
const newValue = (counter?.value || 0) + 1;
await repo.updateInTransaction(tx, 'global-counter', {
value: newValue
});
return newValue;
});
getForUpdate
Get a document within a transaction for update. Ensures you read the latest version before updating.
async getForUpdate(
tx: FirebaseFirestore.Transaction,
id: ID,
includeDeleted = false
): Promise<(T & { id: ID }) | null>
tx
FirebaseFirestore.Transaction
required
Firestore transaction object
If true, include soft-deleted documents
Document or null if not found
await repo.runInTransaction(async (tx, repo) => {
const user = await repo.getForUpdate(tx, 'user-123');
if (user) {
await repo.updateInTransaction(tx, user.id, {
loginCount: (user.loginCount || 0) + 1
});
}
});
updateInTransaction
Update a document within a transaction. Supports dot notation for nested field updates. IMPORTANT: Call getForUpdate() first to read the document before updating.
async updateInTransaction(
tx: FirebaseFirestore.Transaction,
id: ID,
data: Partial<T>,
existingData?: T & { id: ID }
): Promise<void>
tx
FirebaseFirestore.Transaction
required
Firestore transaction object
Partial data to update (supports dot notation)
Optional: pass existing data from getForUpdate to avoid extra read
Throws: ValidationError if validation fails
await repo.runInTransaction(async (tx, repo) => {
const product = await repo.getForUpdate(tx, 'product-123');
await repo.updateInTransaction(tx, 'product-123', {
stock: product.stock - quantity
}, product);
});
// With dot notation in transaction
await repo.runInTransaction(async (tx, repo) => {
const user = await repo.getForUpdate(tx, 'user-123');
await repo.updateInTransaction(tx, 'user-123', {
'settings.notifications': true,
'profile.lastLogin': new Date()
}, user);
});
createInTransaction
Create a document within a transaction. Must be used inside runInTransaction callback.
async createInTransaction(
tx: FirebaseFirestore.Transaction,
data: T
): Promise<T & { id: ID }>
tx
FirebaseFirestore.Transaction
required
Firestore transaction object
Throws: ValidationError if validation fails
await repo.runInTransaction(async (tx, repo) => {
const newOrder = await repo.createInTransaction(tx, {
userId: 'user-123',
total: 99.99,
status: 'pending'
});
console.log('Order created:', newOrder.id);
});
deleteInTransaction
Delete a document within a transaction. Must be used inside runInTransaction callback.
async deleteInTransaction(
tx: FirebaseFirestore.Transaction,
id: ID
): Promise<void>
tx
FirebaseFirestore.Transaction
required
Firestore transaction object
Throws: NotFoundError if document doesn’t exist
await repo.runInTransaction(async (tx, repo) => {
const item = await repo.getForUpdate(tx, 'item-123');
if (item && item.quantity === 0) {
await repo.deleteInTransaction(tx, item.id);
}
});