diff --git a/.eslintrc.json b/.eslintrc.json index 7164c7f..b968689 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -61,7 +61,7 @@ "no-obj-calls": 2, "no-octal": 2, "no-prototype-builtins": 2, - "no-redeclare": 2, + "no-redeclare": "off", "no-regex-spaces": 2, "no-self-assign": 2, "no-setter-return": 2, diff --git a/src/lib/firebase/firestore/firestore.ts b/src/lib/firebase/firestore/firestore.ts index 5c10537..5c38f64 100644 --- a/src/lib/firebase/firestore/firestore.ts +++ b/src/lib/firebase/firestore/firestore.ts @@ -15,15 +15,41 @@ * limitations under the License. */ -import { FirebaseApp } from "@firebase/app"; -import { getFirestore, Firestore as Database } from "@firebase/firestore"; import { App } from "firebase-admin/app"; -import { getFirestore as adminGetFirestore, Firestore as AdminDatabase } from "firebase-admin/firestore"; +import { + getFirestore as adminGetFirestore, + SetOptions, + type Firestore as AdminDatabase, +} from "firebase-admin/firestore"; +import { FirebaseApp } from "@firebase/app"; +import { + getFirestore, + Firestore as Database, + disableNetwork, + enableNetwork, + getDocs, + DocumentReference, + getDoc, + onSnapshot, + setDoc, + updateDoc, + deleteDoc, +} from "@firebase/firestore"; + import { FirestoreError } from "./error"; +import { DataSnapshot, ErrorCallback, QueryConfig, QueryMethod, SubscribeCallback, Unsubscribe } from "./types"; +import { documentFrom, queryFrom, Snapshot } from "./utils"; import { Client } from "../client"; +/** + * Class representing a Firestore database. + * @remarks Firestore has not a real connection state, so use the RTDB connection state instead. + * See {@link https://firebase.google.com/docs/firestore/rtdb-vs-firestore?hl=en#presence | Firestore vs RTDB} presence comparison. + * See also the simulated {@link https://firebase.google.com/docs/firestore/solutions/presence | Firestore presence} example. + */ export class Firestore { private _database!: AdminDatabase | Database; + private _isOffline: boolean = false; constructor(public readonly client: Client) { if (!(client instanceof Client)) throw new TypeError("Firestore must be instantiated with Client as parameter"); @@ -35,6 +61,10 @@ export class Firestore { return this._database; } + public get offline(): boolean { + return this._isOffline; + } + private getDatabase() { if (!this.client.app || !this.client.clientInitialised) throw new FirestoreError("Firestore is called before the Client is initialized"); @@ -46,10 +76,99 @@ export class Firestore { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - protected isAdminApp(app: App | FirebaseApp): app is App { + private isAdmin(db: AdminDatabase | Database): db is AdminDatabase { if (this.client.admin === undefined) throw new FirestoreError("Property 'admin' missing in App class"); return this.client.admin; } - // TODO: add methods for querying + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + private isAdminApp(app: App | FirebaseApp): app is App { + if (this.client.admin === undefined) throw new FirestoreError("Property 'admin' missing in App class"); + return this.client.admin; + } + + public goOffline(): Promise { + this._isOffline = true; + // TODO: this._database.terminate() kill the instance + return this._database instanceof Database ? disableNetwork(this._database) : Promise.resolve(); + } + + public goOnline(): Promise { + this._isOffline = false; + // TODO: How to re-use the database after `terminate()` + if (this._database instanceof Database) { + return enableNetwork(this._database); + } + + return Promise.resolve(); + } + + public async get(config: QueryConfig): Promise { + if (this.isAdmin(this._database)) { + const snapshot = await queryFrom(this._database, config).get(); + + return Snapshot.from(snapshot); + } + + const query = queryFrom(this._database, config); + + if (query instanceof DocumentReference) { + return Snapshot.from(await getDoc(query)); + } + + return Snapshot.from(await getDocs(query)); + } + + // For autocomple - TODO listDocuments + public async listCollections(): Promise> { + if (this.isAdmin(this._database)) { + const collections = await this._database.listCollections(); + return collections.map((collection) => collection.id); + } + + // TODO: Firebase JS don't have listCollections function + return Promise.resolve([]); + } + + public async modify(method: QueryMethod, config: QueryConfig, value?: object, options: SetOptions = {}) { + switch (method) { + case "set": + if (!value || typeof value !== "object") throw new TypeError("Value to write must be an object"); + if (this.isAdmin(this._database)) { + return documentFrom(this._database, config).set(value, options); + } + return setDoc(documentFrom(this._database, config), value, options); + case "update": + if (!value || typeof value !== "object") throw new TypeError("Value to write must be an object"); + if (this.isAdmin(this._database)) { + return documentFrom(this._database, config).update(value); + } + return updateDoc(documentFrom(this._database, config), value); + case "delete": + if (this.isAdmin(this._database)) { + return documentFrom(this._database, config).delete(); + } + return deleteDoc(documentFrom(this._database, config)); + default: + throw new Error(`Write method should be one of "set", "update" or "delete"`); + } + } + + public subscribe(config: QueryConfig, callback: SubscribeCallback, errorCallback?: ErrorCallback): Unsubscribe { + if (this.isAdmin(this._database)) { + return queryFrom(this._database, config).onSnapshot( + (snapshot) => callback(Snapshot.from(snapshot)), + errorCallback + ); + } + + const query = queryFrom(this._database, config); + + if (query instanceof DocumentReference) { + return onSnapshot(query, (snapshot) => callback(Snapshot.from(snapshot)), errorCallback); + } + + return onSnapshot(query, (snapshot) => callback(Snapshot.from(snapshot)), errorCallback); + } } diff --git a/src/lib/firebase/firestore/index.ts b/src/lib/firebase/firestore/index.ts index e214cf3..dbefeec 100644 --- a/src/lib/firebase/firestore/index.ts +++ b/src/lib/firebase/firestore/index.ts @@ -15,5 +15,7 @@ * limitations under the License. */ -export * from "./firestore"; export * from "./error"; +export * from "./firestore"; +export * from "./types"; +export * from "./utils"; diff --git a/src/lib/firebase/firestore/types.ts b/src/lib/firebase/firestore/types.ts new file mode 100644 index 0000000..5a64792 --- /dev/null +++ b/src/lib/firebase/firestore/types.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2023 Gauthier Dandele + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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 { DocumentChangeType, DocumentData, OrderByDirection, WhereFilterOp } from "firebase-admin/firestore"; + +export type DocumentId = string; + +export interface DocumentChange { + id: DocumentId; + doc: DocumentData; + newIndex: number; + oldIndex: number; + type: DocumentChangeType; +} + +export interface CollectionData { + changes: Array; + docs: Record; + size: number; +} + +export type DataSnapshot = CollectionData | DocumentData | null; + +export type SubscribeCallback = (snapshot: DataSnapshot) => void; +export type ErrorCallback = (error: Error) => void; +export type Unsubscribe = () => void; + +export interface Constraint { + endAt?: unknown; + endBefore?: unknown; + limitToFirst?: number; + limitToLast?: number; + orderBy?: { fieldPath: string; direction?: OrderByDirection }; + offset?: number; + select?: string | Array; + startAfter?: unknown; + startAt?: unknown; + where?: { fieldPath: string; filter: WhereFilterOp; value: unknown }; +} + +export interface QueryConfig { + collection?: string; + collectionGroup?: string; + constraints?: Constraint; + document?: string; +} + +export type QueryMethod = "delete" | "set" | "update"; + +interface SetMergeOption { + merge?: boolean; +} + +interface SetMergeFieldsOption { + mergeFields?: Array; +} + +export type SetOptions = SetMergeOption | SetMergeFieldsOption; diff --git a/src/lib/firebase/firestore/utils.ts b/src/lib/firebase/firestore/utils.ts new file mode 100644 index 0000000..efbd302 --- /dev/null +++ b/src/lib/firebase/firestore/utils.ts @@ -0,0 +1,301 @@ +/** + * @license + * Copyright 2023 Gauthier Dandele + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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 { + CollectionGroup as AdminCollectionGroup, + CollectionReference as AdminCollectionReference, + DocumentData, + type DocumentReference as AdminDocumentReference, + DocumentSnapshot as AdminDocumentSnapshot, + type Firestore as AdminFirestore, + type Query as AdminQuery, + type QuerySnapshot as AdminQuerySnapshot, + FieldValue, + GeoPoint as AdminGeoPoint, +} from "firebase-admin/firestore"; +import { + arrayRemove, + arrayUnion, + collection, + collectionGroup, + CollectionReference, + deleteField, + doc, + type DocumentReference, + DocumentSnapshot, + endAt, + endBefore, + type Firestore, + GeoPoint, + increment, + limit, + limitToLast, + orderBy, + type Query, + query, + type QueryConstraint, + type QuerySnapshot, + serverTimestamp, + startAfter, + startAt, + where, +} from "@firebase/firestore"; +import { CollectionData, Constraint, DataSnapshot, DocumentChange, DocumentId, QueryConfig } from "./types"; +import { Entry } from "../utils"; + +// TODO: type guards +function applyQueryConstraints(constraints: Constraint | undefined, query: AdminQuery): AdminQuery; +function applyQueryConstraints(constraints: Constraint | undefined): Array; +function applyQueryConstraints(constraints: Constraint = {}, query?: AdminQuery): AdminQuery | Array { + if (typeof constraints !== "object") throw new TypeError("Query Constraints must be an Object!"); + + const queryConstraints: Array = []; + const constraintObject = Object.entries(constraints) as Entry[]; + for (const [method, value] of constraintObject) { + switch (method) { + case "endAt": + case "endBefore": + case "startAfter": + case "startAt": { + const firestore = { endAt, endBefore, startAfter, startAt }; + + if (query) { + query[method](value); + } else { + queryConstraints.push(firestore[method](value)); + } + break; + } + case "limitToFirst": + if (typeof value !== "number") throw new TypeError(`The value of the "${method}" constraint must be a number!`); + + if (query) { + query.limit(value); + } else { + queryConstraints.push(limit(value)); + } + break; + case "limitToLast": + if (typeof value !== "number") throw new TypeError(`The value of the "${method}" constraint must be a number!`); + + if (query) { + query.limitToLast(value); + } else { + queryConstraints.push(limitToLast(value)); + } + break; + case "offset": + if (typeof value !== "number") throw new TypeError(`The value of the "${method}" constraint must be a number!`); + + if (query) { + query.offset(value); + } else { + throw new Error("Offset Query Constraint not available for this SDK."); + } + break; + case "orderBy": + if (query) { + query.orderBy(value.fieldPath, value.direction); + } else { + queryConstraints.push(orderBy(value.fieldPath, value.direction)); + } + break; + case "select": + if (query) { + query.select(...value); + } else { + throw new Error("Select Query Constraint not available for this SDK."); + } + break; + case "where": + if (query) { + query.where(value.fieldPath, value.filter, value.value); + } else { + queryConstraints.push(where(value.fieldPath, value.filter, value.value)); + } + break; + default: + continue; + } + } + + return query || queryConstraints; +} + +function isAdminFirestore(firestore: AdminFirestore | Firestore): firestore is AdminFirestore { + return "settings" in firestore && "databaseId" in firestore; +} + +function documentFrom(firestore: AdminFirestore, config: QueryConfig): AdminDocumentReference; +function documentFrom(firestore: Firestore, config: QueryConfig): DocumentReference; +function documentFrom( + firestore: AdminFirestore | Firestore, + config: QueryConfig +): AdminDocumentReference | DocumentReference { + let reference; + + if (config.collection) { + if (typeof config.collection !== "string") throw new TypeError("CollectionPath must be a String."); + + if (isAdminFirestore(firestore)) { + reference = firestore.collection(config.collection); + } else { + reference = collection(firestore, config.collection); + } + } + + if (config.document) { + if (typeof config.document !== "string") throw new TypeError("DocumentPath must be a String."); + + if (isAdminFirestore(firestore)) { + return reference instanceof AdminCollectionReference + ? reference.doc(config.document) + : firestore.doc(config.document); + } + return reference instanceof CollectionReference ? doc(reference, config.document) : doc(firestore, config.document); + } else { + throw new Error("DocumentPath missing for Document reference."); + } +} + +function queryFrom(firestore: AdminFirestore, config: QueryConfig): AdminDocumentReference | AdminQuery; +function queryFrom(firestore: Firestore, config: QueryConfig): DocumentReference | Query; +function queryFrom( + firestore: AdminFirestore | Firestore, + config: QueryConfig +): AdminDocumentReference | AdminQuery | DocumentReference | Query { + let reference; + + if (config.collectionGroup) { + if (typeof config.collectionGroup !== "string") throw new TypeError("CollectionGroupPath must be a String."); + if (config.document) throw new Error("DocumentPath must be empty with CollectionGroup."); + + if (isAdminFirestore(firestore)) { + reference = firestore.collectionGroup(config.collectionGroup); + } else { + reference = collectionGroup(firestore, config.collectionGroup); + } + } else if (config.collection) { + if (typeof config.collection !== "string") throw new TypeError("CollectionPath must be a String."); + + if (isAdminFirestore(firestore)) { + reference = firestore.collection(config.collection); + } else { + reference = collection(firestore, config.collection); + } + } + + if (config.document) { + if (typeof config.document !== "string") throw new TypeError("DocumentPath must be a String."); + + if (isAdminFirestore(firestore)) { + return reference instanceof AdminCollectionReference + ? reference.doc(config.document) + : firestore.doc(config.document); + } + return reference instanceof CollectionReference ? doc(reference, config.document) : doc(firestore, config.document); + } + + if (!reference) throw new Error("No Path Given - Collection(Group)Path or/and DocumentPath missing"); + + if (reference instanceof AdminCollectionReference || reference instanceof AdminCollectionGroup) { + return applyQueryConstraints(config.constraints, reference); + } + + return query(reference, ...applyQueryConstraints(config.constraints)); +} + +class Snapshot { + public static from( + data: AdminDocumentSnapshot | AdminQuerySnapshot | DocumentSnapshot | QuerySnapshot + ): DataSnapshot { + if (data instanceof DocumentSnapshot || data instanceof AdminDocumentSnapshot) { + return data.data() || null; + } + + return new Snapshot(data).toJSON(); + } + + private constructor(private query: AdminQuerySnapshot | QuerySnapshot) {} + + public get changes(): Array { + return this.query.docChanges().map((change) => ({ + id: change.doc.id, + doc: change.doc.data(), + newIndex: change.newIndex, + oldIndex: change.oldIndex, + type: change.type, + })); + } + + /** An array of all the documents in the QuerySnapshot. */ + public get docs(): Record { + return this.query.docs.reduce>((acc, doc) => { + acc[doc.id] = doc.data(); + return acc; + }, {}); + } + + /** The number of documents in the QuerySnapshot. */ + public get size(): number { + return this.query.size; + } + + public toJSON(): CollectionData { + return { docs: this.docs, size: this.size, changes: this.changes }; + } +} + +class SpecialFieldValue { + private admin: boolean = false; + + public constructor(isAdminFirestore: boolean) { + this.admin = isAdminFirestore; + } + + public arrayUnion(...elements: unknown[]) { + if (this.admin) return FieldValue.arrayUnion(...elements); + return arrayUnion(...elements); + } + + public arrayRemove(...elements: unknown[]) { + if (this.admin) return FieldValue.arrayRemove(...elements); + return arrayRemove(...elements); + } + + public delete() { + if (this.admin) return FieldValue.delete(); + return deleteField(); + } + + public geoPoint(latitude: number, longitude: number) { + if (this.admin) return new AdminGeoPoint(latitude, longitude); + return new GeoPoint(latitude, longitude); + } + + public increment(delta: number) { + if (this.admin) return FieldValue.increment(delta); + return increment(delta); + } + + public serverTimestamp() { + if (this.admin) return FieldValue.serverTimestamp(); + return serverTimestamp(); + } +} + +export { applyQueryConstraints, documentFrom, queryFrom, Snapshot, SpecialFieldValue }; diff --git a/src/lib/nodes/firebase-client.ts b/src/lib/nodes/firebase-client.ts index 3b667cc..9c44d2f 100644 --- a/src/lib/nodes/firebase-client.ts +++ b/src/lib/nodes/firebase-client.ts @@ -125,11 +125,15 @@ export class FirebaseClient { this.node.warn(`WARNING: '${name}' config node is unused! All connections with Firebase will be closed...`); } - // TODO: Add firestore if (rtdb.length === 0 && this.node.rtdb && !this.node.rtdb.offline) { this.node.rtdb.goOffline(); this.node.log("Connection with Firebase RTDB was closed because no node used."); } + + if (firestore.length === 0 && this.node.firestore && !this.node.firestore.offline) { + this.node.firestore.goOffline(); + this.node.log("Connection with Firestore was closed because no node used."); + } } private disableGlobalLogHandler() { @@ -331,9 +335,12 @@ export class FirebaseClient { // TODO: Add firestore const rtdbOnline = this.node.rtdb && !this.node.rtdb.offline; + const firestoreOnline = this.node.firestore && !this.node.firestore.offline; if (rtdbOnline) this.node.log("Closing connection with Firebase RTDB"); + if (firestoreOnline) this.node.log("Closing connection with Firestore"); + // Only RTDB has connection state this.node.rtdb?.removeConnectionState(); this.disableConnectionHandler(); @@ -391,8 +398,8 @@ export class FirebaseClient { */ private restoreDestroyedConnection() { this.destroyUCMsgEmitted = false; - // TODO: Add firestore - if (this.node.rtdb?.offline) this.node.rtdb.goOnline(); + if (this.node.rtdb?.offline && this.statusListeners.rtdb) this.node.rtdb.goOnline(); + if (this.node.firestore?.offline && this.statusListeners.firestore) this.node.firestore.goOnline(); } private setCurrentStatus(id: string) {