@serge-ivo/firestore-client v1.5.6
@serge-ivo/firestore-client
A TypeScript/JavaScript library that provides controlled and cost-effective Firestore data management. This library helps prevent abuse and excessive costs in your Firebase applications by implementing configurable rate limiting and usage controls.
Note: This library requires instantiation. You must create an instance of FirestoreService by passing it a configured Firebase configuration object (FirebaseOptions).
Why Use This Library?
- Cost Control: Prevents unexpected Firebase costs by limiting the number of reads and writes (via
RequestLimiter, requires separate configuration). - Abuse Prevention: Protects your application from potential abuse by implementing query limits (partially via
QueryOptionslimit, more robust prevention viaRequestLimiter). - Clear Structure: Provides a consistent, instance-based API for Firestore operations.
- Type Safety: Leverages TypeScript for improved development experience.
Installation
npm install @serge-ivo/firestore-client firebase
# or
yarn add @serge-ivo/firestore-client firebaseBasic Setup & Usage
Get your Firebase Configuration:
// Example: src/firebaseConfig.ts import { FirebaseOptions } from "firebase/app"; const firebaseConfig: FirebaseOptions = { // Your Firebase config details here apiKey: "...", authDomain: "...", projectId: "...", // ...etc }; // Export the configuration object for use elsewhere export { firebaseConfig };Create a
FirestoreServiceinstance using the configuration:It's recommended to create a single instance (singleton) and export it for use throughout your application.
// Example: src/firestore.ts import { firebaseConfig } from "./firebaseConfig"; // Import your config import { FirestoreService } from "@serge-ivo/firestore-client"; // Create and export the service instance export const firestoreService = new FirestoreService(firebaseConfig);Use the instance in your application:
// Example: In a React component or service file import { firestoreService } from "../firestore"; // Adjust path as needed import { useEffect, useState } from "react"; interface UserData { // Use an interface for the data shape name: string; email: string; } function UserProfile({ userId }: { userId: string }) { // State holds the plain data object (or null) const [user, setUser] = useState<UserData | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { const fetchUser = async () => { setLoading(true); try { // Use the service instance methods, specifying the expected data type const userData = await firestoreService.getDocument<UserData>( `users/${userId}` // Construct path manually or use a model/helper ); // The fetched data (with ID added by the converter) is stored setUser(userData); } catch (error) { console.error("Failed to fetch user:", error); } setLoading(false); }; fetchUser(); }, [userId]); if (loading) return <p>Loading...</p>; if (!user) return <p>User not found.</p>; return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> {/* If user object had id: <p>ID: {user.id}</p> */} </div> ); }
API Reference
All methods (except utility methods like getTimestamp) are now instance methods called on your created firestoreService instance.
Constructor
// Create a new service instance
constructor(firebaseConfig: FirebaseOptions)Document Operations
// Get a single document
async getDocument<T>(docPath: string): Promise<T | null>
// Add a new document
async addDocument<T>(collectionPath: string, data: T): Promise<string | undefined>
// Update a document
async updateDocument(docPath: string, data: Record<string, any>): Promise<void>
// Set a document (create or overwrite)
async setDocument<T>(docPath: string, data: T, options?: { merge?: boolean }): Promise<void>
// Delete a document
async deleteDocument(docPath: string): Promise<void>
// Delete an entire collection (use with caution!)
async deleteCollection(collectionPath: string): Promise<void>Collection Operations
// Fetch collection with native Firestore query constraints
async fetchCollection<T>(path: string, ...queryConstraints: QueryConstraint[]): Promise<T[]>
// Query collection with structured options object
async queryCollection<T>(collectionPath: string, options?: QueryOptions): Promise<T[]>Note: QueryOptions allows where, orderBy, limit, startAfter, endBefore.
Real-time Subscriptions
// Note: All subscription methods return an unsubscribe function.
// Subscribe to document changes
subscribeToDocument<T>(docPath: string, callback: (data: T | null) => void): () => void
// Subscribe to collection changes (uses data converter)
subscribeToCollection<T>(collectionPath: string, callback: (data: T[]) => void, options?: QueryOptions): () => void
// Subscribe to collection with FirestoreModel subclass instantiation
subscribeToCollection2<T extends FirestoreModel>(model: new (...args: any[]) => T, collectionPath: string, callback: (data: T[]) => void, options?: QueryOptions): () => voidBatch Operations
// Get a new batch instance associated with this service's db
getBatch(): WriteBatch
// Add an update operation to a batch
updateInBatch(batch: WriteBatch, docPath: string, data: { [key: string]: FieldValue | Partial<unknown> | undefined }): void
// Add a set operation to a batch
setInBatch<T>(batch: WriteBatch, docPath: string, data: T, options?: SetOptions): void
// Add a delete operation to a batch
deleteInBatch(batch: WriteBatch, docPath: string): voidStatic Utility Methods
These can still be called directly on the FirestoreService class.
// Get field value constants like arrayUnion, arrayRemove
static getFieldValue(): { arrayUnion: ..., arrayRemove: ... }
// Get a server timestamp
static getTimestamp(): Timestamp
// Get the sentinel value for deleting a field
static deleteField(): FieldValueAuthentication (AuthService)
The FirestoreService instance also provides access to authentication methods via its auth property, which holds an instance of AuthService.
Accessing Auth Methods:
import { firestoreService } from "./path/to/your/firestore/instance";
// Access auth methods via the 'auth' property
const authService = firestoreService.auth;Available Methods on firestoreService.auth:
getCurrentUser(): User | null: Gets the currently signed-in Firebase User object, or null.getCurrentUserId(): string | null: Gets the ID of the currently signed-in user, or null.signInWithEmailPassword(email, password): Promise<UserCredential>: Signs in using email and password.signInWithGoogle(): Promise<UserCredential>: Initiates sign-in with Google via a popup.signOut(): Promise<void>: Signs out the current user.onAuthStateChanged(callback: (user: User | null) => void): Unsubscribe: Listens for changes in authentication state. Returns an unsubscribe function.
Example Usage:
import { firestoreService } from "./firestore"; // Adjust path as needed
async function checkAuthAndSignIn() {
let userId = firestoreService.auth.getCurrentUserId();
console.log("Initial User ID:", userId);
if (!userId) {
try {
console.log("Attempting Google Sign-In...");
const userCredential = await firestoreService.auth.signInWithGoogle();
console.log("Signed in with Google:", userCredential.user?.displayName);
userId = userCredential.user?.uid;
console.log("User ID after sign-in:", userId);
} catch (error) {
console.error("Google Sign-In failed:", error);
return; // Exit if sign-in fails
}
}
// Set up a listener for auth state changes
console.log("Setting up auth state listener...");
const unsubscribe = firestoreService.auth.onAuthStateChanged((user) => {
if (user) {
console.log("Auth state changed: User is signed in -", user.uid);
} else {
console.log("Auth state changed: User is signed out");
}
});
// Example: Sign out after a delay (replace with your app logic)
setTimeout(async () => {
try {
console.log("Signing out...");
await firestoreService.auth.signOut();
console.log("User signed out successfully.");
} catch (error) {
console.error("Sign out failed:", error);
}
// Clean up the listener when no longer needed
console.log("Unsubscribing auth listener...");
unsubscribe();
}, 5000); // Sign out after 5 seconds for demo
}
checkAuthAndSignIn();FirestoreModel Base Class (Simplified)
The library includes a base class FirestoreModel (src/firestoreModel.ts) that can be extended. In the current design, its primary purpose is:
- Defining Data Structure: Child classes define the properties of your entity.
- Encapsulating Path Logic: Child classes must implement
getDocPath()andgetColPath()to define how Firestore paths are constructed for that entity type.
Models do not handle persistence directly (no save, update, delete methods). All persistence operations are performed using the firestoreService instance.
Example Definition:
// src/models/ExampleEntity.ts
import { FirestoreModel } from "@serge-ivo/firestore-client";
// Interface for the raw data shape
export interface ExampleData {
title: string;
description: string;
owner: string;
// Timestamps (createdAt, updatedAt) can be handled by Firestore
// ID is handled automatically by the service/converter and base class
}
export class ExampleEntity extends FirestoreModel {
// Declare properties for type safety
title!: string;
description!: string;
owner!: string;
createdAt!: Date; // Assuming converter handles Timestamp -> Date
updatedAt!: Date;
// Constructor accepts the data object (including optional id from converter)
constructor(data: { id?: string } & Partial<ExampleData>) {
// ID is added by the data converter
super(data); // Passes data up to base class (which assigns properties)
}
// --- Path Logic Implementation ---
static buildPath(id?: string): string {
return id ? `examples/${id}` : `examples`;
}
getDocPath(): string {
if (!this.id) throw new Error("Cannot get doc path without ID.");
return ExampleEntity.buildPath(this.id);
}
getColPath(): string {
return ExampleEntity.buildPath();
}
}Example Usage with Service:
import { firestoreService } from "../firestore";
import { ExampleEntity, ExampleData } from "../models/ExampleEntity";
async function workWithExamples() {
// 1. Create data using the service
const newData: ExampleData = {
title: "New Example",
description: "Desc",
owner: "me",
};
const newId = await firestoreService.addDocument<ExampleData>(
ExampleEntity.buildPath(), // Use static path builder for collection
newData
);
if (!newId) return;
console.log("Created document with ID:", newId);
// 2. Fetch data using the service
const fetchedData = await firestoreService.getDocument<
ExampleData & { id: string }
>(ExampleEntity.buildPath(newId)); // Use static path builder for document
if (fetchedData) {
// 3. Optionally instantiate the model if needed for path logic or other methods
const exampleInstance = new ExampleEntity(fetchedData);
console.log("Fetched Title:", exampleInstance.title);
console.log("Instance ID:", exampleInstance.id);
console.log("Document Path:", exampleInstance.getDocPath());
// 4. Update using the service, providing the path and partial data
await firestoreService.updateDocument(exampleInstance.getDocPath(), {
description: "Updated Description",
});
// 5. Delete using the service, providing the path
await firestoreService.deleteDocument(exampleInstance.getDocPath());
console.log("Document deleted.");
}
}Request Limiter (Optional)
The RequestLimiter class (src/RequestLimiter.ts) is included but currently acts mostly as a placeholder or basic logger. To implement actual rate limiting or cost control, you would need to significantly enhance this class or integrate a more robust external library.
Type System
The library provides comprehensive TypeScript types for all operations. Here's how to work with types:
Available Types
// Query Types
type FilterOperator =
| "=="
| "!="
| "<"
| "<="
| ">"
| ">="
| "array-contains"
| "in"
| "array-contains-any"
| "not-in";
interface WhereClause {
field: string;
op: FilterOperator;
value: any;
}
interface OrderByClause {
field: string;
direction?: "asc" | "desc";
}
interface QueryOptions {
where?: WhereClause[];
orderBy?: OrderByClause[];
limit?: number;
startAfter?: any; // Document snapshot or field values for pagination
endBefore?: any; // Document snapshot or field values for pagination
}
// Model Types
interface FirestoreData {
id?: string;
createdAt?: Timestamp;
updatedAt?: Timestamp;
}
// Service Types
interface FirestoreServiceConfig {
maxBatchSize?: number;
maxQueryLimit?: number;
enablePersistence?: boolean;
}
// Utility Types
type WithId<T> = T & { id: string };
type WithOptionalId<T> = T & { id?: string };
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};Best Practices
Use Type Safety
interface User { name: string; email: string; lastLogin?: Date; } const user = await FirestoreService.getDocument<User>("users/123");Handle Rate Limits
try { await firestoreService.addDocument("users", userData); } catch (error) { console.error("Request limit exceeded:", error); }Use Emulators for Testing
if (process.env.NODE_ENV === "development") { firestoreService.connectEmulator(9098); }Clean Up Subscriptions
class MyComponent { private unsubscribe?: () => void; componentDidMount() { this.unsubscribe = firestoreService.subscribeToDocument<MyDataType>( "myCollection/doc123", (data) => { console.log("Received data:", data); // Update component state } ); } componentWillUnmount() { this.unsubscribe?.(); } }Use Batch Operations for Better Performance
const batch = firestoreService.getBatch(); // Add multiple operations await batch.commit();
Testing
This library uses Jest for testing. Tests are run against the Firebase Emulator Suite.
Setup:
- Install Firebase CLI: If you haven't already, install the Firebase CLI:
npm install -g firebase-tools - Login: Login to Firebase:
firebase login - Install Emulator Suite: If needed, setup the emulators:
firebase init emulators(select Firestore)
Running Tests:
- Start the Firebase emulators:
npm run start:emulators- In a separate terminal, run the tests:
npm test
The tests will verify core functionality including:
- Document operations (create, read, update, delete)
- Collection queries
- Batch operations
- Rate limiting and usage controls
License
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago