1.5.6 • Published 3 months ago

@serge-ivo/firestore-client v1.5.6

Weekly downloads
-
License
MIT
Repository
github
Last release
3 months ago

@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 via RequestLimiter).
  • 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

  1. 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 };
  2. 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);
  3. 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:

  1. Defining Data Structure: Child classes define the properties of your entity.
  2. Encapsulating Path Logic: Child classes must implement getDocPath() and getColPath() 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

  1. Use Type Safety

    interface User {
      name: string;
      email: string;
      lastLogin?: Date;
    }
    
    const user = await FirestoreService.getDocument<User>("users/123");
  2. Handle Rate Limits

    try {
      await firestoreService.addDocument("users", userData);
    } catch (error) {
      console.error("Request limit exceeded:", error);
    }
  3. Use Emulators for Testing

    if (process.env.NODE_ENV === "development") {
      firestoreService.connectEmulator(9098);
    }
  4. 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?.();
      }
    }
  5. 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:

  1. Install Firebase CLI: If you haven't already, install the Firebase CLI: npm install -g firebase-tools
  2. Login: Login to Firebase: firebase login
  3. Install Emulator Suite: If needed, setup the emulators: firebase init emulators (select Firestore)

Running Tests:

  1. Start the Firebase emulators:
npm run start:emulators
  1. 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

MIT

1.5.6

3 months ago

1.5.5

3 months ago

1.5.4

3 months ago

1.5.3

3 months ago

1.5.2

3 months ago

1.5.1

3 months ago

1.5.0

3 months ago

1.4.0

3 months ago

1.3.9

3 months ago

1.3.8

3 months ago

1.3.6

3 months ago

1.3.5

3 months ago

1.3.4

3 months ago

1.3.3

3 months ago

1.3.2

3 months ago

1.3.1

4 months ago

1.2.0

4 months ago

1.1.0

4 months ago

1.0.9

4 months ago

1.0.8

4 months ago

1.0.7

4 months ago

1.0.6

4 months ago

1.0.5

4 months ago

1.0.4

4 months ago

1.0.2

4 months ago

1.0.1

4 months ago

1.0.0

5 months ago