UNPKG

@serge-ivo/firestore-client

Version:
508 lines (469 loc) 17.4 kB
/** * FirestoreService - A wrapper around Firebase Firestore providing type-safe operations. * This service needs to be instantiated with a Firestore database instance. * * @example * // 1️⃣ Basic Setup * import { initializeApp } from 'firebase/app'; * import { getFirestore } from 'firebase/firestore'; * import { FirestoreService } from '@serge-ivo/firestore-client'; * * // Initialize Firebase app first * const app = initializeApp(firebaseConfig); * const db = getFirestore(app); * * // Create an instance of FirestoreService * const firestoreService = new FirestoreService(db); * * @example * // 2️⃣ Using the Service Instance * // Use the created instance to call methods * const user = await firestoreService.getDocument<User>('users/user123'); * * @example * // 3️⃣ Common Error Cases * // ❌ Don't instantiate without a valid Firestore instance * // const invalidService = new FirestoreService(null); // Throws error * * // ✅ Correct usage: * const firestoreService = new FirestoreService(db); * const result = await firestoreService.getDocument('users/user123'); */ // src/services/FirestoreService.ts import { initializeApp, FirebaseOptions } from "firebase/app"; import { addDoc, arrayRemove, arrayUnion, collection, CollectionReference, connectFirestoreEmulator, deleteDoc, deleteField, doc, DocumentReference, endBefore, FieldValue, Firestore, getDoc, getDocs, limit, onSnapshot, orderBy, query, QueryConstraint, setDoc, SetOptions, startAfter, Timestamp, updateDoc, where, WriteBatch, writeBatch, getFirestore, } from "firebase/firestore"; // Correctly import the local factory function import createFirestoreDataConverter from "./FirestoreDataConverter"; import { FirestoreModel } from "./firestoreModel"; import RequestLimiter from "./RequestLimiter"; import { AuthService } from "./AuthService"; // Import AuthService export type FilterOperator = | "==" | "!=" | "<" | "<=" | ">" | ">=" | "array-contains" | "in" | "array-contains-any" | "not-in"; interface QueryOptions { where?: Array<{ field: string; op: FilterOperator; value: any }>; orderBy?: Array<{ field: string; direction?: "asc" | "desc" }>; limit?: number; startAfter?: any; endBefore?: any; } export class FirestoreService { // Store db as a private readonly instance variable private readonly db: Firestore; // private readonly app: FirebaseApp; // Store the FirebaseApp instance - Removed as it's unused within the class public readonly auth: AuthService; // Expose AuthService instance /** * Creates an instance of FirestoreService. * @param {FirebaseOptions} firebaseConfig - The Firebase configuration object. * @throws Error if firebaseConfig is not provided or invalid. */ constructor(firebaseConfig: FirebaseOptions) { // Basic check for firebaseConfig object if ( !firebaseConfig || typeof firebaseConfig !== "object" || !firebaseConfig.apiKey // Check for a key property like apiKey as a basic validation ) { throw new Error( "Valid Firebase configuration object is required for FirestoreService constructor" ); } // Initialize Firebase app and Firestore instance try { const app = initializeApp(firebaseConfig); // this.app = app; // Store the app instance - Removed as it's unused within the class this.db = getFirestore(app); this.auth = new AuthService(app); // Initialize AuthService console.log( "FirestoreService and AuthService instances created and Firebase initialized successfully." ); } catch (error) { console.error("Error initializing Firebase:", error); throw new Error("Failed to initialize Firebase within FirestoreService"); } } // --- Path Validation Methods (can remain private static or become private instance methods) --- // Let's make them instance methods for consistency, though static is also fine. private validatePathBasic(path: string): void { if (!path) { throw new Error("Path cannot be empty"); } if (path.startsWith("/") || path.endsWith("/")) { throw new Error("Path cannot start or end with '/'"); } } private validateCollectionPathSegments(path: string): void { this.validatePathBasic(path); const segments = path.split("/"); if (segments.length % 2 !== 1) { throw new Error( "Collection path must have an odd number of segments (e.g., 'users' or 'users/123/posts')" ); } } private validateDocumentPathSegments(path: string): void { this.validatePathBasic(path); const segments = path.split("/"); if (segments.length % 2 !== 0) { throw new Error( "Document path must have an even number of segments (e.g., 'users/123' or 'users/123/posts/456')" ); } if (segments.length < 2) { throw new Error("Document path must have at least two segments."); } } private validateDocumentPath(path: string): void { this.validateDocumentPathSegments(path); } // --- End Path Validation --- // --- Instance Methods (previously static, now use this.db) --- /** * Connects this service instance to the Firestore emulator. * Note: This should ideally be done once globally if possible. * @param {number} firestoreEmulatorPort - The port the emulator is running on. */ connectEmulator(firestoreEmulatorPort: number): void { // Note: Emulator connection is typically done once globally, // but providing it as an instance method allows flexibility if needed. connectFirestoreEmulator(this.db, "localhost", firestoreEmulatorPort); console.log("🔥 Connected instance to Firestore Emulator"); } // Private helpers now use this.db and are instance methods private docRef<T>(path: string): DocumentReference<T> { this.validateDocumentPath(path); return doc(this.db, path).withConverter(createFirestoreDataConverter<T>()); } private colRef<T>(path: string): CollectionReference<T> { // Use the imported factory function return collection(this.db, path).withConverter( createFirestoreDataConverter<T>() ); } // Public API methods are now instance methods /** * Retrieves a single document from Firestore by its full path. * @template T The expected type of the document data. * @param {string} docPath The full path to the document (e.g., 'users/userId'). * @returns {Promise<T | null>} A promise resolving to the document data or null if not found. */ async getDocument<T>(docPath: string): Promise<T | null> { RequestLimiter.logDocumentRequest(docPath); const docSnap = await getDoc(this.docRef<T>(docPath)); return docSnap.exists() ? docSnap.data() : null; } /** * Adds a new document to a specified collection. * @template T The type of the data being added. * @param {string} collectionPath The path to the collection (e.g., 'posts', 'users/userId/tasks'). * @param {T} data The data for the new document. * @returns {Promise<string | undefined>} A promise resolving to the new document's ID, or undefined on failure. */ async addDocument<T>( collectionPath: string, data: T ): Promise<string | undefined> { this.validateCollectionPathSegments(collectionPath); // Validate path first RequestLimiter.logGeneralRequest(); const docRef = await addDoc(this.colRef<T>(collectionPath), data); return docRef.id; } /** * Updates specific fields of an existing document. * @param {string} docPath The full path to the document. * @param {Record<string, any>} data An object containing the fields to update. * @returns {Promise<void>} */ async updateDocument( docPath: string, data: Record<string, any> ): Promise<void> { this.validateDocumentPath(docPath); // Ensure doc path is valid before update RequestLimiter.logGeneralRequest(); // Use the raw doc ref without converter for partial updates await updateDoc(doc(this.db, docPath), data); } /** * Creates or overwrites a document completely. * @template T The type of the data being set. * @param {string} docPath The full path to the document. * @param {T} data The data for the document. * @param {object} [options] Optional settings. `merge: true` merges data instead of overwriting. * @returns {Promise<void>} */ async setDocument<T>( docPath: string, data: T, options: { merge?: boolean } = { merge: true } ): Promise<void> { this.validateDocumentPath(docPath); RequestLimiter.logGeneralRequest(); await setDoc(this.docRef<T>(docPath), data, options); } /** * Deletes a document from Firestore. * @param {string} docPath The full path to the document. * @returns {Promise<void>} */ async deleteDocument(docPath: string): Promise<void> { this.validateDocumentPath(docPath); RequestLimiter.logGeneralRequest(); // Get the raw doc ref for deletion await deleteDoc(doc(this.db, docPath)); } /** * Deletes all documents within a specified collection or subcollection. * Use with caution, especially on large collections. * @param {string} collectionPath The path to the collection (e.g., 'users', 'users/userId/posts'). * @returns {Promise<void>} */ async deleteCollection(collectionPath: string): Promise<void> { this.validateCollectionPathSegments(collectionPath); RequestLimiter.logGeneralRequest(); const batch = writeBatch(this.db); // Use colRef without converter for deletion query const snapshot = await getDocs(collection(this.db, collectionPath)); snapshot.docs.forEach((d) => batch.delete(d.ref)); await batch.commit(); } /** * Subscribes to real-time updates for a single document. * @template T The expected type of the document data. * @param {string} docPath The full path to the document. * @param {(data: T | null) => void} callback The function to call with document data (or null) on updates. * @returns {() => void} A function to unsubscribe from updates. */ subscribeToDocument<T>( docPath: string, callback: (data: T | null) => void ): () => void { this.validateDocumentPath(docPath); RequestLimiter.logSubscriptionRequest(docPath); const unsubscribe = onSnapshot(this.docRef<T>(docPath), (docSnap) => { callback(docSnap.exists() ? docSnap.data() : null); }); return unsubscribe; } /** * Subscribes to real-time updates for a collection. * @template T The expected type of the documents in the collection. * @param {string} collectionPath The path to the collection. * @param {(data: T[]) => void} callback The function to call with an array of document data on updates. * @returns {() => void} A function to unsubscribe from updates. */ subscribeToCollection<T>( collectionPath: string, callback: (data: T[]) => void ): () => void { this.validateCollectionPathSegments(collectionPath); RequestLimiter.logSubscriptionRequest(collectionPath); const unsubscribe = onSnapshot( query(this.colRef<T>(collectionPath)), (snapshot) => { const data = snapshot.docs.map((d) => d.data()); callback(data); } ); return unsubscribe; } /** * Subscribes to real-time updates for a collection, automatically instantiating FirestoreModel subclasses. * @template T A type extending FirestoreModel. * @param {new (...args: any[]) => T} model The constructor of the FirestoreModel subclass. * @param {string} collectionPath The path to the collection. * @param {(data: T[]) => void} callback The function to call with an array of instantiated models on updates. * @returns {() => void} A function to unsubscribe from updates. */ subscribeToCollection2<T extends FirestoreModel>( model: new (...args: any[]) => T, collectionPath: string, callback: (data: T[]) => void ): () => void { this.validateCollectionPathSegments(collectionPath); RequestLimiter.logSubscriptionRequest(collectionPath); // Use the factory function to get the converter const converter = createFirestoreDataConverter<any>(); // Use <any> or a base type for raw data const unsubscribe = onSnapshot( query(this.colRef<any>(collectionPath)).withConverter(converter), (snapshot) => { // Map the raw data, then instantiate the specific model class const data = snapshot.docs.map((doc) => { const rawData = doc.data(); // Get data converted by the converter // Manually instantiate the correct model subclass return new model(rawData, doc.id); }); callback(data); } ); return unsubscribe; } /** * Fetches documents from a collection, optionally applying query constraints. * @template T The expected type of the documents. * @param {string} path The path to the collection. * @param {...QueryConstraint} queryConstraints Optional Firestore query constraints (where, orderBy, limit, etc.). * @returns {Promise<T[]>} A promise resolving to an array of document data. */ async fetchCollection<T>( path: string, ...queryConstraints: QueryConstraint[] ): Promise<T[]> { this.validateCollectionPathSegments(path); RequestLimiter.logCollectionFetchRequest(path); const snapshot = await getDocs( queryConstraints.length > 0 ? query(this.colRef<T>(path), ...queryConstraints) : this.colRef<T>(path) ); return snapshot.docs.map((d) => d.data()); } /** * Queries a Firestore collection using a structured options object. * @template T The expected type of the document data. * @param {string} collectionPath The path to the collection. * @param {QueryOptions} [options={}] Optional query constraints (where, orderBy, limit, startAfter, endBefore). * @returns {Promise<T[]>} A promise resolving to an array of document data. */ async queryCollection<T>( collectionPath: string, options: QueryOptions = {} ): Promise<T[]> { this.validateCollectionPathSegments(collectionPath); RequestLimiter.logGeneralRequest(); const colReference = this.colRef<T>(collectionPath); const constraints: QueryConstraint[] = []; if (options.where) { options.where.forEach((w) => { constraints.push(where(w.field, w.op, w.value)); }); } if (options.orderBy) { options.orderBy.forEach((o) => { constraints.push(orderBy(o.field, o.direction)); }); } if (options.startAfter) { constraints.push(startAfter(options.startAfter)); } if (options.endBefore) { constraints.push(endBefore(options.endBefore)); } // Apply limit LAST as recommended by Firestore docs if (options.limit) { constraints.push(limit(options.limit)); } const q = query(colReference, ...constraints); const snapshot = await getDocs(q); return snapshot.docs.map((d) => d.data()); } // --- Batch Operations --- /** * Returns a new Firestore WriteBatch associated with this service instance's database. * @returns {WriteBatch} */ getBatch(): WriteBatch { RequestLimiter.logGeneralRequest(); return writeBatch(this.db); } /** * Updates specific fields of multiple documents in a batch. * @param {WriteBatch} batch The Firestore WriteBatch to update. * @param {string} docPath The full path to the document. * @param {object} data An object containing the fields to update. */ updateInBatch( batch: WriteBatch, docPath: string, data: { [key: string]: FieldValue | Partial<unknown> | undefined } ): void { this.validateDocumentPath(docPath); const docRef = doc(this.db, docPath); // Use raw doc ref batch.update(docRef, data); } /** * Sets a document in a batch. * @template T The type of the data being set. * @param {WriteBatch} batch The Firestore WriteBatch to set. * @param {string} docPath The full path to the document. * @param {T} data The data for the document. * @param {SetOptions} [options] Optional settings. `merge: true` merges data instead of overwriting. */ setInBatch<T>( batch: WriteBatch, docPath: string, data: T, options: SetOptions = {} ): void { this.validateDocumentPath(docPath); // Use docRef with converter for type safety during set const docRef = this.docRef<T>(docPath); batch.set(docRef, data, options); } /** * Deletes a document in a batch. * @param {WriteBatch} batch The Firestore WriteBatch to delete. * @param {string} docPath The full path to the document. */ deleteInBatch(batch: WriteBatch, docPath: string): void { this.validateDocumentPath(docPath); const docRef = doc(this.db, docPath); // Use raw doc ref batch.delete(docRef); } // --- Static Utility Methods (Do not depend on instance state) --- /** * Provides access to Firestore FieldValue constants (e.g., arrayUnion, arrayRemove). */ static getFieldValue() { return { arrayUnion, arrayRemove }; } /** * Returns a Firestore Timestamp for the current time. */ static getTimestamp() { return Timestamp.now(); } /** * Returns a special value used to delete a field during an update. */ static deleteField() { return deleteField(); } }