todosapp: wire up real syncgroup calls

i haven't tested sync yet, but everything works for a single user

yeeeehhhhaaaa

Change-Id: Ib0aff7f53616a749535824ee35d8ca051ff825fa
diff --git a/browser/defaults.js b/browser/defaults.js
index d41e637..2dceddb 100644
--- a/browser/defaults.js
+++ b/browser/defaults.js
@@ -84,8 +84,6 @@
   var service = syncbase.newService(name);
   var app = service.app('todos'), db = app.noSqlDatabase('db');
   var disp = new SyncbaseDispatcher(rt, db);
-  // TODO(sadovsky): Check that the VC (and discharge, etc.) for this RPC is
-  // reused for all subsequent RPCs.
   app.create(wt(ctx), {}, function(err) {
     if (err) {
       if (err instanceof verror.ExistError) {
@@ -103,6 +101,10 @@
       db.createTable(wt(ctx), 'tb', {}, function(err) {
         if (err) return cb(err);
         if (benchmark) {
+          // TODO(sadovsky): Restructure things so that we still call initData
+          // even if on the first page load we ran the benchmark. Maybe do this
+          // by having the benchmark use a completely different app and/or
+          // database.
           return bm.runBenchmark(ctx, db, cb);
         }
         console.log('hierarchy created; writing rows');
diff --git a/browser/index.js b/browser/index.js
index 09b69ad..dd5d88d 100644
--- a/browser/index.js
+++ b/browser/index.js
@@ -25,8 +25,8 @@
 ////////////////////////////////////////
 // Global state
 
-// Dispatcher, initialized by initDispatcher.
-var disp;
+// Dispatcher and user's email address, both initialized by initDispatcher.
+var disp, userEmail;
 
 // Used for query params.
 var u = url.parse(window.location.href, true);
@@ -59,6 +59,7 @@
   } else if (dispType === DISP_TYPE_SYNCBASE) {
     initVanadium(function(err, rt) {
       if (err) return cb(err);
+      userEmail = blessingToEmail(rt.accountName);
       defaults.initSyncbaseDispatcher(rt, syncbaseName, benchmark, cb);
     });
   } else {
@@ -68,6 +69,16 @@
   }
 }
 
+// HACKETY HACK
+function emailToBlessing(email) {
+  return 'dev.v.io/u/' + email;
+}
+function blessingToEmail(blessing) {
+  var parts = blessing.split('/');
+  console.assert(parts.length >= 3);
+  return parts[2];
+}
+
 function activateInput(input) {
   input.focus();
   input.select();
@@ -265,6 +276,8 @@
   render: function() {
     var that = this;
     var children = [];
+    // TODO(sadovsky): If there are no lists (and thus no todos), we end up
+    // rendering "Loading..." here, which is wrong.
     if (!this.props.listId || !this.props.todos) {
       children.push(h('div.loading', {key: 'loading'}, 'Loading...'));
     } else {
@@ -382,30 +395,41 @@
       h('input', _.assign({
         key: 'input',
         placeholder: 'Add email address',
-        // TODO(sadovsky): Let the user add members to an existing SG once
-        // Syncbase supports it.
-        disabled: shared
       }, okCancelEvents({
         ok: function(value, e) {
-          disp.createSyncGroup(list._id);
           e.target.value = '';
+          if (shared) {
+            // TODO(sadovsky): Let the user add members to an existing SG once
+            // Syncbase supports it.
+            console.error('Cannot add members to existing SyncGroup.');
+            return;
+          }
+          // TODO(sadovsky): Better input validation.
+          if (!value.includes('@') || !value.includes('.')) {
+            console.error('Invalid email address.');
+            return;
+          }
+          disp.createSyncGroup(list._id, [
+            emailToBlessing(userEmail),
+            emailToBlessing(value)
+          ]);
         },
         cancel: function(e) {
           e.target.value = '';
         }
       }, true))),
-      // TODO(sadovsky): Exclude self from displayed member list?
+      // TODO(sadovsky): Exclude self?
       !shared ? null : h('div.shared-with', {key: 'shared-with'}, [
         h('div.subtitle', {key: 'subtitle'}, 'Currently shared with'),
         h('div.emails', {
           key: 'emails'
-        }, _.map(list.sg.members, function(member) {
-          return h('div.email', {key: member}, member);
-        }))
+        }, _.map(list.sg.spec.perms.get('Admin')['in'], function(blessing) {
+          return blessingToEmail(blessing);
+        }).join(', '))
       ]),
       !shared ? null : h('div.url', {key: 'url'}, [
-        h('div.subtitle', {key: 'subtitle'}, 'Url to share with invitees'),
-        h('div.value', {key: 'value'}, shareUrl)
+        h('div.subtitle', {key: 'subtitle'}, 'URL to share with invitees'),
+        h('div.value', {key: 'value'}, h('a', {href: shareUrl}, shareUrl))
       ]),
       h('div.close', {
         key: 'close',
@@ -478,12 +502,8 @@
       showStatusDialog: false
     };
   },
-  getSyncGroup_: function(listId, cb) {
-    console.assert(this.props.dispType === DISP_TYPE_SYNCBASE);
-    disp.getSyncGroup(listId, cb);
-  },
   getLists_: function(cb) {
-    disp.getListsWithSyncGroups(function(err, lists) {
+    disp.getLists(function(err, lists) {
       if (err) return cb(err);
       // Sort lists by name in the UI.
       return cb(null, _.sortBy(lists, 'name'));
@@ -517,9 +537,15 @@
     }
     initDispatcher(dt, sn, bm, function(err) {
       if (err) throw err;
+      if (bm) {
+        console.log('benchmark done');
+        return;
+      }
       if (props.joinListId) {
         console.assert(dt === DISP_TYPE_SYNCBASE);
         disp.joinSyncGroup(props.joinListId, function(err) {
+          // Note, joinSyncGroup is a noop (no error) if the caller is already a
+          // member, which is the desired behavior here.
           if (err) throw err;
           done();
         });
@@ -597,12 +623,13 @@
     // TODO(sadovsky): Only read (and only redraw) what's needed based on what
     // changed.
     disp.on('change', function() {
+      var doneCb = bm.logFn('onChange', function(err) {
+        if (err) throw err;
+      });
       updateLists(function(err) {
         if (err) throw err;
         var listId = getListId();
-        updateTodos(listId, function(err) {
-          if (err) throw err;
-        });
+        updateTodos(listId, doneCb);
       });
     });
 
diff --git a/browser/syncbase_dispatcher.js b/browser/syncbase_dispatcher.js
index bc51adb..89ac9b3 100644
--- a/browser/syncbase_dispatcher.js
+++ b/browser/syncbase_dispatcher.js
@@ -3,9 +3,8 @@
 // Schema design doc (a bit outdated):
 // https://docs.google.com/document/d/1GtBk75QmjSorUW6T6BATCoiS_LTqOrGksgqjqJ1Hiow/edit#
 //
-// TODO(sadovsky): Currently, list and todo order are not preserved. We should
-// make the app always order these lexicographically, or better yet, use a
-// Dewey-Decimal-like scheme (with randomization).
+// TODO(sadovsky): Support arbitrary item reordering using a Dewey-Decimal-like
+// order-tracking scheme, with suffix randomization to prevent conflicts.
 //
 // NOTE: For now, when an item is deleted, any sub-items that were added
 // concurrently (on some other device) are orphaned. Eventually, we'll GC
@@ -14,10 +13,7 @@
 // stored in Syncbase.
 //
 // TODO(sadovsky): Orphaning degrades performance, because scan responses (e.g.
-// scan to get all lists) include orphaned records. If we switch from scans to
-// queries, performance should improve since all row filtering will happen
-// server side.
-// TODO(sadovsky): Better yet, move lists to a separate keyspace.
+// scan to get all todos for a given list) include orphaned records.
 
 'use strict';
 
@@ -60,7 +56,7 @@
 
 function join() {
   // TODO(sadovsky): Switch to using naming.join() once Syncbase allows slashes
-  // in row keys. Also, restrict which chars are allowed in tags.
+  // in row keys. Also, restrict which chars are allowed in tag names.
   var args = Array.prototype.slice.call(arguments);
   return args.join(SEP);
 }
@@ -91,7 +87,7 @@
   throw new Error('bad key: ' + key);
 }
 
-// TODO(sadovsky): Switch from JSON to VOM.
+// TODO(sadovsky): Maybe switch from JSON to VOM/VDL.
 function marshal(x) {
   return JSON.stringify(x);
 }
@@ -144,9 +140,10 @@
 ////////////////////////////////////////
 // SyncbaseDispatcher impl
 
-// TODO(sadovsky): Use a query for better performance. In theory it's possible
-// to query based on row key regexp.
-define('getLists', function(ctx, cb) {
+// Returns list objects without 'sg' fields.
+// TODO(sadovsky): Use a query for better performance. It should be possible to
+// query based on row key regexp.
+define('getListsOnly_', function(ctx, cb) {
   this.getRows_(ctx, null, function(err, rows) {
     if (err) return cb(err);
     var lists = [];
@@ -160,29 +157,27 @@
   });
 });
 
-define('getListsWithSyncGroups', function(ctx, cb) {
+// Returns a list of objects describing SyncGroups.
+// TODO(sadovsky): Would be nice if this could be done with a single RPC.
+define('getSyncGroups_', function(ctx, cb) {
+  var that = this;
+  this.db_.getSyncGroupNames(ctx, function(err, sgNames) {
+    if (err) return cb(err);
+    async.map(sgNames, function(sgName, cb) {
+      that.getSyncGroup_(ctx, that.sgNameToListId_(sgName), cb);
+    }, cb);
+  });
+});
+
+// Returns list objects with 'sg' fields.
+define('getLists', function(ctx, cb) {
   var that = this;
   async.parallel([
     function(cb) {
-      that.getLists(ctx, cb);
+      that.getListsOnly_(ctx, cb);
     },
-    // Returns a list of SG objects (from getSyncGroup), one per SG.
-    // TODO(sadovsky): Would be nice if Syncbase could provide more info in a
-    // single RPC.
     function(cb) {
-      bm.logFn('getSyncGroupNames', cb);
-      // FIXME: Remove this hack once db.getSyncGroupNames, sg.getSpec, and
-      // sg.getMembers are implemented.
-      /* jshint -W027 */
-      return process.nextTick(function() {
-        cb(null, []);
-      });
-      that.db_.getSyncGroupNames(ctx, function(err, sgNames) {
-        if (err) return cb(err);
-        async.map(sgNames, function(sgName, cb) {
-          that.getSyncGroup(ctx, that.sgNameToListId_(sgName), cb);
-        }, cb);
-      });
+      that.getSyncGroups_(ctx, cb);
     }
   ], function(err, results) {
     if (err) return cb(err);
@@ -191,8 +186,8 @@
     _.forEach(lists, function(list) {
       var listId = list._id;
       _.forEach(sgs, function(sg) {
-        console.assert(sg.spec.Prefixes.length === 1);
-        if (listId === sg.spec.Prefixes[0]) {
+        console.assert(sg.spec.prefixes.length === 1);
+        if (listId === sg.spec.prefixes[0].slice(3)) {  // drop 'tb:' prefix
           list.sg = sg;
         }
       });
@@ -306,32 +301,43 @@
 // We use <app>/<db>/<table>/<listId> for the suffix part.
 
 SyncbaseDispatcher.prototype.sgNameToListId_ = function(sgName) {
-  return sgName.replace(new RegExp('.*/$sync/todos/db/tb/'), '');
+  return sgName.replace(new RegExp('.*/\\$sync/todos/db/tb/'), '');
 };
 
 SyncbaseDispatcher.prototype.listIdToSgName_ = function(listId) {
-  var prefix = this.tb_.fullName.replace('/todos/db/tb/',
-                                         '/$sync/todos/db/tb/');
-  return prefix + listId;
+  var prefix = this.tb_.fullName.replace('/todos/db/tb',
+                                         '/$sync/todos/db/tb');
+  return prefix + '/' + listId;
 };
 
-// Returns spec and members for the given list.
-define('getSyncGroup', function(ctx, listId, cb) {
-  var sg = this.db_.syncGroup(this.listIdToSgName_(listId));
+// Returns an object describing the SyncGroup for the given list id.
+// Currently, this object will have just one field, 'spec'.
+define('getSyncGroup_', function(ctx, listId, cb) {
+  var sgName = this.listIdToSgName_(listId), sg = this.db_.syncGroup(sgName);
   async.parallel([
     function(cb) {
-      sg.getSpec(ctx, cb);
+      sg.getSpec(ctx, function(err, spec, version) {
+        if (err) return cb(err);
+        cb(null, spec);
+      });
     },
     function(cb) {
-      sg.getMembers(ctx, cb);
+      // TODO(sadovsky): For now, in the UI we just want to show who's on the
+      // ACL for a given list, so we don't bother with getMembers. On top of
+      // that, currently getMembers returns a map of random Syncbase instance
+      // ids to SyncGroupMemberInfo structs, neither of which tell us anything
+      // useful.
+      if (true) {
+        process.nextTick(cb);
+      } else {
+        sg.getMembers(ctx, cb);
+      }
     }
   ], function(err, results) {
     if (err) return cb(err);
-    // FIXME: Convert 'members' to email addresses.
-    return {
+    cb(null, {
       spec: results[0],
-      members: _.keys(results[1])
-    };
+    });
   });
 });
 
@@ -341,16 +347,16 @@
   syncPriority: 8
 });
 
-define('createSyncGroup', function(ctx, listId, cb) {
-  var sg = this.db_.syncGroup(this.listIdToSgName_(listId));
+define('createSyncGroup', function(ctx, listId, blessings, cb) {
+  var sgName = this.listIdToSgName_(listId), sg = this.db_.syncGroup(sgName);
   var spec = new nosql.SyncGroupSpec({
-    // TODO(sadovsky): Make perms more restrictive.
+    // TODO(sadovsky): Maybe make perms more restrictive.
     perms: new Map([
-      ['Admin',   {'In': ['...']}],
-      ['Read',    {'In': ['...']}],
-      ['Write',   {'In': ['...']}],
-      ['Resolve', {'In': ['...']}],
-      ['Debug',   {'In': ['...']}]
+      ['Admin',   {'in': blessings}],
+      ['Read',    {'in': blessings}],
+      ['Write',   {'in': blessings}],
+      ['Resolve', {'in': blessings}],
+      ['Debug',   {'in': blessings}]
     ]),
     // TODO(sadovsky): Update this once we switch to {table, prefix} tuples.
     prefixes: ['tb:' + listId]
@@ -359,7 +365,7 @@
 });
 
 define('joinSyncGroup', function(ctx, listId, cb) {
-  var sg = this.db_.syncGroup(this.listIdToSgName_(listId));
+  var sgName = this.listIdToSgName_(listId), sg = this.db_.syncGroup(sgName);
   sg.join(ctx, MEMBER_INFO, this.maybeEmit_(cb));
 });
 
@@ -369,6 +375,8 @@
 // DO NOT USE THIS. vtrace RPCs are extremely slow in JavaScript because VOM
 // decoding is slow for trace records, which are deeply nested. E.g. 100 puts
 // can take 20+ seconds with vtrace vs. 2 seconds without.
+// EDIT: It might not be quite that bad - the "20+ seconds" cited above might
+// also include the latency added by having the Chrome dev console open.
 SyncbaseDispatcher.prototype.resetTraceRecords = function() {
   this.ctx_ = vtrace.withNewStore(this.rt_.getContext());
   vtrace.getStore(this.ctx_).setCollectRegexp('.*');
@@ -397,7 +405,7 @@
 }
 
 var seq = 0;  // for our own writes
-var prevWatchVersions = {};  // map of list id to version string
+var prevVersions = null;  // map of list id to version string
 
 // Increments stored seq for this client.
 define('bumpSeq_', function(ctx, listId, cb) {
@@ -422,10 +430,10 @@
 // Returns true if any data has arrived via sync, false if not.
 define('checkForChanges_', function(ctx, cb) {
   var that = this;
-  this.getLists(ctx, function(err, lists) {
+  this.getListsOnly_(ctx, function(err, lists) {
     if (err) return cb(err);
     // Build a map of list id to current version.
-    var currWatchVersions = {};
+    var currVersions = {};
     var listIds = _.pluck(lists, '_id');
     async.each(listIds, function(listId, cb) {
       that.getWatchSeqMap_(ctx, listId, function(err, seqMap) {
@@ -435,14 +443,14 @@
         var strs = _.map(seqMap, function(v, k) {
           return k + ':' + v;
         });
-        currWatchVersions[listId] = strs.sort().join(',');
+        currVersions[listId] = strs.sort().join(',');
         cb();
       });
     }, function(err) {
       if (err) return cb(err);
       // Note that _.isEqual performs a deep comparison.
-      var changed = !_.isEqual(currWatchVersions, prevWatchVersions);
-      prevWatchVersions = currWatchVersions;
+      var changed = prevVersions && !_.isEqual(currVersions, prevVersions);
+      prevVersions = currVersions;
       cb(null, changed);
     });
   });
@@ -467,8 +475,7 @@
 ////////////////////////////////////////
 // Internal helpers
 
-// TODO(sadovsky): Watch for changes on Syncbase itself so that we can detect
-// when data arrives via sync, and drop this method.
+// TODO(sadovsky): Drop this method once we have client watch.
 SyncbaseDispatcher.prototype.maybeEmit_ = function(cb, key) {
   var that = this;
   cb = cb || noop;
diff --git a/stylesheets/constants.less b/stylesheets/constants.less
index 8e996ff..8f36820 100644
--- a/stylesheets/constants.less
+++ b/stylesheets/constants.less
@@ -1,3 +1,4 @@
 /* https://www.google.com/design/spec/style/color.html */
 @color-green-700: #388e3c;
+@color-red-500: #f44336;
 @color-red-700: #d32f2f;
diff --git a/stylesheets/index.less b/stylesheets/index.less
index 0d9f70f..468e796 100644
--- a/stylesheets/index.less
+++ b/stylesheets/index.less
@@ -46,6 +46,8 @@
 
 input {
   font: inherit;
+  margin: 0;
+  padding: 0;
 
   &:focus {
     outline: none;
@@ -156,7 +158,7 @@
         background-color: #888;
 
         &.shared {
-          background-color: @color-green-700;
+          background-color: @color-red-500;
         }
       }
     }
@@ -195,24 +197,36 @@
     background-color: #fff;
     border: 1px solid rgba(0, 0, 0, 0.3);
     box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+  }
 
-    input {
-      width: 50%;
-    }
+  input {
+    width: 70%;
+  }
 
-    .close {
-      position: absolute;
-      top: 0;
-      right: 0;
-      margin: 8px;
-      background: url("/public/destroy.png") no-repeat 0 0;
-      cursor: pointer;
-      height: 20px;
-      width: 20px;
+  .shared-with, .url {
+    margin-top: 24px;
+  }
 
-      &:hover {
-        background-position: 0 -20px;
-      }
+  .subtitle {
+    margin-bottom: 4px;
+    color: #999;
+    font-size: 13px;
+    font-weight: bold;
+    text-transform: uppercase;
+  }
+
+  .close {
+    position: absolute;
+    top: 0;
+    right: 0;
+    margin: 8px;
+    background: url("/public/destroy.png") no-repeat 0 0;
+    cursor: pointer;
+    height: 20px;
+    width: 20px;
+
+    &:hover {
+      background-position: 0 -20px;
     }
   }
 }