/* * Copyright (c) 2016-present Invertase Limited & Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this library except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { setLogLevel, connectFirestoreEmulator, initializeFirestore, runTransaction, getApp, getFirestore, collection, collectionGroup, doc, getDoc, getDocs, getCount, getAggregate, count, average, sum, deleteDoc, setDoc, updateDoc, writeBatch, terminate, } from '@react-native-firebase/app/dist/module/internal/web/firebaseFirestore'; import type { Firestore, Transaction, } from '@react-native-firebase/app/dist/module/internal/web/firebaseFirestore'; import { guard, getWebError, emitEvent, } from '@react-native-firebase/app/dist/module/internal/web/utils'; import { objectToWriteable, readableToObject, parseDocumentBatches } from './convert'; import { buildQuery } from './query'; import type { FilterSpec, OrderSpec, QueryOptions } from './query'; import type { FirestorePipelineExecuteOptionsInternal, FirestorePipelineSnapshotInternal, FirestorePipelineSerializedInternal, } from '../types/internal'; import { executeWebSdkPipeline } from './pipelines/pipeline'; function rejectWithCodeAndMessage(code: string, message: string): Promise { return Promise.reject(getWebError({ code, message } as Error & { code: string })); } type DocumentSnapshotLike = { exists(): boolean; ref: { path: string }; data(options?: { serverTimestamps?: 'estimate' | 'previous' | 'none'; }): Record | undefined; }; function documentSnapshotToObject(snapshot: DocumentSnapshotLike): { path: string; exists: boolean; data?: unknown; dataEstimate?: unknown; dataPrevious?: unknown; dataNone?: unknown; metadata: [boolean, boolean]; } { const exists = snapshot.exists(); const out: { path: string; exists: boolean; data?: unknown; dataEstimate?: unknown; dataPrevious?: unknown; dataNone?: unknown; metadata: [boolean, boolean]; } = { metadata: [false, false], path: snapshot.ref.path, exists, }; if (exists) { out.data = objectToWriteable(snapshot.data() ?? {}); // Lite has no pending-write / offline queue, so server timestamps on returned snapshots are // already resolved and estimate/previous/none are identical to `data`. Still emit the keys // (via SnapshotOptions) so the JS DocumentSnapshot path stays the same as native. out.dataEstimate = objectToWriteable(snapshot.data({ serverTimestamps: 'estimate' }) ?? {}); out.dataPrevious = objectToWriteable(snapshot.data({ serverTimestamps: 'previous' }) ?? {}); out.dataNone = objectToWriteable(snapshot.data({ serverTimestamps: 'none' }) ?? {}); } return out; } function querySnapshotToObject(snapshot: { docs: DocumentSnapshotLike[] }): { source: string; excludesMetadataChanges: boolean; changes: unknown[]; metadata: [boolean, boolean]; documents: Array>; } { return { source: 'get', excludesMetadataChanges: true, changes: [], metadata: [false, false], documents: snapshot.docs.map(documentSnapshotToObject), }; } const emulatorForApp: Record = {}; const firestoreInstances: Record = {}; const appInstances: Record> = {}; const transactionHandler: Record = {}; const transactionBuffer: Record = {}; interface TransactionCommand { path: string; type: string; data?: Record; options?: Record; } function getCachedAppInstance(appName: string): ReturnType { return (appInstances[appName] ??= getApp(appName)); } function createFirestoreKey(appName: string, databaseId: string): string { return `${appName}:${databaseId}`; } function getCachedFirestoreInstance(appName: string, databaseId: string): Firestore { const firestoreKey = createFirestoreKey(appName, databaseId); let instance = firestoreInstances[firestoreKey]; if (!instance) { instance = getFirestore(getCachedAppInstance(appName), databaseId); if (emulatorForApp[firestoreKey]) { connectFirestoreEmulator( instance, emulatorForApp[firestoreKey].host, emulatorForApp[firestoreKey].port, ); } firestoreInstances[firestoreKey] = instance; } return instance; } export default { async setLogLevel(logLevel: string): Promise { if (logLevel === 'debug' || logLevel === 'error') { setLogLevel(logLevel); } else { setLogLevel('silent'); } }, loadBundle(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, clearPersistence(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, async waitForPendingWrites(): Promise { return null; }, disableNetwork(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, enableNetwork(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, addSnapshotsInSync(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, removeSnapshotsInSync(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, useEmulator(appName: string, databaseId: string, host: string, port: number): Promise { return guard(async (): Promise => { const firestore = getCachedFirestoreInstance(appName, databaseId); connectFirestoreEmulator(firestore, host, port); const firestoreKey = createFirestoreKey(appName, databaseId); emulatorForApp[firestoreKey] = { host, port }; return null; }); }, settings(appName: string, databaseId: string, settings: object): Promise { return guard(async (): Promise => { const instance = initializeFirestore( getCachedAppInstance(appName), settings as Parameters[1], databaseId, ); firestoreInstances[appName] = instance; return null; }); }, terminate(appName: string, databaseId: string): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); await terminate(firestore); return null; }); }, namedQueryOnSnapshot(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, collectionOnSnapshot(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, collectionOffSnapshot(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, namedQueryGet(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, collectionCount( appName: string, databaseId: string, path: string, type: string, filters: FilterSpec[], orders: OrderSpec[], options: QueryOptions, ): Promise<{ count: number }> { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const queryRef = type === 'collectionGroup' ? collectionGroup(firestore, path) : collection(firestore, path); const query = buildQuery(queryRef, filters, orders, options); const snapshot = await getCount(query); return { count: snapshot.data().count }; }); }, aggregateQuery( appName: string, databaseId: string, path: string, type: string, filters: FilterSpec[], orders: OrderSpec[], options: QueryOptions, aggregateQueries: Array<{ aggregateType: string; field?: unknown; key: string }>, ): Promise> { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const queryRef = type === 'collectionGroup' ? collectionGroup(firestore, path) : collection(firestore, path); const query = buildQuery(queryRef, filters, orders, options); const aggregateSpec: Record = {}; for (let i = 0; i < aggregateQueries.length; i++) { const aggregateQuery = aggregateQueries[i]!; const { aggregateType, field, key } = aggregateQuery; switch (aggregateType) { case 'count': aggregateSpec[key] = count(); break; case 'avg': aggregateSpec[key] = average(field as Parameters[0]); break; case 'sum': aggregateSpec[key] = sum(field as Parameters[0]); break; } } const result = await getAggregate(query, aggregateSpec as Parameters[1]); const data = result.data(); const response: Record = {}; for (let i = 0; i < aggregateQueries.length; i++) { const aggregateQuery = aggregateQueries[i]!; response[aggregateQuery.key] = data[aggregateQuery.key]; } return response; }); }, pipelineExecute( appName: string, databaseId: string, pipeline: FirestorePipelineSerializedInternal, options?: FirestorePipelineExecuteOptionsInternal, ): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); return executeWebSdkPipeline(firestore, pipeline, options); }); }, collectionGet( appName: string, databaseId: string, path: string, type: string, filters: FilterSpec[], orders: OrderSpec[], options: QueryOptions, getOptions?: { source?: string }, ): Promise> { if (getOptions?.source === 'cache') { return rejectWithCodeAndMessage( 'unsupported', 'The source cache is not supported in the lite SDK.', ); } return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const queryRef = type === 'collectionGroup' ? collectionGroup(firestore, path) : collection(firestore, path); const query = buildQuery(queryRef, filters, orders, options); const snapshot = await getDocs(query); return querySnapshotToObject(snapshot as { docs: DocumentSnapshotLike[] }); }); }, documentOnSnapshot(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, documentOffSnapshot(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, persistenceCacheIndexManager(): Promise { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, documentGet( appName: string, databaseId: string, path: string, getOptions?: { source?: string }, ): Promise> { return guard(async () => { if (getOptions?.source === 'cache') { return rejectWithCodeAndMessage( 'unsupported', 'The source cache is not supported in the lite SDK.', ); } const firestore = getCachedFirestoreInstance(appName, databaseId); const ref = doc(firestore, path); const snapshot = await getDoc(ref); return documentSnapshotToObject(snapshot); }); }, documentDelete(appName: string, databaseId: string, path: string): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const ref = doc(firestore, path); await deleteDoc(ref); return null; }); }, documentSet( appName: string, databaseId: string, path: string, data: Record, options: Record, ): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const ref = doc(firestore, path); const setOptions: Record = {}; if (options && 'merge' in options) { setOptions.merge = options.merge; } else if (options && 'mergeFields' in options) { setOptions.mergeFields = options.mergeFields; } await setDoc(ref, readableToObject(firestore, data), setOptions); }); }, documentUpdate( appName: string, databaseId: string, path: string, data: Record, ): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const ref = doc(firestore, path); await updateDoc(ref, readableToObject(firestore, data)); }); }, documentBatch( appName: string, databaseId: string, writes: Array>, ): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const batch = writeBatch(firestore); const writesArray = parseDocumentBatches(firestore, writes); for (const parsed of writesArray) { const { type, path } = parsed; const ref = doc(firestore, path); switch (type) { case 'DELETE': batch.delete(ref); break; case 'UPDATE': batch.update(ref, parsed.data!); break; case 'SET': { const options = parsed.options ?? {}; const setOptions: Record = {}; if ('merge' in options) { setOptions.merge = options.merge; } else if ('mergeFields' in options) { setOptions.mergeFields = options.mergeFields; } batch.set(ref, parsed.data!, setOptions); break; } } } await batch.commit(); }); }, transactionGetDocument( appName: string, databaseId: string, transactionId: number, path: string, ): Promise> { if (!transactionHandler[transactionId]) { return rejectWithCodeAndMessage( 'internal-error', 'An internal error occurred whilst attempting to find a native transaction by id.', ); } return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const docRef = doc(firestore, path); const tsx = transactionHandler[transactionId]!; const snapshot = await tsx.get(docRef); return documentSnapshotToObject(snapshot); }); }, transactionDispose(_appName: string, _databaseId: string, transactionId: number): void { delete transactionHandler[transactionId]; }, transactionApplyBuffer( _appName: string, _databaseId: string, transactionId: number, commandBuffer: TransactionCommand[], ): void { if (transactionHandler[transactionId]) { transactionBuffer[transactionId] = commandBuffer; } }, transactionBegin( appName: string, databaseId: string, transactionId: number, maxAttempts: number, ): Promise { return guard(async () => { const firestore = getCachedFirestoreInstance(appName, databaseId); const transactionOptions = maxAttempts > 0 ? { maxAttempts } : undefined; try { await runTransaction( firestore, async (tsx: Transaction) => { transactionHandler[transactionId] = tsx; emitEvent('firestore_transaction_event', { eventName: 'firestore_transaction_event', body: { type: 'update' }, appName, databaseId, listenerId: transactionId, }); const getBuffer = (): TransactionCommand[] | undefined => transactionBuffer[transactionId]; const buffer = await new Promise(resolve => { const interval = setInterval(() => { const b = getBuffer(); if (b) { clearInterval(interval); resolve(b); } }, 100); }); for (const serialized of buffer) { const { path: docPath, type, data } = serialized; const docRef = doc(firestore, docPath); switch (type) { case 'DELETE': tsx.delete(docRef); break; case 'UPDATE': tsx.update(docRef, readableToObject(firestore, data ?? {})); break; case 'SET': { const options = serialized.options ?? {}; const setOptions: Record = {}; if ('merge' in options) { setOptions.merge = options.merge; } else if ('mergeFields' in options) { setOptions.mergeFields = options.mergeFields; } tsx.set(docRef, readableToObject(firestore, data ?? {}), setOptions); break; } } } }, transactionOptions, ); emitEvent('firestore_transaction_event', { eventName: 'firestore_transaction_event', body: { type: 'complete' }, appName, databaseId, listenerId: transactionId, }); } catch (e) { emitEvent('firestore_transaction_event', { eventName: 'firestore_transaction_event', body: { type: 'error', error: getWebError(e as Error) }, appName, databaseId, listenerId: transactionId, }); } }); }, };