@mzsn/firestore
v1.2.11
Published
TypeScript-first ORM library for Firestore with support for both client-side (web) and server-side (admin) environments
Maintainers
Readme
Firestore ORM
TypeScript-first ORM library for Firestore with support for both client-side (web) and server-side (admin) environments.
✨ Key Features:
- ActiveRecord & Repository patterns - Intuitive document and collection management
- Automatic change tracking - Only modified fields are saved via Proxy
- Auto-generated IDs - Optional automatic ID generation with
defaultKey - Lifecycle hooks - Built-in validation and timestamp management with
beforeSave/afterSave - Works everywhere - Both
firebase(web) andfirebase-admin(server) SDKs - Multiple database support - Use multiple Firestore databases with
databaseId - Path templates - Flexible document path configuration with placeholders
Installation
npm install @mzsn/firestoreFor Web/Client Projects:
npm install @mzsn/firestore firebaseFor Server/Functions Projects:
npm install @mzsn/firestore firebase-adminUsage
Quick Start
// Auto-detects environment (browser → web, node → admin)
import { FirestoreDocument, FirestoreCollection, setupFirestore, newId } from '@mzsn/firestore';
import type { FirestoreKey, FirestoreData } from '@mzsn/firestore';
// Or explicitly specify (recommended for clarity)
// import { FirestoreDocument, FirestoreCollection, setupFirestore, newId } from '@mzsn/firestore/admin';
// import type { FirestoreKey, FirestoreData } from '@mzsn/firestore/admin';
import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
// Initialize Firebase Admin
const app = initializeApp({ projectId: 'your-project-id' });
const db = getFirestore(app);
setupFirestore(db);
// Define your types
interface UserKey extends FirestoreKey {
uid: string;
}
interface UserData extends FirestoreData {
name: string;
email: string;
age: number;
createdAt: Date;
updatedAt: Date;
}
// Create a Document class with auto-generated IDs
class UserDocument extends FirestoreDocument<UserKey, UserData> {
public static pathTemplate = 'users/{uid}';
// Auto-generate ID for each new instance
public static get defaultKey(): UserKey {
return { uid: newId() };
}
public static get defaultData(): UserData {
return {
name: '',
email: '',
age: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
}
// Validation and auto-update timestamps
protected override beforeSave(): void {
// Update timestamp on every save
this.data.updatedAt = new Date();
// Validation
if (!this.data.name || this.data.name.trim() === '') {
throw new Error('Name is required');
}
if (!this.data.email || !this.data.email.includes('@')) {
throw new Error('Valid email is required');
}
if (this.data.age < 0) {
throw new Error('Age must be non-negative');
}
}
}
// Create a Collection class
class UserCollection extends FirestoreCollection<never, UserKey, UserData, UserDocument> {
public static pathTemplate = 'users';
public static documentClass = UserDocument;
}
// Example 1: Create with auto-generated ID
const newUser = new UserDocument();
newUser.data.name = 'John Doe';
newUser.data.email = '[email protected]';
newUser.data.age = 30;
await newUser.save();
console.log('Created:', newUser.id); // Auto-generated ID
// Example 2: Create with manual ID
const specificUser = new UserDocument({ uid: 'user123' });
specificUser.data.name = 'Jane Doe';
specificUser.data.email = '[email protected]';
specificUser.data.age = 25;
await specificUser.save();
// Example 3: Load and update
const existingUser = new UserDocument({ uid: 'user123' });
await existingUser.get();
existingUser.data.age = 26; // Automatically tracked
await existingUser.save(); // Only saves 'age' field
// Example 4: Query with Collection
const users = new UserCollection({
where: [['age', '>=', 18]],
orderBy: ['age', 'asc'],
limit: 10
});
await users.get();
for (const [id, user] of users.documents) {
console.log(`${id}: ${user.data.name} (${user.data.age})`);
}
// Example 5: Add via Collection
await users.add({
name: 'Alice',
email: '[email protected]',
age: 28,
createdAt: new Date(),
updatedAt: new Date(),
});
// Example 6: Validation in action
try {
const invalidUser = new UserDocument();
invalidUser.data.name = ''; // Invalid - empty name
invalidUser.data.email = 'invalid-email'; // Invalid - no @
invalidUser.data.age = -5; // Invalid - negative age
await invalidUser.save(); // Throws error
} catch (error) {
console.error('Validation failed:', error.message);
}Web vs Admin SDK
The library provides separate implementations for client-side (web) and server-side (admin) with the same API.
Option 1: Auto-detect (recommended)
// Automatically uses the right version based on your environment:
// - Browser/Edge/Worker → web version
// - Node.js → admin version
import { FirestoreDocument, FirestoreCollection } from '@mzsn/firestore';Option 2: Explicit import (for clarity)
// Admin SDK (Firebase Functions, Node.js, Cloud Functions)
import { FirestoreDocument, FirestoreCollection } from '@mzsn/firestore/admin';
// Web SDK (Browser, React, Vue, etc.)
import { FirestoreDocument, FirestoreCollection } from '@mzsn/firestore/web';Web SDK Setup:
import { initializeApp } from 'firebase/app';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
import { setupFirestore } from '@mzsn/firestore/web';
const app = initializeApp({
apiKey: 'your-api-key',
projectId: 'your-project-id',
// ... other config
});
const db = getFirestore(app);
// For development with emulator
connectFirestoreEmulator(db, 'localhost', 8080);
// Initialize ORM
setupFirestore(db);Admin SDK Setup:
import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { setupFirestore } from '@mzsn/firestore/admin';
const app = initializeApp();
const db = getFirestore(app);
// For development with emulator
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
db.settings({ host: 'localhost:8080', ssl: false });
// Initialize ORM
setupFirestore(db);Multiple Database Support
You can use multiple Firestore databases by specifying a databaseId:
import { setupFirestore, FirestoreDocument } from '@mzsn/firestore/admin';
import { getFirestore } from 'firebase-admin/firestore';
// Register default database
const db = getFirestore();
setupFirestore(db); // or setupFirestore(db, '')
// Register additional databases
const dbSub = getFirestore('sub');
setupFirestore(dbSub, 'sub');
// Use specific database in Document class
class UserDocument extends FirestoreDocument<UserKey, UserData> {
public static pathTemplate = 'users/{uid}';
public static databaseId = ''; // Default database
}
class SubUserDocument extends FirestoreDocument<UserKey, UserData> {
public static pathTemplate = 'users/{uid}';
public static databaseId = 'sub'; // Secondary database
}
// Or create a subclass
class SubUserDocument extends UserDocument {
public static databaseId = 'sub';
}Auto-Generated Keys and Default Data
Use static getters for defaultKey and defaultData to generate dynamic values:
import { FirestoreDocument } from '@mzsn/firestore/admin';
// Auto-generate time-based IDs for each new instance
class Post extends FirestoreDocument<PostKey, PostData> {
protected static pathTemplate = 'posts/{postId}';
// Auto-generate unique, sortable, and verifiable ID for each new instance
protected static get defaultKey(): PostKey {
return { postId: timeId() }; // 26 chars: 13-digit timestamp + 13 random
}
// Fresh default data with current timestamp
protected static get defaultData(): PostData {
return {
title: '',
content: '',
createdAt: new Date(), // Fresh timestamp each time
};
}
}
// No need to specify key - auto-generated
const post = new Post();
post.data.title = 'My Post';
await post.save(); // Saved with auto-generated ID
// Singleton pattern - always same document
class AppConfig extends FirestoreDocument<ConfigKey, ConfigData> {
protected static pathTemplate = 'config/{configId}';
protected static get defaultKey(): ConfigKey {
return { configId: 'app-settings' }; // Always references the same document
}
protected static get defaultData(): ConfigData {
return {
theme: 'light',
language: 'en',
};
}
}
const config = new AppConfig(); // Always references 'app-settings'
await config.get();Core Concepts
Path Templates
Define document paths using templates with placeholders:
import { FirestoreDocument } from '@mzsn/firestore/web';
import type { FirestoreKey, FirestoreData } from '@mzsn/firestore/web';
interface PostKey extends FirestoreKey {
userId: string;
postId: string;
}
interface PostData extends FirestoreData {
title: string;
content: string;
}
class Post extends FirestoreDocument<PostKey, PostData> {
protected static pathTemplate = 'users/{userId}/posts/{postId}';
}
// Creates document at: users/user123/posts/post456
const post = new Post({ userId: 'user123', postId: 'post456' });Change Tracking
All data access goes through a Proxy that automatically tracks changes:
const user = new User({ id: 'user123' });
await user.get();
console.log(user.isDirty); // false
user.data.name = 'Jane';
user.data.age = 30;
console.log(user.isDirty); // true
await user.save(); // Only sends {name: 'Jane', age: 30} to Firestore
console.log(user.isDirty); // falseAPI Reference
FirestoreDocument
Constructor
constructor(key?, data?, exists?)key: Document key object or path stringdata: Initial data (optional)exists: Whether document exists (default: false)
Properties
data: Data- Data proxy with change trackingkey: Key- Document key objectid: string- Document IDexists: boolean- Whether document exists in FirestoreisNew: boolean- Whether document is new (not saved)isDirty: boolean- Whether document has unsaved changesisLoaded: boolean- Whether document has been loadedreference: DocumentReference- Firestore reference
Methods
get(transaction?, cache?): Promise<this>
Load document from Firestore.
await user.get(); // Load from Firestore
await user.get(transaction); // Load within transaction
await user.get(undefined, true); // Use cache if already loadedsave(force?, transaction?): Promise<void>
Save changes to Firestore. Auto-detects whether to use set() or update().
await user.save(); // Update changed fields only
await user.save(true); // Force full set operation
await user.save(false, transaction); // Save within transactionset(data?, transaction?): Promise<void>
Overwrite entire document.
await user.set({ name: 'John', age: 30, email: '[email protected]' });
await user.set(undefined, transaction); // Set current data in transactionupdate(transaction?): Promise<void>
Update only changed fields.
user.data.name = 'Jane';
await user.update(); // Only updates 'name' fielddelete(transaction?): Promise<void>
Delete document from Firestore.
await user.delete();
await user.delete(transaction);watch(callback?): () => void
Watch document for real-time updates. Returns unsubscribe function.
const unsubscribe = user.watch((data) => {
console.log('Updated:', data);
});
// Stop watching
unsubscribe();snapshot<T>(): AsyncGenerator<T>
Async generator for real-time updates.
for await (const user of new User({ id: 'user123' }).snapshot()) {
console.log('Current:', user.data);
if (someCondition) break;
}toObject(): Data
Get a deep copy of document data.
const userData = user.toObject();Lifecycle Hooks
Override these methods in subclasses:
import { FirestoreDocument } from '@mzsn/firestore/web';
import type { FirestoreKey, FirestoreData, FirestoreValue } from '@mzsn/firestore/web';
interface UserKey extends FirestoreKey {
id: string;
}
interface UserData extends FirestoreData {
name: string;
email: string;
age: number;
}
class User extends FirestoreDocument<UserKey, UserData> {
protected static pathTemplate = 'users/{id}';
// Validation before save
protected beforeSave(): void {
if (!this.data.email.includes('@')) {
throw new Error('Invalid email');
}
}
// Custom logic after save
protected afterSave(): void {
console.log('User saved:', this.id);
}
// Custom serialization
protected serializeValue(value: FirestoreValue): FirestoreValue {
// Date → Timestamp conversion is automatic
return super.serializeValue(value);
}
// Custom deserialization
protected unserializeValue(value: FirestoreValue): FirestoreValue {
// Timestamp → Date conversion is automatic
return super.unserializeValue(value);
}
}FirestoreCollection
Constructor
class UserCollection extends FirestoreCollection<never, UserKey, UserData, User> {
protected static pathTemplate = 'users'; // Top-level collection
protected static documentClass = User;
}
// For subcollections, define CollectionKey type
interface PostCollectionKey {
userId: string;
}
class PostCollection extends FirestoreCollection<PostCollectionKey, PostKey, PostData, Post> {
protected static pathTemplate = 'users/{userId}/posts';
protected static documentClass = Post;
}
// Usage
const users = new UserCollection(); // All documents
const users = new UserCollection(condition); // With query condition
const userPosts = new PostCollection({ userId: 'user123' }); // For subcollectionsParameters:
key?: CollectionKey- Collection key object for subcollections (optional)condition?: Condition- Query conditions (optional)
Properties
documents: Map<string, Document>- Map of document ID to Document instancereference: CollectionReference- Firestore collection referencekey: Key | string[]- Collection keycondition: Condition- Query conditionisLoaded: boolean- Whether documents have been loaded
Static Properties
pathTemplate: string- Path template for building collection paths (e.g., 'users' or 'users/{userId}/posts')documentClass: DocumentConstructor- Document class constructor for creating document instances (required for inheritance pattern)
Methods
get(cache?): Promise<this>
Load documents matching query.
class UserCollection extends FirestoreCollection<never, UserKey, UserData, User> {
protected static pathTemplate = 'users';
protected static documentClass = User;
}
const users = new UserCollection({
where: [['age', '>=', 18]],
limit: 10
});
await users.get();
for (const [id, user] of users.documents) {
console.log(id, user.data);
}
// Use cache to skip loading if already loaded
await users.get(true);add(data?, transaction?): Promise<Document | undefined>
Create document with auto-generated ID.
const user = await users.add({
name: 'John',
age: 30,
email: '[email protected]'
});
console.log('Created user:', user?.id);set(id, data, transaction?): Promise<Document | undefined>
Create/update document with specific ID.
const user = await users.set('user123', {
name: 'Jane',
age: 25,
email: '[email protected]'
});delete(id, transaction?): Promise<void>
Delete document by ID.
await users.delete('user123');save(transaction?): Promise<void>
Save all dirty documents in the collection.
// Make changes to multiple documents
users.documents.get('user1')!.data.age = 30;
users.documents.get('user2')!.data.age = 25;
// Save all changes
await users.save();first(): Document | undefined
Get the first document in the collection.
const firstUser = users.first();find(id): Document | undefined
Find a document by ID from loaded documents (cache only, doesn't query Firestore).
const user = users.find('user123');toArray(): Document[]
Convert documents map to array.
const userArray = users.toArray();docs(force?): Promise<Document[]>
Get all documents as an array, loading if necessary.
const userArray = await users.docs();
const freshUserArray = await users.docs(true); // Force reloadwatch(callback): void
Watch collection for real-time updates.
users.watch((snapshot) => {
console.log(`Collection has ${users.documents.size} documents`);
console.log('Changes:', snapshot.docChanges());
});unwatch(): void
Cancel all active snapshot listeners.
users.unwatch();snapshot(): AsyncGenerator<Document[]>
Async generator for real-time collection updates.
for await (const documents of users.snapshot()) {
console.log('Documents updated:', documents.length);
for (const doc of documents) {
console.log(doc.data);
}
}Batch Operations
Batch operations are provided as standalone functions that handle Firestore's 500-document batch limit automatically.
import { batchSave, batchDelete } from '@mzsn/firestore/admin';batchSave(documents): Promise<void>
Save multiple documents in batches.
const documents = [user1, user2, user3, /* ... up to 1000s ... */];
// Automatically chunks into batches of 500
await batchSave(documents);batchDelete(documents): Promise<void>
Delete multiple documents in batches.
const documentsToDelete = Array.from(users.documents.values());
// Automatically chunks into batches of 500
await batchDelete(documentsToDelete);Query Conditions
// Where clause tuple: [fieldPath, operator, value]
type WhereClause = [fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown];
interface Condition {
where: WhereClause[]; // Required field
orderBy?: [fieldPath: string | FieldPath, directionStr?: 'asc' | 'desc'];
limit?: number;
limitToLast?: number;
startAfter?: DocumentSnapshot;
startAt?: DocumentSnapshot;
endBefore?: DocumentSnapshot;
endAt?: DocumentSnapshot;
}
// Example
const condition: Condition = {
where: [['age', '>=', 18]],
orderBy: ['createdAt', 'desc'],
limit: 10
};
// Just orderBy/limit - use empty where array
const condition2: Condition = {
where: [],
orderBy: ['name', 'asc'],
limit: 5
};Advanced Examples
Nested Collections
import { FirestoreDocument, FirestoreCollection } from '@mzsn/firestore/web';
import type { FirestoreKey, FirestoreData } from '@mzsn/firestore/web';
interface CommentKey extends FirestoreKey {
userId: string;
postId: string;
commentId: string;
}
interface CommentData extends FirestoreData {
text: string;
author: string;
createdAt: Date;
}
class Comment extends FirestoreDocument<CommentKey, CommentData> {
protected static pathTemplate = 'users/{userId}/posts/{postId}/comments/{commentId}';
}
// For accessing a single comment
const comment = new Comment({
userId: 'user123',
postId: 'post456',
commentId: 'comment789'
});
// For accessing a collection of comments
interface CommentCollectionKey {
userId: string;
postId: string;
}
class CommentCollection extends FirestoreCollection<CommentCollectionKey, CommentKey, CommentData, Comment> {
protected static pathTemplate = 'users/{userId}/posts/{postId}/comments';
protected static documentClass = Comment;
}
const comments = new CommentCollection({ userId: 'user123', postId: 'post456' });
await comments.get();Transactions
import { getFirestore } from 'firebase/firestore';
import { runTransaction } from 'firebase/firestore';
await runTransaction(getFirestore(), async (transaction) => {
const sender = new User({ id: 'user1' });
const receiver = new User({ id: 'user2' });
await sender.get(transaction);
await receiver.get(transaction);
sender.data.balance -= 100;
receiver.data.balance += 100;
await sender.save(false, transaction);
await receiver.save(false, transaction);
});Custom Validation
import { FirestoreDocument } from '@mzsn/firestore/web';
import type { FirestoreKey, FirestoreData } from '@mzsn/firestore/web';
interface UserKey extends FirestoreKey {
id: string;
}
interface UserData extends FirestoreData {
name: string;
email: string;
age: number;
}
class User extends FirestoreDocument<UserKey, UserData> {
protected static pathTemplate = 'users/{id}';
protected beforeSave(): void {
if (this.data.age < 0) {
throw new Error('Age must be positive');
}
if (!this.data.email.includes('@')) {
throw new Error('Invalid email');
}
}
}Real-time UI Updates
// React example
function UserProfile({ userId }: { userId: string }) {
const [userData, setUserData] = useState<UserData | null>(null);
useEffect(() => {
const user = new User({ id: userId });
user.get();
const unsubscribe = user.watch((data) => {
setUserData(data);
});
return unsubscribe;
}, [userId]);
return <div>{userData?.name}</div>;
}Utility Functions
The library also exports utility functions:
import {
deepEqual,
parseKey,
buildPath,
newId,
timeId,
AsyncQueue
} from '@mzsn/firestore/web';
// Generate random ID
const id = newId(20); // 20-character random ID
// Generate time-based ID (sortable and verifiable in Firestore Rules)
const timeBasedId = timeId(); // 26 chars: 13-digit timestamp + 13 random
const customLength = timeId(20); // 20 chars: 13-digit timestamp + 7 random
// Deep equality check
const isEqual = deepEqual(obj1, obj2);
// Parse path to key
const key = parseKey('users/user123/posts/post456', 'users/{userId}/posts/{postId}');
// => { userId: 'user123', postId: 'post456' }
// Build path from key
const path = buildPath({ userId: 'user123', postId: 'post456' }, 'users/{userId}/posts/{postId}');
// => 'users/user123/posts/post456'Firestore Rules Validation with timeId()
The timeId() format allows secure validation in Firestore Security Rules:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Validate time-based IDs to prevent spoofing attacks
match /posts/{postId} {
allow create: if
// Check ID format (26 chars with 13-digit timestamp)
postId.size() == 26 &&
// Extract timestamp and verify it's not from the future
int(postId.substring(0, 13)) <= request.time.toMillis() &&
// Optional: reject IDs older than 1 minute (prevent replay attacks)
int(postId.substring(0, 13)) >= request.time.toMillis() - 60000;
allow read, update, delete: if true;
}
}
}This prevents malicious users from:
- Creating documents with future timestamps
- Creating documents with very old timestamps (collision attacks)
- Using arbitrary IDs that don't follow the time-based format
## Development
```bash
# Install dependencies
npm install
# Build
npm run build
# Clean
npm run cleanRunning Tests
This project includes comprehensive test files for both web and admin implementations.
1. Start Firestore Emulator
In one terminal, start the Firestore emulator:
npm run emulatorThis will start:
- Firestore Emulator on
localhost:8080 - Emulator UI on
http://localhost:4000
2. Run Tests
In another terminal, run the tests:
# Run admin (firebase-admin) tests
npm run test:admin
# Run web (firebase) tests
npm run test:web
# Run both
npm run test:admin && npm run test:webNote: Make sure the emulator is running before executing tests, otherwise you'll get connection errors.
Test Coverage
Both test files include the following test cases:
CRUD Operations
- Creating documents with manual IDs
- Creating documents with auto-generated IDs
- Reading documents
- Updating documents (dirty tracking)
- Deleting documents
Collection Operations
- Adding multiple documents
- Querying with conditions
- Filtering and ordering
Change Tracking
- Verifying dirty state after load
- Verifying dirty state after changes
- Verifying dirty state after save
Real-time Updates
- Watching document changes
- Receiving snapshot updates
- Unsubscribing from listeners
Collection Inheritance Pattern
- Creating collections with keys
- Querying with conditions
- Direct usage without inheritance
- Using string paths
- Using string array keys
Test Files
Test files are located in the test/ directory:
test/admin/test.ts- Admin SDK integration teststest/web/test.ts- Web SDK integration teststest/firebase.json- Emulator configuration
Example test output:
✓ Connected to Firestore Emulator at localhost:8080
=== Testing CRUD Operations ===
Creating user with manual ID...
✓ User created: {
name: 'John Doe',
email: '[email protected]',
age: 30,
createdAt: 2025-10-20T15:00:54.953Z,
updatedAt: 2025-10-20T15:00:54.953Z,
_id: 'user123'
}
Creating user with auto-generated ID...
✓ Auto user created: {
name: 'Auto User',
email: '[email protected]',
age: 25,
createdAt: 2025-10-20T15:00:54.961Z,
updatedAt: 2025-10-20T15:00:54.961Z,
_id: 'gYgYmMi9TVTFOXxrEvmM'
}
✓ Auto-generated uid: gYgYmMi9TVTFOXxrEvmM
Updating user...
✓ Verified: {
name: 'Jane Doe',
age: 31,
createdAt: 2025-10-20T15:00:54.953Z,
updatedAt: 2025-10-20T15:00:54.973Z, // Updated timestamp!
_id: 'user123'
}
=== Testing Collection Operations ===
✓ Found 3 users:
- Alice (25) [ZL6ql0Dn0td2suPDFeCl]
- Charlie (28) [dblOR1bUfFTPpTQrP1i2]
- Bob (35) [O1agEjATccrF072nYbfs]
✅ All tests passed!Project Structure
firestore-orm/
├── shared/ # Common code (types, utils, async-queue)
├── web/ # Client-side implementation (firebase/firestore)
├── admin/ # Server-side implementation (firebase-admin/firestore)
├── test/ # Test files (not published to npm)
│ ├── admin/test.ts
│ ├── web/test.ts
│ └── firebase.json
└── dist/ # Compiled output
├── shared/
├── web/
└── admin/License
MIT
