import { Comp , createPlugin , PluginViewCtx , ProdoPlugin } from "@prodo/core" ; import * as firebase from "firebase/app" ; import "firebase/firestore" ; import * as _ from "lodash" ; import { createFirestoreQuery , createQueryName } from "./query" ; import { ActionCtx , Collection , Config , DBQuery , Doc , DocChange , FetchAll , Fetching , Query , QueryRefs , Universe , ViewCtx , } from "./types" ; export { Collection } ; ( window as any ) . counter = 0 ; let firestore ; const cannotUseInAction = "Cannot use this method in an action" ; const cannotUseInComponent = "Cannot use this method in a React component" ; const addIdToDoc = < T > ( doc ) : T & { id : string ; } => ( { id : doc . id , ... ( doc . data ( ) as T ) , } ) ; const getSnapshotDocs = < T > ( docs ) : Array < T & { id : string ; } > => docs . map ( doc => addIdToDoc ( doc ) ) ; const firestorePlugin = < DB > ( ) : ProdoPlugin < Config < DB > , Universe , ActionCtx < DB > , ViewCtx < DB > > => { const plugin = createPlugin < Config < DB > , Universe , ActionCtx < DB > , ViewCtx < DB > > ( "firestore" ) ; const queryRefs = { } ; const updateDocs = plugin . action ( ctx => ( collectionName , docChanges ) => { docChanges . forEach ( ( { id , changeType , data } ) => { const path = [ collectionName , id ] ; const existingDoc = _ . get ( ctx . db_cache . docs , path ) || { watchers : 0 , } ; if ( changeType === "added" ) { _ . set ( ctx . db_cache . docs , path , { ... existingDoc , watchers : existingDoc . watchers + 1 , data , } ) ; } else if ( changeType === "modified" ) { _ . set ( ctx . db_cache . docs , path , { ... existingDoc , data , } ) ; } else if ( changeType === "removed" ) { _ . set ( ctx . db_cache . docs , path , { ... existingDoc , watchers : existingDoc . watchers - 1 , } ) ; const doc = _ . get ( ctx . db_cache . docs , path ) ; if ( doc . watchers <= 0 ) { _ . unset ( ctx . db_cache . docs , path ) ; } } } ) ; } , "updateDocs" ) ; const removeQuery = plugin . action ( ctx => ( collectionName , queryName ) => { const dbQuery = ctx . db_cache . queries [ collectionName ] [ queryName ] ; delete ctx . db_cache . queries [ collectionName ] [ queryName ] ; const docChanges = dbQuery . ids . map ( id => ( { id , changeType : "removed" , } ) ) ; updateDocs ( ctx ) ( collectionName , docChanges ) ; } , "removeQuery" ) ; const updateQuery = plugin . action ( ctx => ( collectionName , queryName , dbQuery ) => { const existingDbQuery = _ . get ( ctx . db_cache . queries , [ collectionName , queryName , ] ) || { ids : [ ] , query : { } , state : "fetching" , } ; _ . set ( ctx . db_cache . queries , [ collectionName , queryName ] , { ... existingDbQuery , ... dbQuery , } ) ; } , "updateQuery" ) ; const createActionCollection = < T extends { id : string ; } > ( _ctx , collectionName ) : Collection < T > => { return { getAll : async ( ) : Promise < T [ ] > => { const snapshot = await firestore . collection ( collectionName ) . get ( ) ; const data = getSnapshotDocs < T > ( snapshot . docs ) ; return data ; } , get : async ( id ) : Promise < T > => { const doc = await firestore . collection ( collectionName ) . doc ( id ) . get ( ) ; const data = addIdToDoc < T > ( doc ) ; return data ; } , set : async ( id , value ) : Promise < void > => { await firestore . collection ( collectionName ) . doc ( id ) . set ( value ) ; } , update : async ( id , value ) : Promise < void > => { await firestore . collection ( collectionName ) . doc ( id ) . set ( value , { merge : true } ) ; } , delete : async ( id ) : Promise < void > => { await firestore . collection ( collectionName ) . doc ( id ) . delete ( ) ; } , query : async ( query ) : Promise < T [ ] > => { const ref = createFirestoreQuery ( firestore . collection ( collectionName ) , query ) ; const snapshot = await ref . get ( ) ; const data = getSnapshotDocs < T > ( snapshot . docs ) ; return data ; } , insert : async ( value ) : Promise < string > => { const ref = await firestore . collection ( collectionName ) . add ( value ) ; return ref . id ; } , watch : ( ) => { throw new Error ( cannotUseInAction ) ; } , watchAll : ( ) => { throw new Error ( cannotUseInAction ) ; } , ref : ( ) : firebase . firestore . CollectionReference => { return firestore . collection ( name ) ; } , } ; } ; const subscribeComponent = ( { queryRefs , ctx , collectionName , queryName , comp , dbQuery , } ) => { if ( ! queryRefs [ queryName ] . watchers . has ( comp . name ) ) { queryRefs [ queryName ] . watchers . add ( comp . name ) ; } ctx . subscribe ( [ "db_cache" , "queries" , collectionName , queryName , "ids" ] , ( comp ) => { if ( queryRefs [ queryName ] ) { queryRefs [ queryName ] . watchers . delete ( comp . name ) ; if ( queryRefs [ queryName ] . watchers . size === 0 ) { ctx . dispatch ( removeQuery ) ( collectionName , queryName ) ; queryRefs [ queryName ] . unsubscribe ( ) ; delete queryRefs [ queryName ] ; } } } ) ; if ( dbQuery != null ) { dbQuery . ids . forEach ( id => ctx . subscribe ( [ "db_cache" , "docs" , collectionName , id ] ) ) ; } } ; const getDocsFromUniverse = < T > ( { dbQuery , universe , collectionName , } ) : FetchAll < T > => { if ( dbQuery == null || dbQuery . state === "fetching" ) { return { _fetching : true , } ; } else { if ( dbQuery . state === "error" ) { return { _notFound : true , } ; } const data = dbQuery . ids . map ( id => _ . get ( universe . db_cache . docs , [ collectionName , id ] ) ) . filter ( doc => doc != null ) . map ( doc => doc . data ) ; return { _fetching : false , _notFound : false , data , } ; } } ; const createViewCollection = < T extends { id : string ; } > ( queryRefs , ctx , collectionName , universe , comp ) : Collection < T > => { return { get : ( ) => { throw new Error ( cannotUseInComponent ) ; } , getAll : async ( ) => { throw new Error ( cannotUseInComponent ) ; } , insert : ( ) => { throw new Error ( cannotUseInComponent ) ; } , set : ( ) => { throw new Error ( cannotUseInComponent ) ; } , update : ( ) => { throw new Error ( cannotUseInComponent ) ; } , delete : ( ) => { throw new Error ( cannotUseInComponent ) ; } , query : ( ) => { throw new Error ( cannotUseInComponent ) ; } , watch : ( id ) : Fetching < T > => { const queryName = `${ collectionName }-${ id }` ; const dbQuery = _ . get ( universe . db_cache . queries , [ collectionName , queryName , ] ) ; const queryExists = dbQuery != null ; if ( ! queryExists && ! queryRefs [ queryName ] ) { ( window as any ) . counter += 1 ; let firstTime = true ; const unsubscribe = firestore . collection ( collectionName ) . doc ( id ) . onSnapshot ( snapshot => { const ids = [ id ] ; if ( snapshot . exists ) { const docChanges = [ { id , data : addIdToDoc ( snapshot ) , changeType : firstTime ? "added" : "modified" , } , ] ; firstTime = false ; ctx . dispatch ( updateDocs ) ( collectionName , docChanges ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { ids , state : "success" , } ) ; } else { firstTime = true ; const docChanges = [ { id , data : addIdToDoc ( snapshot ) , changeType : "removed" , } , ] ; ctx . dispatch ( updateDocs ) ( collectionName , docChanges ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { ids , state : "error" , } ) ; } } , error => { console . error ( "onSnapshot Error" , error ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { state : "error" , } ) ; } ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { state : "fetching" , } ) ; queryRefs [ queryName ] = { unsubscribe , watchers : new Set ( ) } ; } subscribeComponent ( { queryRefs , ctx , collectionName , queryName , comp , dbQuery , } ) ; const data = getDocsFromUniverse < T > ( { dbQuery , universe , collectionName , } ) ; if ( data . _fetching ) { return { _fetching : true , } ; } if ( data . _notFound ) { return { _notFound : true , } ; } const element = data . data [ 0 ] ; return { _fetching : false , _notFound : false , data : element , } ; } , watchAll : ( query ? ) : FetchAll < T > => { const queryName = createQueryName ( collectionName , query ) ; const dbQuery = _ . get ( universe . db_cache . queries , [ collectionName , queryName , ] ) ; const queryExists = dbQuery != null ; if ( ! queryExists && ! queryRefs [ queryName ] ) { const ref = createFirestoreQuery ( firestore . collection ( collectionName ) , query ) ; ( window as any ) . counter += 1 ; const unsubscribe = ref . onSnapshot ( snapshot => { const ids = snapshot . docs . map ( doc => doc . id ) ; const docChanges = snapshot . docChanges ( ) . map ( change => ( { id : change . doc . id , changeType : change . type , data : addIdToDoc ( change . doc ) , } ) ) ; ctx . dispatch ( updateDocs ) ( collectionName , docChanges ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { ids , state : "success" , } ) ; } , error => { console . error ( "onSnapshot Error" , error ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { state : "error" , } ) ; } ) ; ctx . dispatch ( updateQuery ) ( collectionName , queryName , { state : "fetching" , } ) ; queryRefs [ queryName ] = { unsubscribe , watchers : new Set ( ) } ; } subscribeComponent ( { queryRefs , ctx , collectionName , queryName , comp , dbQuery , } ) ; const data = getDocsFromUniverse < T > ( { dbQuery , universe , collectionName , } ) ; return data ; } , ref : ( ) : firebase . firestore . CollectionReference => { return firestore . collection ( name ) ; } , } ; } ; plugin . init ( ( config , universe ) => { if ( ! config . firestoreMock ) { firebase . initializeApp ( config . firebaseConfig ) ; firestore = firebase . firestore ( ) ; } universe . db_cache = { docs : { } , queries : { } , } ; } ) ; plugin . prepareActionCtx ( ( { ctx , universe } ) => { ctx . db_cache = universe . db_cache ; ctx . db = new Proxy ( { } , { get ( _target , key ) { return createActionCollection ( ctx , key . toString ( ) ) ; } , } ) as DB ; } ) ; plugin . prepareViewCtx ( ( { ctx , universe , comp } ) => { ctx . db = new Proxy ( { } , { get ( _target , key ) { return createViewCollection ( queryRefs , ctx , key . toString ( ) , universe , comp ) ; } , } ) as DB ; } ) ; return plugin ; } ; export default firestorePlugin ;