First pass of working invitations

Added dump debug method
Adding per-trip sync groups (TODO: lazy create these)
Adding SyncGroup deletion wrapper (unused now)
Removing per-sycngroup sgmt instances (significant performance improvement)

Change-Id: Ie8bf588f7e08feff09c6577b2dc5d6bfe380ef96
diff --git a/src/invitation-manager.js b/src/invitation-manager.js
index e1a26a4..fc98c76 100644
--- a/src/invitation-manager.js
+++ b/src/invitation-manager.js
@@ -6,6 +6,7 @@
 
 var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
+var debug = require('./debug');
 
 // TODO(rosswang): generalize this
 var ESC = {
@@ -31,42 +32,24 @@
   });
 }
 
-function invitationKey(owner, recipient) {
-  return ['invitations', escapeUsername(owner), escapeUsername(recipient)];
+function invitationKey(recipient, owner, tripId) {
+  return [
+    'invitations',
+    escapeUsername(recipient),
+    escapeUsername(owner),
+    tripId
+  ];
 }
 
 var InvitationManager = defineClass({
   publics: {
-    accept: function(owner) {
-    },
-
-    decline: function(owner) {
-      return Promise.all([
-        this.syncbasePromise,
-        this.prereqs
-      ]).then(function(args) {
-        var syncbase = args[0];
-        var username = args[1].identity.username;
-
-        return syncbase.delete(invitationKey(owner, username));
-      });
-    },
-
-    getActiveInvite: function() {
-      return this.activeInvite;
-    },
-
-    invite: function(username) {
+    invite: function(recipient, owner, tripId) {
       var self = this;
 
       return this.groupManagerPromise.then(function(gm) {
-        return gm.joinSyncGroup(username, 'invitations').then(function() {
-          return self.prereqs;
-        }).then(function(prereqs) {
-          var owner = self.activeInvite || prereqs.identity.username;
-
-          return gm.syncbaseWrapper.put(invitationKey(owner, username),
-            prereqs.identity.username);
+        return gm.joinSyncGroup(recipient, 'invitations').then(function() {
+          return gm.syncbaseWrapper.put(invitationKey(recipient, owner, tripId),
+            self.username);
         });
       });
     },
@@ -77,55 +60,93 @@
   },
 
   privates: {
+    invitation: defineClass.innerClass({
+      publics: {
+        delete: function() {
+          var self = this;
+
+          var username = this.outer.username;
+          return this.outer.syncbasePromise.then(function(syncbase) {
+            return syncbase.delete(invitationKey(
+              username, self.owner, self.tripId));
+          });
+        }
+      },
+
+      constants: [ 'owner', 'tripId', 'sender' ],
+
+      events: {
+        onDismiss: 'memory once'
+      },
+
+      init: function(owner, tripId, sender, callbacks) {
+        this.owner = owner;
+        this.tripId = tripId;
+        this.sender = sender;
+        callbacks.dismiss = this.onDismiss;
+      }
+    }),
+
     processUpdates: function(data) {
       var self = this;
 
-      if (data.invitations) {
-        $.each(data.invitations, function(owner, record) {
+      var toMe;
+      if (data.invitations &&
+          (toMe = data.invitations[escapeUsername(this.username)])) {
+        $.each(toMe, function(owner, ownerRecords) {
           var ownerInvites = self.invitations[owner];
           if (!ownerInvites) {
             ownerInvites = self.invitations[owner] = {};
           }
 
-          $.each(record, function(recipient, sender) {
-            if (ownerInvites[recipient]) {
-              delete ownerInvites[recipient];
+          var uOwner;
+
+          $.each(ownerRecords, function(tripId, sender) {
+            var record = ownerInvites[tripId];
+            if (record) {
+              record.seen = true;
             } else {
-              self.onInvite(unescapeUsername(owner),
-                unescapeUsername(recipient), sender);
+              if (!uOwner) {
+                uOwner = unescapeUsername(owner);
+              }
+
+              debug.log('Received invite from ' + sender + ' to ' + uOwner +
+                ':' + tripId);
+
+              var callbacks = {};
+              var invite = self.invitation(uOwner, tripId, sender, callbacks);
+              ownerInvites[tripId] = {
+                invite: invite,
+                dismiss: callbacks.dismiss,
+                seen: true
+              };
+              self.onInvite(invite);
             }
           });
         });
       }
 
       if (this.invitations) {
-        $.each(this.invitations, function(owner, record) {
-          $.each(record, function(recipient, sender) {
-            self.onDismiss(unescapeUsername(owner),
-              unescapeUsername(recipient), sender);
+        $.each(this.invitations, function(owner, ownerRecords) {
+          $.each(ownerRecords, function(tripId, record) {
+            if (record.seen) {
+              delete record.seen;
+            } else {
+              delete ownerRecords[tripId];
+              record.dismiss();
+            }
           });
         });
       }
-
-      this.invitations = data.invitations || {};
     }
   },
 
   events: {
     /**
-     * @param owner the user who owns the trip.
-     * @param recipient the user invited to the trip.
-     * @param sender the user who sent the invitation.
+     * @param invitation
      */
     onInvite: '',
 
-    /**
-     * @param owner the user who owns the trip.
-     * @param recipient the user invited to the trip.
-     * @param sender the user who sent the invitation.
-     */
-    onDismiss: '',
-
     onError: 'memory'
   },
 
@@ -145,11 +166,13 @@
     this.invitations = {};
 
     prereqs.then(function(args) {
+      //this will have been set prior to groupManagerPromise completing
       self.username = args.identity.username;
     });
 
     groupManagerPromise.then(function(gm) {
-      gm.createSyncGroup('invitations', ['invitations'])
+      gm.createSyncGroup('invitations',
+          [['invitations', escapeUsername(self.username)]])
         .catch(self.onError);
     });
   }
diff --git a/src/static/index.css b/src/static/index.css
index 46adc30..b6d591e 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -144,6 +144,10 @@
   text-indent: -.5em;
 }
 
+.messages span {
+  text-indent: initial;
+}
+
 .messages.headlines li {
   background-color: rgba(0, 0, 0, .6);
   border-radius: 4px;
@@ -156,7 +160,6 @@
   border-radius: 2px;
   display: inline-block;
   padding: 1px 3px;
-  text-indent: initial;
 }
 
 .messages.headlines li.history {
diff --git a/src/strings.js b/src/strings.js
index 5ce352c..96f726d 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -83,6 +83,7 @@
     },
     'Timeline': 'Timeline',
     'Travel Planner': 'Travel Planner',
+    'Trip is still initializing.' : 'Trip is still initializing.',
     'Unknown error': 'Unknown error'
   };
 }
diff --git a/src/syncgroup-manager.js b/src/syncgroup-manager.js
index 97b8040..35ce0c6 100644
--- a/src/syncgroup-manager.js
+++ b/src/syncgroup-manager.js
@@ -17,7 +17,7 @@
       return this.prereq.then(function() {
         var sg = self.syncbaseWrapper.syncGroup(self.sgAdmin, name);
 
-        var mgmt = vanadium.naming.join(self.mountNames.app, 'sgmt', name);
+        var mgmt = vanadium.naming.join(self.mountNames.app, 'sgmt');
         var spec = sg.buildSpec(prefixes, [mgmt]);
 
         /* TODO(rosswang): Right now, duplicate Syncbase creates on
@@ -30,10 +30,16 @@
             ['Read', {in: ['...']}],
             ['Resolve', {in: ['...']}]
           ]));
+        }).then(function() {
+          return sg;
         });
       });
     },
 
+    destroySyncGroup: function(name) {
+      return this.syncbaseWrapper.syncGroup(this.sgAdmin, name).destroy();
+    },
+
     joinSyncGroup: function(owner, name) {
       var sg = this.syncbaseWrapper.syncGroup(
         vanadium.naming.join(naming.appMount(owner), 'sgadmin'), name);
@@ -89,4 +95,4 @@
   }
 });
 
-module.exports = SyncgroupManager;
\ No newline at end of file
+module.exports = SyncgroupManager;
diff --git a/src/travel.js b/src/travel.js
index 510c0c1..197d3e1 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -22,6 +22,7 @@
 
 var vanadiumWrapperDefault = require('./vanadium-wrapper');
 
+var debug = require('./debug');
 var describeDestination = require('./describe-destination');
 var naming = require('./naming');
 var strings = require('./strings').currentLocale;
@@ -77,6 +78,14 @@
 
 var Travel = defineClass({
   publics: {
+    dump: function() {
+      this.sync.getData().then(function(data) {
+        debug.log(data);
+      }, function(err) {
+        console.error(err);
+      });
+    },
+
     error: function (err) {
       this.messages.push(Message.error(err));
     },
@@ -87,6 +96,32 @@
         text: info,
         promise: promise
       }));
+    },
+
+    invite: function(recipient) {
+      var self = this;
+
+      var owner = this.sync.getActiveTripOwner();
+      if (owner) {
+        this.info(strings.sendingInvite(recipient),
+          this.sync.invitationManager.invite(recipient,
+              this.sync.getActiveTripOwner(), this.sync.getActiveTripId())
+            .then(function() {
+              var me = self.sync.invitationManager.getUsername();
+              self.sync.message({
+                type: Message.INFO,
+                text: strings.invitationSent(recipient, me)
+              });
+            }, function(err) {
+              if (err.id === 'v.io/v23/verror.NoServers') {
+                throw strings.notReachable(recipient);
+              } else {
+                throw err;
+              }
+            }));
+      } else {
+        this.error(strings['Trip is still initializing.']);
+      }
     }
   },
 
@@ -280,56 +315,39 @@
       }
     },
 
-    dismissInvite: function(owner, sender) {
-      var invite = this.invites[owner];
-      if (invite) {
-        invite.resolve(strings.invitationDismissed(sender, owner));
-        delete this.invites[owner];
-      }
-    },
-
-    handleInvite: function(owner, recipient, sender) {
+    handleInvite: function(invitation) {
       var self = this;
-      var invitationManager = this.sync.invitationManager;
-      var me = invitationManager.getUsername();
 
-      if (recipient === me) {
-        this.dismissInvite(owner, sender);
+      var sender = invitation.sender;
+      var owner = invitation.owner;
+      var tripId = invitation.tripId;
 
-        var message = new Message();
-        message.setType(Message.INFO);
-        message.setHtml(strings.invitationReceived(sender, owner));
-        message.setPromise(new Promise(function(resolve, reject) {
-          self.invites[owner] = {
-            resolve: resolve,
-            reject: reject
-          };
-
-          message.$.find('a[name=accept]').click(function() {
-            invitationManager.accept(owner).then(function() {
-              delete self.invites[owner];
+      var message = new Message();
+      message.setType(Message.INFO);
+      message.setHtml(strings.invitationReceived(sender, owner));
+      message.setPromise(new Promise(function(resolve, reject) {
+        message.$.find('a[name=accept]').click(function() {
+          self.sync.joinTripSyncGroup(owner, tripId)
+            .then(invitation.delete)
+            .then(function() {
+              self.sync.watchForTrip(tripId);
               return strings.invitationAccepted(sender, owner);
             }).then(resolve, reject);
-            return false;
-          });
-          message.$.find('a[name=decline]').click(function() {
-            invitationManager.decline(owner).then(function() {
-              delete self.invites[owner];
-              return strings.invitationDeclined(sender, owner);
-            }).then(resolve, reject);
-            return false;
-          });
-        }));
+          return false;
+        });
+        message.$.find('a[name=decline]').click(function() {
+          invitation.delete().then(function() {
+            return strings.invitationDeclined(sender, owner);
+          }).then(resolve, reject);
+          return false;
+        });
 
-        this.messages.push(message);
-      }
-    },
+        invitation.onDismiss.add(function() {
+          resolve(strings.invitationDismissed(sender, owner));
+        });
+      }));
 
-    handleInviteDismiss: function(owner, recipient, sender) {
-      var me = this.sync.invitationManager.getUsername();
-      if (recipient === me) {
-        this.dismissInvite(owner, sender);
-      }
+      this.messages.push(message);
     },
 
     handleUserMessage: function(message, raw) {
@@ -378,8 +396,6 @@
     opts = opts || {};
     var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
 
-    this.invites = {};
-
     var destinations = this.destinations = new Destinations();
     destinations.onAdd.add(this.handleDestinationAdd);
     destinations.onRemove.add(this.handleDestinationRemove);
@@ -439,7 +455,6 @@
     });
 
     sync.invitationManager.onInvite.add(this.handleInvite);
-    sync.invitationManager.onDismiss.add(this.handleInviteDismiss);
 
     messages.onMessage.add(this.handleUserMessage);
 
@@ -524,23 +539,7 @@
 
     this.commands = {
       invite: {
-        op: function(username) {
-          this.info(strings.sendingInvite(username),
-            this.sync.invitationManager.invite(username)
-              .then(function() {
-                var me = self.sync.invitationManager.getUsername();
-                self.sync.message({
-                  type: Message.INFO,
-                  text: strings.invitationSent(username, me)
-                });
-              }, function(err) {
-                if (err.id === 'v.io/v23/verror.NoServers') {
-                  throw strings.notReachable(username);
-                } else {
-                  throw err;
-                }
-              }));
-        }
+        op: this.invite
       },
 
       status: {
diff --git a/src/travelsync.js b/src/travelsync.js
index 3fedd67..4c87344 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -61,7 +61,15 @@
     pushStatus: function() {
     },
 
-    setActiveTrip: function(tripId) {
+    getActiveTripId: function() {
+      return this.activeTripId;
+    },
+
+    getActiveTripOwner: function() {
+      return this.activeTripOwner;
+    },
+
+    setActiveTripId: function(tripId, pull) {
       var self = this;
 
       this.activeTripId = tripId;
@@ -71,7 +79,26 @@
         syncbase.put(['user', 'tripMetadata', tripId, 'latestSwitch'],
           Date.now()).catch(self.onError);
 
-        return syncbase.refresh();
+        return pull? syncbase.refresh() : Promise.resolve();
+      });
+    },
+
+    getData: function() {
+      return this.startSyncbase.then(function(syncbase) {
+        return syncbase.getData();
+      });
+    },
+
+    /**
+     * Sets the active trip to the given trip ID after it is available.
+     */
+    watchForTrip: function(tripId) {
+      this.awaitedTripId = tripId;
+    },
+
+    joinTripSyncGroup: function(owner, tripId) {
+      return this.startSyncgroupManager.then(function(gm) {
+        return gm.joinSyncGroup(owner, 'trip-' + tripId);
       });
     }
   },
@@ -484,24 +511,68 @@
         this.getDestinationIds(trip.destinations).length <= 1;
     },
 
+    manageTripSyncGroups: function(trips) {
+      var self = this;
+
+      //TODO(rosswang): maybe make this more intelligent, and handle ejection
+      if (trips) {
+        $.each(trips, function(tripId, trip) {
+          /* Join is idempotent, but repeatedly joining might be causing major,
+           * fatal sluggishness. TODO(rosswang): if this is not the case, maybe
+           * go ahead and poll. */
+          if (!self.joinedTrips.has(tripId) && trip.owner) {
+            self.joinedTrips.add(tripId);
+            self.joinTripSyncGroup(trip.owner, tripId).catch(self.onError);
+          }
+        });
+      }
+    },
+
     processTrips: function(userTripMetadata, trips) {
+      var self = this;
+
+      this.manageTripSyncGroups(trips);
+
       var trip;
 
+      if (this.awaitedTripId) {
+        this.setActiveTripId(this.awaitedTripId, false);
+        delete this.awaitedTripId;
+
+        /* Override latestSwitch this frame. (Subsequently syncbase will be up
+         * to date.) */
+        if (!userTripMetadata) {
+          userTripMetadata = {};
+        }
+        var activeTripMd = userTripMetadata[this.activeTripId];
+        if (!activeTripMd) {
+          activeTripMd = userTripMetadata[this.activeTripId] = {};
+        }
+        activeTripMd.latestSwitch = Date.now();
+      }
+
       if (this.activeTripId) {
         trip = trips && trips[this.activeTripId];
         if (!trip) {
           debug.log('Last active trip ' + this.activeTripId +
             ' is no longer present.');
-        } else if (this.isNascent(trip)) {
-          var establishedId = this.getDefaultTrip(userTripMetadata, trips);
-          if (establishedId && establishedId !== this.activeTripId &&
-              trips[establishedId]) {
-            this.deleteTrip(this.activeTripId);
+        } else {
+          var defaultId = this.getDefaultTrip(userTripMetadata, trips);
+          if (defaultId && defaultId !== this.activeTripId &&
+              trips[defaultId]) {
+            if (this.isNascent(trip)) {
+              this.deleteTrip(this.activeTripId);
+              debug.log('Replacing nascent trip ' + this.activeTripId +
+                ' with established trip ' + defaultId);
+            } else {
+              /* TODO(rosswang): for now, sync trip changes. This behavior may
+               * change. */
+              debug.log('Replacing active trip ' + this.activeTripId +
+                ' with most recent selection ' + defaultId);
+            }
 
-            debug.log('Replacing nascent trip ' + this.activeTripId +
-              ' with established trip ' + establishedId);
-            this.activeTripId = establishedId;
-            trip = trips[establishedId];
+            this.activeTripId = defaultId;
+            trip = trips[defaultId];
           }
         }
       }
@@ -512,12 +583,23 @@
           debug.log('Setting active trip ' + this.activeTripId);
           trip = trips[this.activeTripId];
         } else {
-          this.activeTripId = uuid.v4();
-          debug.log('Creating new trip ' + this.activeTripId);
-          trip = {};
+          var tripId = this.activeTripId = uuid.v4();
+          debug.log('Creating new trip ' + tripId);
+          trip = {}; //don't initialize owner until the syncgroup is ready
+          this.startSyncgroupManager.then(function(gm) {
+            return self.createTripSyncGroup(gm, tripId)
+              .then(function(sg) {
+                return gm.syncbaseWrapper.put(['trips', tripId, 'owner'],
+                  self.invitationManager.getUsername()).then(function() {
+                    return sg;
+                  });
+              })
+              .catch(self.onError);
+          });
         }
       }
 
+      this.activeTripOwner = trip.owner;
       this.processMessages(trip.messages);
       this.processDestinations(trip.destinations);
     },
@@ -585,15 +667,20 @@
     createPrimarySyncGroup: function(groupManager) {
       var self = this;
 
-      this.status.tripSyncGroup = 'creating';
-      return groupManager.createSyncGroup('trip', [''])
+      this.status.userSyncGroup = 'creating';
+      return groupManager.createSyncGroup('user', [[]])
         .then(function(sg) {
-          self.status.tripSyncGroup = 'created';
+          self.status.userSyncGroup = 'created';
           return sg;
         }, function(err) {
-          self.status.tripSyncGroup = 'failed';
+          self.status.usersSyncGroup = 'failed';
           throw err;
         });
+    },
+
+    createTripSyncGroup: function(groupManager, tripId) {
+      return groupManager.createSyncGroup('trip-' + tripId,
+        [['trips', tripId]]);
     }
   },
 
@@ -636,22 +723,23 @@
     this.messages = {};
     this.destRecords = [];
     this.status = {};
+    this.joinedTrips = new Set();
 
     this.server = new vdlTravel.TravelSync();
     var startRpc = prereqs.then(this.serve);
     var startSyncbase = this.startSyncbase = prereqs.then(this.connectSyncbase);
-    var startSyncgroupManager = Promise
+    this.startSyncgroupManager = Promise
       .all([prereqs, startSyncbase])
       .then(function(args) {
         return self.createSyncgroupManager(args[0], args[1]);
       });
-    var createPrimarySyncGroup = startSyncgroupManager
+    var createPrimarySyncGroup = this.startSyncgroupManager
       .then(this.createPrimarySyncGroup);
 
     this.startup = Promise.all([
         startRpc,
         startSyncbase,
-        startSyncgroupManager,
+        this.startSyncgroupManager,
         createPrimarySyncGroup
       ]).then(function(values) {
         return {
@@ -662,7 +750,7 @@
       });
 
     this.invitationManager = new InvitationManager(prereqs,
-      startSyncgroupManager);
+      this.startSyncgroupManager);
     this.invitationManager.onError.add(this.onError);
 
     this.handleDestinationPlaceChange = function() {
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 92ba622..9b7664e 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -141,6 +141,10 @@
       return this.manageWrite(this.standardDelete(this.deleteFromSyncbase, k));
     },
 
+    getData: function() {
+      return this.data;
+    },
+
     /**
      * Since I/O is asynchronous, sparse, and fast, let's avoid concurrency/
      * merging with the local syncbase instance by only starting a refresh if
@@ -163,6 +167,7 @@
       if (!current) {
         current = this.pull.current = this.pull().then(function(data) {
             self.pull.current = null;
+            self.data = data;
             self.onUpdate(data);
             return data;
           }, function(err) {
@@ -194,6 +199,11 @@
         sg.create(self.context, spec, SG_MEMBER_INFO, chainable(cb));
       });
 
+      var destroy = promisify(function(cb) {
+        debug.log('Syncbase: destroy syncgroup ' + name);
+        sg.destroy(self.context, cb);
+      });
+
       var join = promisify(function(cb) {
         debug.log('Syncbase: join syncgroup ' + name);
         sg.join(self.context, SG_MEMBER_INFO, chainable(cb));
@@ -203,7 +213,10 @@
           sg.setSpec(self.context, spec, '', chainable(cb));
       });
 
-      //be explicit about arg lists because promisify is sensitive to extra args
+      /* Be explicit about arg lists because promisify is sensitive to extra
+       * args. i.e. even though destroy and join could just be fn refs, since
+       * they're made by promisify, wrap them in a fn that actually takes 0
+       * args. */
       sgp = {
         buildSpec: function(prefixes, mountTables) {
           return new syncbase.nosql.SyncGroupSpec({
@@ -214,12 +227,13 @@
               ['Resolve', {in: ['...']}],
               ['Debug', {in: ['...']}]
             ]),
-            prefixes: prefixes.map(function(p) { return 't:' + p; }),
+            prefixes: prefixes.map(function(p) { return 't:' + joinKey(p); }),
             mountTables: mountTables
           });
         },
 
         create: function(spec) { return create(spec); },
+        destroy: function() { return destroy(); },
         join: function() { return join(); },
         setSpec: function(spec) { return setSpec(spec); },