import { Storable, Query, Transaction, DataStore, CollectionParams, CollectionChange, DataStoreSentinels } from "@astronautlabs/datastore"; import * as firebase from 'firebase/app'; import 'firebase/firestore'; import { Observable, Subject, ReplaySubject, ConnectableObservable } from "rxjs"; import { publish } from "rxjs/operators"; /** * Specify a modifier for a field instead of a direct field value */ export class FbSentinels implements DataStoreSentinels { increment(number: number): unknown { return firebase.firestore.FieldValue.increment(number); } serverTimestamp(): unknown { return firebase.firestore.FieldValue.serverTimestamp(); } delete(): unknown { return firebase.firestore.FieldValue.delete(); } arrayUnion(...elements: any[]): unknown { return firebase.firestore.FieldValue.arrayUnion(...elements); } arrayRemove(...elements: any[]): unknown { return firebase.firestore.FieldValue.arrayRemove(...elements); } } export class FbTransaction implements Transaction { constructor( private store : FbDataStore, private txn : firebase.firestore.Transaction ) { } async create(collectionPath : string, data : T): Promise { let firestore = this.store.firestore; let docRef = firestore.collection(collectionPath).doc(); try { await this.txn.set(docRef, data); } catch (e) { handleError(e, `datastoreTransaction.create('${collectionPath}', ...)`, data); } data.id = docRef.id; return data; } async read(docPath : string): Promise { try { return (await this.txn.get(this.store.firestore.doc(docPath)))?.data() as T; } catch (e) { handleError(e, `datastoreTransaction.read('${docPath}')`); } } async update(docPath : string, data : T): Promise { try { await this.txn.update(this.store.firestore.doc(docPath), data); } catch (e) { handleError(e, `datastoreTransaction.update('${docPath}', ...)`, data); } } async set(docPath : string, data : T): Promise { try { await this.txn.set(this.store.firestore.doc(docPath), data); } catch (e) { handleError(e, `datastoreTransaction.set('${docPath}', ...)`, data); } } async delete(docPath : string) { try { await this.txn.delete(this.store.firestore.doc(docPath)); } catch (e) { handleError(e, `datastoreTransaction.delete('${docPath}', ...)`); } } async readAll(docPaths : string[]): Promise { return ( await Promise.all( docPaths .map(docPath => this.store.firestore.doc(docPath)) .map(x => this.txn.get(x)) ) ) .map(x => x.data()) ; } } class FbQuery implements Query { constructor( private _query : firebase.firestore.Query ) { } where(fieldName : string, operator : string, value : any) { return new FbQuery(this._query.where(fieldName, operator as any, value)); } async get() { let snapshot = await this._query.get(); return snapshot.docs.map(x => x.data()) as T[]; } } interface LazyConnectionOptions { start : (subject : Subject) => void; stop : () => void; } function lazyConnection(options : LazyConnectionOptions): Observable { let obs = new Observable(observer => { let subject = new Subject(); let subscription = subject.subscribe(observer); options.start(subject); return () => { subscription.unsubscribe(); options.stop(); }; }); return (>obs.pipe(publish())).refCount(); } function printError(error : any, operationName : string, data? : any) { console.groupCollapsed(`Error '${error.message}' during ${operationName}`); console.log(`Details:`); console.error(error); if (data) { console.log(`Data:`); console.dir(data); } console.groupEnd(); } function handleError(error : any, operationName : string, data? : any) { printError(error, operationName, data); throw error; } export class FbDataStore implements DataStore { constructor( readonly firestore : firebase.firestore.Firestore ) { } sentinels = new FbSentinels(); async set(docPath: string, data: T): Promise { try { await this.firestore.doc(docPath).set(data); } catch (e) { handleError(e, `datastore.set('${docPath}', ...)`, data); } } async create(collectionPath : string, data : T): Promise { let colRef = this.firestore.collection(collectionPath); let docRef : firebase.firestore.DocumentReference; try { docRef = await colRef.add(data); } catch (e) { handleError(e, `datastore.create('${collectionPath}', ...)`, data); } data.id = docRef.id; return data; } async transact(handler : (txn : FbTransaction) => Promise) { return await this.firestore.runTransaction(async fsTxn => { let txn = new FbTransaction(this, fsTxn); return await handler(txn); }); } async read(docPath : string): Promise { let docRef = this.firestore.doc(docPath); let snap : firebase.firestore.DocumentSnapshot; try { snap = await docRef.get(); } catch (e) { handleError(e, `datastore.read('${docPath}')`); } let t = snap.data() as T; if (t) t.id = docRef.id; return t; } query(collectionPath : string): Query { return new FbQuery(this.firestore.collection(collectionPath)); } watch(docPath: string): Observable { let doc = this.firestore.doc(docPath); let unsub : () => void; return lazyConnection({ start: (subject) => { unsub = doc.onSnapshot({ next: snap => subject.next(snap.data()), error: error => (printError(error, `watch('${docPath}')`), subject.error(error)) }); }, stop: () => unsub() }); } watchForChanges(collectionPath : string, params? : CollectionParams): Observable[]> { let collection : firebase.firestore.Query = this.firestore.collection(collectionPath); if (params?.order) collection = collection.orderBy(params?.order?.field, params?.order?.direction); if (params?.limit) collection = collection.limit(params?.limit); if (params?.startAfter) collection = collection.startAfter(params?.startAfter); let unsub : () => void; return lazyConnection[]>({ start: (subject) => unsub = collection.onSnapshot(snap => subject.next([]>snap.docChanges().map(x => ({ type: x.type, document: x.doc.data() })))), stop: () => unsub() }); } watchAll(collectionPath: string, params? : CollectionParams): Observable { let collection : firebase.firestore.Query = this.firestore.collection(collectionPath); if (params?.order) collection = collection.orderBy(params?.order?.field, params?.order?.direction); if (params?.limit) collection = collection.limit(params?.limit); if (params?.startAfter) collection = collection.startAfter(params?.startAfter); let unsub : () => void; return lazyConnection({ start: (subject) => unsub = collection.onSnapshot({ next: snap => subject.next(snap.docs.map(x => x.data())), error: error => (printError(error, `watchAll('${collectionPath}')`), subject.error(error)) }), stop: () => unsub() }); } async listAll(collectionPath : string, params? : CollectionParams): Promise { let collection : firebase.firestore.Query = this.firestore.collection(collectionPath); if (params?.order) collection = collection.orderBy(params?.order?.field, params?.order?.direction); if (params?.limit) collection = collection.limit(params?.limit); if (params?.startAfter) collection = collection.startAfter(params?.startAfter); let snap : firebase.firestore.QuerySnapshot; try { snap = await collection.get(); } catch (e) { handleError(e, `datastore.listAll('${collectionPath}')`); } return snap .docs .map(x => Object.assign( {}, x.data(), { id: x.id } ) ) as T[]; } async update(docPath : string, data : Partial): Promise { let docRef = this.firestore.doc(docPath); try { await docRef.set(data, { merge: true }); } catch (e) { handleError(e, `datastore.update('${docPath}')`); } } async delete(docPath : string): Promise { let docRef = this.firestore.doc(docPath); try { await docRef.delete(); } catch (e) { handleError(e, `datastore.delete('${docPath}')`); } } async mirrorInTransaction(txn : FbTransaction, primaryKey : string, mirrorKeys : string[], data?): Promise { try { if (!data) data = await txn.read(primaryKey); } catch (e) { console.error(`Caught error while fetching content for mirroring from '${primaryKey}' (would have mirrored to ${mirrorKeys.length} keys: ${mirrorKeys.join(', ')})`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.groupEnd(); throw e; } if (!data) throw new Error(`No such object ${primaryKey}`); try { await Promise.all(mirrorKeys.map(key => txn.set(key, data))); } catch (e) { console.error(`Caught error while mirroring content from '${primaryKey}' to ${mirrorKeys.length} keys: ${mirrorKeys.join(', ')}`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.log(`The data being mirrored:`); console.dir(data); console.groupEnd(); throw e; } } async mirror(primaryKey : string, mirrorKeys : string[], data?): Promise { let primaryRef = this.firestore.doc(primaryKey); let mirrorRefs = mirrorKeys.map(x => this.firestore.doc(x)); try { if (!data) data = (await primaryRef.get()).data(); } catch (e) { console.error(`Caught error while fetching content for mirroring from '${primaryKey}' (would have mirrored to ${mirrorKeys.length} keys: ${mirrorKeys.join(', ')})`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.groupEnd(); throw e; } if (!data) throw new Error(`No such object ${primaryKey}`); try { await Promise.all(mirrorRefs.map(ref => ref.set(data))); } catch (e) { console.error(`Caught error while mirroring content from '${primaryKey}' to ${mirrorKeys.length} keys: ${mirrorKeys.join(', ')}`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.log(`The data being mirrored:`); console.dir(data); console.groupEnd(); throw e; } } async createAndMirror(collectionPath : string, data : T, mirrorKeys : string[]): Promise { let record : T; await this.transact(async txn => { try { record = await txn.create(collectionPath, data); } catch (e) { console.error(`Caught error while creating entry in '${collectionPath}' (and mirroring to ${mirrorKeys.length} keys: ${mirrorKeys.join(', ')})`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.log(`The data being written:`); console.dir(data); console.groupEnd(); throw e; } await this.mirrorInTransaction( txn, `${collectionPath}/${record.id}`, mirrorKeys.map(key => key.replace(/:id/g, record.id)), record ); }); try { record = await this.create(collectionPath, data); } catch (e) { console.error(`Caught error while creating entry in '${collectionPath}' (and mirroring to ${mirrorKeys.length} keys: ${mirrorKeys.join(', ')})`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.log(`The data being written:`); console.dir(data); console.groupEnd(); throw e; } await this.mirror(`${collectionPath}/${record.id}`, mirrorKeys.map(key => key.replace(/:id/g, record.id), record)); return record; } async multiUpdate(docPaths : string[], data : T): Promise { try { await Promise.all(docPaths.map(path => this.update(path, data))); } catch (e) { console.error(`Caught error during multi-update to ${docPaths.length} keys: ${docPaths.join(', ')})`); console.error(e); console.groupCollapsed('Debug Details'); console.dir(`Object view of error:`); console.dir(e); console.log(`The data being written:`); console.dir(data); console.groupEnd(); throw e; } } }