import { FirestoreTextSearchController, FirestoreTextSearchControllerBuilder } from "../types"; import { FirebaseApp } from "@firebase/app"; import { getFunctions, httpsCallable } from "@firebase/functions"; import { EntityCollection, ResolvedEntityCollection } from "@firecms/core"; /** * Configuration returned by the FireCMS Search Extension */ interface SearchConfig { host: string; port: number; protocol: "http" | "https"; apiKey: string; collectionsToIndex: string[]; path?: string; } /** * Options for building the FireCMS Search Controller */ export interface FireCMSSearchControllerOptions { /** * The Firebase region where the extension is deployed. */ region: string; /** * The extension instance ID. Defaults to "firecms-search". * Use this if you installed the extension with a custom instance ID. */ extensionInstanceId?: string; /** * Custom Typesense configuration. If provided, skips fetching from extension. * Use this if you want to connect to your own Typesense instance. */ customConfig?: { host: string; port?: number; protocol?: "http" | "https"; apiKey: string; path?: string; }; /** * Override the collections to index returned by the extension. * Use this if you want to restrict search to specific collections on the client side, * regardless of what is configured in the extension. */ collections?: string[]; } /** * Creates a text search controller that uses the FireCMS Search Extension. * * This requires the `firecms-search` extension to be installed in the user's * Firebase project. The extension automatically deploys Typesense to Cloud Run * and syncs Firestore data. * * @example * ```typescript * import { buildFireCMSSearchController } from "@firecms/firebase"; * * // Using the extension (recommended) * const textSearchControllerBuilder = buildFireCMSSearchController(); * * // Or with custom Typesense instance * const textSearchControllerBuilder = buildFireCMSSearchController({ * customConfig: { * host: "your-typesense-instance.com", * apiKey: "your-api-key" * } * }); * * * ``` * * @param options - Configuration options * @returns A FirestoreTextSearchControllerBuilder * * @group Firebase */ export function buildFireCMSSearchController( options?: FireCMSSearchControllerOptions ): FirestoreTextSearchControllerBuilder { const region = options?.region || "us-central1"; const extensionInstanceId = options?.extensionInstanceId || "typesense-search"; let searchConfig: SearchConfig | null = null; let typesenseClient: any = null; let initPromise: Promise | null = null; return ({ firebaseApp }: { firebaseApp: FirebaseApp }): FirestoreTextSearchController => { /** * Initializes the Typesense client */ const initializeClient = async (): Promise => { if (typesenseClient) return; // Use custom config if provided if (options?.customConfig) { searchConfig = { host: options.customConfig.host, port: options.customConfig.port || 443, protocol: options.customConfig.protocol || "https", apiKey: options.customConfig.apiKey, path: options.customConfig.path, collectionsToIndex: ["*"], }; } else { // Fetch config from extension const functions = getFunctions(firebaseApp, region); const getConfig = httpsCallable( functions, `ext-${extensionInstanceId}-getSearchConfig` ); try { const result = await getConfig(); searchConfig = result.data; if (options?.collections && options.collections.length > 0) { searchConfig.collectionsToIndex = options.collections; } } catch (error: any) { console.error("Failed to get search config from extension:", error); throw new Error( "Failed to initialize FireCMS Search. " + "Make sure the firecms-search extension is installed and configured. " + `Error: ${error.message || error}` ); } } if (!searchConfig) { throw new Error("Search config not available"); } // Dynamically import Typesense client to avoid bundling if not used const Typesense = (await import("typesense")).default; typesenseClient = new Typesense.Client({ nodes: [{ host: searchConfig.host, port: searchConfig.port, protocol: searchConfig.protocol, path: searchConfig.path || "", }], apiKey: searchConfig.apiKey, connectionTimeoutSeconds: 5, retryIntervalSeconds: 0.5, numRetries: 2, }); }; /** * Converts a Firestore path to Typesense collection name * e.g., "users/123/orders" -> "users_orders" */ const getTypesenseCollectionName = (path: string): string => { const pathParts = path.split("/"); // Extract collection names (even indices) and join with underscore const collectionNames: string[] = []; for (let i = 0; i < pathParts.length; i += 2) { if (pathParts[i]) { collectionNames.push(pathParts[i]); } } return collectionNames.join("_"); }; /** * Extracts parent filter for subcollection queries * e.g., "users/123/orders" -> { "_parent_users_id": "123" } */ const getParentFilter = (path: string): string | null => { const pathParts = path.split("/"); if (pathParts.length <= 1) return null; // Build filter for parent IDs const filters: string[] = []; for (let i = 0; i < pathParts.length - 1; i += 2) { const collectionName = pathParts[i]; const docId = pathParts[i + 1]; if (collectionName && docId) { filters.push(`_parent_${collectionName}_id:=${docId}`); } } return filters.length > 0 ? filters.join(" && ") : null; }; /** * Initializes search for a specific collection path */ const init = async (props: { path: string; collection?: EntityCollection | ResolvedEntityCollection; databaseId?: string; }): Promise => { try { // Ensure client is initialized (only once) if (!initPromise) { initPromise = initializeClient(); } await initPromise; if (!searchConfig) return false; // Get collection pattern (e.g., "users/orders" from "users/123/orders") const pathParts = props.path.split("/"); const collectionNames: string[] = []; for (let i = 0; i < pathParts.length; i += 2) { if (pathParts[i]) collectionNames.push(pathParts[i]); } const collectionPattern = collectionNames.join("/"); const rootCollection = collectionNames[0]; // Check if this collection is indexed if (searchConfig.collectionsToIndex.includes("*")) { return true; } // Check exact pattern or root collection return searchConfig.collectionsToIndex.includes(collectionPattern) || searchConfig.collectionsToIndex.includes(rootCollection); } catch (error) { console.error("Failed to initialize FireCMS Search:", error); return false; } }; // Cache for Typesense collection schemas (field names) const schemaCache: Map = new Map(); /** * Fetches the Typesense collection schema and returns searchable string field names. * Results are cached to avoid repeated API calls. */ const getSearchableFieldsFromSchema = async (collectionName: string): Promise => { // Check cache first if (schemaCache.has(collectionName)) { return schemaCache.get(collectionName)!; } try { const collection = await typesenseClient.collections(collectionName).retrieve(); // Extract string fields from the schema const stringFields = collection.fields .filter((f: any) => { // Include string and string[] types, exclude internal fields const isStringType = f.type === "string" || f.type === "string[]" || f.type === "string*" || f.type === "auto"; const isNotInternal = !f.name.startsWith("_") && f.name !== ".*"; return isStringType && isNotInternal; }) .map((f: any) => f.name); schemaCache.set(collectionName, stringFields); return stringFields; } catch (error: any) { if (error.httpStatus === 404) { throw new Error( `Collection "${collectionName}" not found in Typesense. ` + "Make sure the collection has been indexed. Try running the backfill function." ); } throw error; } }; /** * Performs a search and returns document IDs * Supports subcollections by filtering on parent IDs */ const search = async (props: { searchString: string; path: string; databaseId?: string; collection?: EntityCollection | ResolvedEntityCollection; }): Promise => { if (!typesenseClient) { // Ensure client is initialized if (!initPromise) { initPromise = initializeClient(); } await initPromise; } if (!typesenseClient) { throw new Error("Typesense client not initialized. Check extension configuration."); } // Convert path to Typesense collection name const collectionName = getTypesenseCollectionName(props.path); // Get parent filter for subcollections const parentFilter = getParentFilter(props.path); // Get searchable fields from the actual Typesense schema const searchableFields = await getSearchableFieldsFromSchema(collectionName); if (searchableFields.length === 0) { throw new Error( `No searchable string fields found in Typesense collection "${collectionName}". ` + "Make sure some documents have been indexed with string fields." ); } const queryBy = searchableFields.join(","); try { const searchParams: any = { q: props.searchString, query_by: queryBy, per_page: 100, prefix: true, // Enable prefix matching typo_tokens_threshold: 1, // Allow some typos }; // Add filter for subcollection queries if (parentFilter) { searchParams.filter_by = parentFilter; } const result = await typesenseClient .collections(collectionName) .documents() .search(searchParams); // Extract document IDs from hits const ids = result.hits?.map((hit: any) => hit.document.id) ?? []; return ids as readonly string[]; } catch (error: any) { // Parse error message for user-friendly display const message = error.message || error.toString(); throw new Error(`Search failed: ${message}`); } }; return { init, search }; }; }