@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
QueryOptions
limit, 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 firebase
Basic 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
FirestoreService
instance 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): () => void
Batch 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): void
Static 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(): FieldValue
Authentication (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
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
3 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
5 months ago