Deprecated
NOTE – as of 20 Nov 2017, Ephemeral exchange is migrating to a Firestore/ Firebase cloud function back end. See Migrating from App Engine to Cloud function

Receiving notifications

Initializing the server shows how the process starts off by the Server writing something to Ephemeral exchange (see also Getting started with Ephemeral Exchange for push notification


This is provoked from initializing the client

ns.init = function() {
    // handle for efx
    ns.state.efx = EffexApiClient;
    spinCursor();

    //first init the server
    return Provoke.run("Server", "init")
      .then(function(keys) {
        resetCursor();
  
        // now we can save those keys for later
        ns.state.efx.setKeys(keys);
       
        // and start watching for changes
        return ns.state.efx.on("update", keys.alias, keys.reader, Client.poked, {
          type: "push"
        });

      })
      ["catch"](function(err) {
        resetCursor();
        App.showNotification("failed to init server", err);
      });
  };

What happens on change detection

The client watches for changes using efx.on

// and start watching for changes
        return ns.state.efx.on("update", keys.alias, keys.reader, Client.poked, {
          type: "push"
        });

which fires Client.poked when anything changes. In my app, here’s what this looks like. There’s quite a lot going on here, but the main points are

  • A notification doesn’t send any useful data. It just informs that there is something that needs attention, and includes the id of the cache item that should be retrieved as in ns.state.efx.read(pack.id).then(function(result) { … etc })
  • You can use the packDigest and digest properties of the cache item to decide whether or not it’s necessary to visit the server to get a data update.
 /**
  * will be called each time theres a change
  * @param {object} poked efxchange object
  */
  ns.poked = function(wid, pack) {
 
    ns.state.poked = pack;
    
    
    // now we can get the latest data- we already have a reader key
    ns.state.efx.read(pack.id).then(function(result) {
      if (!result.data.ok) {
        App.showNotification("failed to get " + pack.id, result.data);
      }

      //latest content
      const content = result.data.value.content;
      ns.state.content = content;
      const cnew = content.activeSheet;

      // record this
      const csh = (ns.state.sheets[cnew.sheetId.toString()] =
        ns.state.sheets[cnew.sheetId.toString()] || {});
      const cold = csh.content && csh.content.activeSheet;

      // if its a an actual change of data then, go get it
      // but we'll do it in parallel to all of that.
      // promise will be resolved with null if there was no change in values
      if (!cold || cnew.digest !== cold.digest) {
        ns.getSheetValues (content.spreadsheet.id , cnew.sheetId, cnew.dataRange.a1);
      }
      else {
        // data hasnt changed
        csh.promiseSheetValues = Promise.resolve(null);
      }

      // move on and start viz while that's happening
      csh.content = content;

      // update counts
      csh.stats = csh.stats || {
        changes: []
      };

      // update the sheet selector
      const cv = DomUtils.elem ('sheet-select').value;
    
      DomUtils.changeOptions (
        'sheet-select' , 
         content.spreadsheet.sheets.map (function (d) { return {text:d.name , value:d.sheetId};}),
         typeof cv === undefined || cv === "" ? content.activeSheet.sheetId : cv );
         
      // make the grid big enough
      const dataRange = content.activeSheet.dataRange;
      adjustGrid(dataRange, csh.stats.changes);

      // next need to accumulate changes
      const activeRange = content.activeSheet.activeRange;
      accumulateChanges(content.changeType, activeRange, csh.stats.changes);

      // update the activity heat map
      csh.promiseSheetValues
        .then(function(result) {
          Render.updateHeat(DomUtils.elem ('sheet-select').value)
          .setActiverc ({
            or:activeRange.startRowIndex -1 ,
            oc:activeRange.startColumnIndex -1
          });
        })
        ["catch"](function(err) {
          App.showNotification("error getting " + pack.id, err);
        });
    });
    /**
    * make the grid big enough for the new data range
    */
    function adjustGrid(dims, grid) {
    
      const targetRows = Math.max (dims.numOfRows ,ns.state.minGridRows );
      const targetColumns = Math.max (dims.numOfColumns ,ns.state.minGridColumns );
      
      // extend no of rows
      while (targetRows > grid.length) grid.push([]);
      
      // extend no of columns
      grid.forEach(function(d) {
        while (targetColumns > d.length) d.push({});
      });

      // but its possible that the grid has shrunk, so we're going to discard previous changes
      if (grid.length > targetRows ) {
        grid = targetRows ? grid.slice (targetRows -1) : [];
      }
      
      return grid.map (function (row){
        return row.length > targetColumns ? row.slice (targetColumns -1) : row;
      })

      return grid;
    }

The Client code

The entire Client namespace is here, and is specific to my demo example, but the principle will be the similar for most use cases

/**
* manage client side activity
* @namespace Client
*/
var Client = (function(ns) {
  ns.state = {
    sheets: {},
    minGridRows:12,
    minGridColumns:6
  };
  
  ns.init = function() {
    // handle for efx
    ns.state.efx = EffexApiClient;
    spinCursor();

    //first init the server
    return Provoke.run("Server", "init")
      .then(function(keys) {
        resetCursor();
  
        // now we can save those keys for later
        ns.state.efx.setKeys(keys);
       
        // and start watching for changes
        return ns.state.efx.on("update", keys.alias, keys.reader, Client.poked, {
          type: "push"
        });

      })
      ["catch"](function(err) {
        resetCursor();
        App.showNotification("failed to init server", err);
      });
  };

  /**
   * this will set the active range back on the server
   */
  ns.setRangeFocus = function (sheetId, a1Range, follow) {
    const h = ns.state.sheets [sheetId.toString()];

    // set the default range to be the known one
    a1Range = a1Range || h&&h.content.activeSheet.activeRange.a1 || "";
    
    return Provoke.run ("Server" , "makeRangeFocus" , h&&h.content.spreadsheet.id || "", sheetId, a1Range, "getDisplayValues",follow || false)
    .then (function (result) {
      // nothing to do
    })
    ['catch'] (function (err) {
      App.showNotification ("server error setting focus", err);
    })
    
    
  };
  
  /**
   * go to the server and get sheet values
   */
  ns.getSheetValues = function (ssId , sheetId, a1Range, method ) {
  
    const csh = (ns.state.sheets[sheetId.toString()] =
     ns.state.sheets[sheetId.toString()] || {});
     
    csh.promiseSheetValues = Provoke.run(
      "Server",
      "getSheetValues",
      ssId,
      sheetId,
      a1Range,
      method || "getDisplayValues"
    )
    .then(function(result) {
      csh.sheetValues = result;
    })
    ["catch"](function(err) {
      App.showNotification("Error getting sheet data ", err);
    });
    
    return csh.promiseSheetValues;
    
  };
  /**
  * will be called each time theres a change
  * @param {object} poked efxchange object
  */
  ns.poked = function(wid, pack) {
 
    ns.state.poked = pack;
    
    
    // now we can get the latest data- we already have a reader key
    ns.state.efx.read(pack.id).then(function(result) {
      if (!result.data.ok) {
        App.showNotification("failed to get " + pack.id, result.data);
      }

      //latest content
      const content = result.data.value.content;
      ns.state.content = content;
      const cnew = content.activeSheet;

      // record this
      const csh = (ns.state.sheets[cnew.sheetId.toString()] =
        ns.state.sheets[cnew.sheetId.toString()] || {});
      const cold = csh.content && csh.content.activeSheet;

      // if its a an actual change of data then, go get it
      // but we'll do it in parallel to all of that.
      // promise will be resolved with null if there was no change in values
      if (!cold || cnew.digest !== cold.digest) {
        ns.getSheetValues (content.spreadsheet.id , cnew.sheetId, cnew.dataRange.a1);
      }
      else {
        // data hasnt changed
        csh.promiseSheetValues = Promise.resolve(null);
      }

      // move on and start viz while that's happening
      csh.content = content;

      // update counts
      csh.stats = csh.stats || {
        changes: []
      };

      // update the sheet selector
      const cv = DomUtils.elem ('sheet-select').value;
    
      DomUtils.changeOptions (
        'sheet-select' , 
         content.spreadsheet.sheets.map (function (d) { return {text:d.name , value:d.sheetId};}),
         typeof cv === undefined || cv === "" ? content.activeSheet.sheetId : cv );
         
      // make the grid big enough
      const dataRange = content.activeSheet.dataRange;
      adjustGrid(dataRange, csh.stats.changes);

      // next need to accumulate changes
      const activeRange = content.activeSheet.activeRange;
      accumulateChanges(content.changeType, activeRange, csh.stats.changes);

      // update the activity heat map
      csh.promiseSheetValues
        .then(function(result) {
          Render.updateHeat(DomUtils.elem ('sheet-select').value)
          .setActiverc ({
            or:activeRange.startRowIndex -1 ,
            oc:activeRange.startColumnIndex -1
          });
        })
        ["catch"](function(err) {
          App.showNotification("error getting " + pack.id, err);
        });
    });
    /**
    * make the grid big enough for the new data range
    */
    function adjustGrid(dims, grid) {
    
      const targetRows = Math.max (dims.numOfRows ,ns.state.minGridRows );
      const targetColumns = Math.max (dims.numOfColumns ,ns.state.minGridColumns );
      
      // extend no of rows
      while (targetRows > grid.length) grid.push([]);
      
      // extend no of columns
      grid.forEach(function(d) {
        while (targetColumns > d.length) d.push({});
      });

      // but its possible that the grid has shrunk, so we're going to discard previous changes
      if (grid.length > targetRows ) {
        grid = targetRows ? grid.slice (targetRows -1) : [];
      }
      
      return grid.map (function (row){
        return row.length > targetColumns ? row.slice (targetColumns -1) : row;
      })

      return grid;
    }

    /**
    * add observations
    */
    function accumulateChanges(changeType, activeRange, grid) {
      for (
        var i = activeRange.startRowIndex;
        i < activeRange.startRowIndex + activeRange.numOfRows;
        i++
      ) {
        for (
          var j = activeRange.startColumnIndex;
          j < activeRange.startColumnIndex + activeRange.numOfColumns;
          j++
        ) {
          const cell = grid[i - 1][j - 1];
          cell[changeType] = (cell[changeType] || 0) + 1;
          
        }
      }
      return grid;
    }
  };

  function resetCursor() {
    DomUtils.hide("spinner", true);
  }
  function spinCursor() {
    DomUtils.hide("spinner", false);
  }

  return ns;
})(Client || {});

For more like this, see Google Apps Scripts snippets.

Continue reading about Pushing Changes from Google Sheets to client here