Kaynağa Gözat

prettier: convert app/js decaffeinated files to Prettier format

Tim Alby 6 yıl önce
ebeveyn
işleme
dbf9e88dc3
59 değiştirilmiş dosya ile 10098 ekleme ve 6839 silme
  1. 110 75
      services/document-updater/app/js/DeleteQueueManager.js
  2. 38 38
      services/document-updater/app/js/DiffCodec.js
  3. 100 70
      services/document-updater/app/js/DispatchManager.js
  4. 759 333
      services/document-updater/app/js/DocumentManager.js
  5. 35 35
      services/document-updater/app/js/Errors.js
  6. 237 124
      services/document-updater/app/js/HistoryManager.js
  7. 29 19
      services/document-updater/app/js/HistoryRedisManager.js
  8. 443 304
      services/document-updater/app/js/HttpController.js
  9. 145 114
      services/document-updater/app/js/LockManager.js
  10. 49 28
      services/document-updater/app/js/LoggerSerializers.js
  11. 1 1
      services/document-updater/app/js/Metrics.js
  12. 163 117
      services/document-updater/app/js/PersistenceManager.js
  13. 47 46
      services/document-updater/app/js/Profiler.js
  14. 110 88
      services/document-updater/app/js/ProjectFlusher.js
  15. 150 91
      services/document-updater/app/js/ProjectHistoryRedisManager.js
  16. 376 215
      services/document-updater/app/js/ProjectManager.js
  17. 143 98
      services/document-updater/app/js/RangesManager.js
  18. 822 702
      services/document-updater/app/js/RangesTracker.js
  19. 64 47
      services/document-updater/app/js/RateLimitManager.js
  20. 67 59
      services/document-updater/app/js/RealTimeRedisManager.js
  21. 738 466
      services/document-updater/app/js/RedisManager.js
  22. 67 52
      services/document-updater/app/js/ShareJsDB.js
  23. 110 88
      services/document-updater/app/js/ShareJsUpdateManager.js
  24. 50 31
      services/document-updater/app/js/SnapshotManager.js
  25. 7 3
      services/document-updater/app/js/UpdateKeys.js
  26. 386 213
      services/document-updater/app/js/UpdateManager.js
  27. 17 13
      services/document-updater/app/js/mongojs.js
  28. 22 17
      services/document-updater/app/js/sharejs/count.js
  29. 65 46
      services/document-updater/app/js/sharejs/helpers.js
  30. 13 11
      services/document-updater/app/js/sharejs/index.js
  31. 257 180
      services/document-updater/app/js/sharejs/json-api.js
  32. 347 258
      services/document-updater/app/js/sharejs/json.js
  33. 504 325
      services/document-updater/app/js/sharejs/model.js
  34. 510 328
      services/document-updater/app/js/sharejs/server/model.js
  35. 28 22
      services/document-updater/app/js/sharejs/server/syncqueue.js
  36. 15 11
      services/document-updater/app/js/sharejs/simple.js
  37. 28 22
      services/document-updater/app/js/sharejs/syncqueue.js
  38. 27 21
      services/document-updater/app/js/sharejs/text-api.js
  39. 37 30
      services/document-updater/app/js/sharejs/text-composable-api.js
  40. 255 179
      services/document-updater/app/js/sharejs/text-composable.js
  41. 71 61
      services/document-updater/app/js/sharejs/text-tp2-api.js
  42. 307 216
      services/document-updater/app/js/sharejs/text-tp2.js
  43. 171 109
      services/document-updater/app/js/sharejs/text.js
  44. 22 17
      services/document-updater/app/js/sharejs/types/count.js
  45. 65 46
      services/document-updater/app/js/sharejs/types/helpers.js
  46. 13 11
      services/document-updater/app/js/sharejs/types/index.js
  47. 257 180
      services/document-updater/app/js/sharejs/types/json-api.js
  48. 347 258
      services/document-updater/app/js/sharejs/types/json.js
  49. 504 325
      services/document-updater/app/js/sharejs/types/model.js
  50. 15 11
      services/document-updater/app/js/sharejs/types/simple.js
  51. 28 22
      services/document-updater/app/js/sharejs/types/syncqueue.js
  52. 27 21
      services/document-updater/app/js/sharejs/types/text-api.js
  53. 37 30
      services/document-updater/app/js/sharejs/types/text-composable-api.js
  54. 255 179
      services/document-updater/app/js/sharejs/types/text-composable.js
  55. 71 61
      services/document-updater/app/js/sharejs/types/text-tp2-api.js
  56. 307 216
      services/document-updater/app/js/sharejs/types/text-tp2.js
  57. 226 148
      services/document-updater/app/js/sharejs/types/text.js
  58. 2 4
      services/document-updater/app/js/sharejs/types/web-prelude.js
  59. 2 4
      services/document-updater/app/js/sharejs/web-prelude.js

+ 110 - 75
services/document-updater/app/js/DeleteQueueManager.js

@@ -12,22 +12,22 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let DeleteQueueManager;
-const Settings = require('settings-sharelatex');
-const RedisManager = require("./RedisManager");
-const ProjectManager = require("./ProjectManager");
-const logger = require("logger-sharelatex");
-const metrics = require("./Metrics");
-const async = require("async");
+let DeleteQueueManager
+const Settings = require('settings-sharelatex')
+const RedisManager = require('./RedisManager')
+const ProjectManager = require('./ProjectManager')
+const logger = require('logger-sharelatex')
+const metrics = require('./Metrics')
+const async = require('async')
 
 // Maintain a sorted set of project flushAndDelete requests, ordered by timestamp
 // (ZADD), and process them from oldest to newest. A flushAndDelete request comes
-// from real-time and is triggered when a user leaves a project. 
+// from real-time and is triggered when a user leaves a project.
 //
 // The aim is to remove the project from redis 5 minutes after the last request
 // if there has been no activity (document updates) in that time.  If there is
 // activity we can expect a further flushAndDelete request when the editing user
-// leaves the project. 
+// leaves the project.
 //
 // If a new flushAndDelete request comes in while an existing request is already
 // in the queue we update the timestamp as we can postpone flushing further.
@@ -35,75 +35,110 @@ const async = require("async");
 // Documents are processed by checking the queue, seeing if the first entry is
 // older than 5 minutes, and popping it from the queue in that case.
 
-module.exports = (DeleteQueueManager = {
-    flushAndDeleteOldProjects(options, callback) {
-        const startTime = Date.now();
-        const cutoffTime = (startTime - options.min_delete_age) + (100 * (Math.random() - 0.5));
-        let count = 0;
+module.exports = DeleteQueueManager = {
+  flushAndDeleteOldProjects(options, callback) {
+    const startTime = Date.now()
+    const cutoffTime =
+      startTime - options.min_delete_age + 100 * (Math.random() - 0.5)
+    let count = 0
 
-        const flushProjectIfNotModified = (project_id, flushTimestamp, cb) => ProjectManager.getProjectDocsTimestamps(project_id, function(err, timestamps) {
-            if (err != null) { return callback(err); }
-            if (timestamps.length === 0) {
-                logger.log({project_id}, "skipping flush of queued project - no timestamps");
-                return cb();
+    const flushProjectIfNotModified = (project_id, flushTimestamp, cb) =>
+      ProjectManager.getProjectDocsTimestamps(project_id, function (
+        err,
+        timestamps
+      ) {
+        if (err != null) {
+          return callback(err)
+        }
+        if (timestamps.length === 0) {
+          logger.log(
+            { project_id },
+            'skipping flush of queued project - no timestamps'
+          )
+          return cb()
+        }
+        // are any of the timestamps newer than the time the project was flushed?
+        for (const timestamp of Array.from(timestamps)) {
+          if (timestamp > flushTimestamp) {
+            metrics.inc('queued-delete-skipped')
+            logger.debug(
+              { project_id, timestamps, flushTimestamp },
+              'found newer timestamp, will skip delete'
+            )
+            return cb()
+          }
+        }
+        logger.log({ project_id, flushTimestamp }, 'flushing queued project')
+        return ProjectManager.flushAndDeleteProjectWithLocks(
+          project_id,
+          { skip_history_flush: false },
+          function (err) {
+            if (err != null) {
+              logger.err({ project_id, err }, 'error flushing queued project')
             }
-            // are any of the timestamps newer than the time the project was flushed?
-            for (const timestamp of Array.from(timestamps)) {
-                if (timestamp > flushTimestamp) {
-                    metrics.inc("queued-delete-skipped");
-                    logger.debug({project_id, timestamps, flushTimestamp}, "found newer timestamp, will skip delete");
-                    return cb();
-                }
-            }
-            logger.log({project_id, flushTimestamp}, "flushing queued project");
-            return ProjectManager.flushAndDeleteProjectWithLocks(project_id, {skip_history_flush: false}, function(err) {
-                if (err != null) {
-                    logger.err({project_id, err}, "error flushing queued project");
-                }
-                metrics.inc("queued-delete-completed");
-                return cb(null, true);
-            });
-        });
+            metrics.inc('queued-delete-completed')
+            return cb(null, true)
+          }
+        )
+      })
 
-        var flushNextProject = function() {
-            const now = Date.now();
-            if ((now - startTime) > options.timeout) {
-                logger.log("hit time limit on flushing old projects");
-                return callback(null, count);
-            }
-            if (count > options.limit) {
-                logger.log("hit count limit on flushing old projects");
-                return callback(null, count);
-            }
-            return RedisManager.getNextProjectToFlushAndDelete(cutoffTime, function(err, project_id, flushTimestamp, queueLength) {
-                if (err != null) { return callback(err); }
-                if ((project_id == null)) { return callback(null, count); }
-                logger.log({project_id, queueLength}, "flushing queued project");
-                metrics.globalGauge("queued-flush-backlog", queueLength);
-                return flushProjectIfNotModified(project_id, flushTimestamp, function(err, flushed) {
-                    if (flushed) { count++; }
-                    return flushNextProject();
-                });
-            });
-        };
+    var flushNextProject = function () {
+      const now = Date.now()
+      if (now - startTime > options.timeout) {
+        logger.log('hit time limit on flushing old projects')
+        return callback(null, count)
+      }
+      if (count > options.limit) {
+        logger.log('hit count limit on flushing old projects')
+        return callback(null, count)
+      }
+      return RedisManager.getNextProjectToFlushAndDelete(cutoffTime, function (
+        err,
+        project_id,
+        flushTimestamp,
+        queueLength
+      ) {
+        if (err != null) {
+          return callback(err)
+        }
+        if (project_id == null) {
+          return callback(null, count)
+        }
+        logger.log({ project_id, queueLength }, 'flushing queued project')
+        metrics.globalGauge('queued-flush-backlog', queueLength)
+        return flushProjectIfNotModified(project_id, flushTimestamp, function (
+          err,
+          flushed
+        ) {
+          if (flushed) {
+            count++
+          }
+          return flushNextProject()
+        })
+      })
+    }
 
-        return flushNextProject();
-    },
+    return flushNextProject()
+  },
 
-    startBackgroundFlush() {
-        const SHORT_DELAY = 10;
-        const LONG_DELAY = 1000;
-        var doFlush = function() {
-            if (Settings.shuttingDown) {
-                logger.warn("discontinuing background flush due to shutdown");
-                return;
-            }
-            return DeleteQueueManager.flushAndDeleteOldProjects({
-                timeout:1000,
-                min_delete_age:3*60*1000,
-                limit:1000 // high value, to ensure we always flush enough projects
-            }, (err, flushed) => setTimeout(doFlush, (flushed > 10 ? SHORT_DELAY : LONG_DELAY)));
-        };
-        return doFlush();
+  startBackgroundFlush() {
+    const SHORT_DELAY = 10
+    const LONG_DELAY = 1000
+    var doFlush = function () {
+      if (Settings.shuttingDown) {
+        logger.warn('discontinuing background flush due to shutdown')
+        return
+      }
+      return DeleteQueueManager.flushAndDeleteOldProjects(
+        {
+          timeout: 1000,
+          min_delete_age: 3 * 60 * 1000,
+          limit: 1000 // high value, to ensure we always flush enough projects
+        },
+        (err, flushed) =>
+          setTimeout(doFlush, flushed > 10 ? SHORT_DELAY : LONG_DELAY)
+      )
     }
-});
+    return doFlush()
+  }
+}

+ 38 - 38
services/document-updater/app/js/DiffCodec.js

@@ -14,44 +14,44 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let DiffCodec;
-const {
-    diff_match_patch
-} = require("../lib/diff_match_patch");
-const dmp = new diff_match_patch();
+let DiffCodec
+const { diff_match_patch } = require('../lib/diff_match_patch')
+const dmp = new diff_match_patch()
 
-module.exports = (DiffCodec = {
-	ADDED: 1,
-	REMOVED: -1,
-	UNCHANGED: 0,
+module.exports = DiffCodec = {
+  ADDED: 1,
+  REMOVED: -1,
+  UNCHANGED: 0,
 
-	diffAsShareJsOp(before, after, callback) {
-		if (callback == null) { callback = function(error, ops) {}; }
-		const diffs = dmp.diff_main(before.join("\n"), after.join("\n"));
-		dmp.diff_cleanupSemantic(diffs);
+  diffAsShareJsOp(before, after, callback) {
+    if (callback == null) {
+      callback = function (error, ops) {}
+    }
+    const diffs = dmp.diff_main(before.join('\n'), after.join('\n'))
+    dmp.diff_cleanupSemantic(diffs)
 
-		const ops = [];
-		let position = 0;
-		for (const diff of Array.from(diffs)) {
-			const type = diff[0];
-			const content = diff[1];
-			if (type === this.ADDED) {
-				ops.push({
-					i: content,
-					p: position
-				});
-				position += content.length;
-			} else if (type === this.REMOVED) {
-				ops.push({
-					d: content,
-					p: position
-				});
-			} else if (type === this.UNCHANGED) {
-				position += content.length;
-			} else {
-				throw "Unknown type";
-			}
-		}
-		return callback(null, ops);
-	}
-});
+    const ops = []
+    let position = 0
+    for (const diff of Array.from(diffs)) {
+      const type = diff[0]
+      const content = diff[1]
+      if (type === this.ADDED) {
+        ops.push({
+          i: content,
+          p: position
+        })
+        position += content.length
+      } else if (type === this.REMOVED) {
+        ops.push({
+          d: content,
+          p: position
+        })
+      } else if (type === this.UNCHANGED) {
+        position += content.length
+      } else {
+        throw 'Unknown type'
+      }
+    }
+    return callback(null, ops)
+  }
+}

+ 100 - 70
services/document-updater/app/js/DispatchManager.js

@@ -14,75 +14,105 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let DispatchManager;
-const Settings = require('settings-sharelatex');
-const logger = require('logger-sharelatex');
-const Keys = require('./UpdateKeys');
-const redis = require("redis-sharelatex");
-const Errors = require("./Errors");
+let DispatchManager
+const Settings = require('settings-sharelatex')
+const logger = require('logger-sharelatex')
+const Keys = require('./UpdateKeys')
+const redis = require('redis-sharelatex')
+const Errors = require('./Errors')
 
-const UpdateManager = require('./UpdateManager');
-const Metrics = require('./Metrics');
-const RateLimitManager = require('./RateLimitManager');
+const UpdateManager = require('./UpdateManager')
+const Metrics = require('./Metrics')
+const RateLimitManager = require('./RateLimitManager')
 
-module.exports = (DispatchManager = {
-	createDispatcher(RateLimiter) {
-		const client = redis.createClient(Settings.redis.documentupdater);
-		var worker = {
-			client,
-			_waitForUpdateThenDispatchWorker(callback) {
-				if (callback == null) { callback = function(error) {}; }
-				const timer = new Metrics.Timer("worker.waiting");
-				return worker.client.blpop("pending-updates-list", 0, function(error, result) {
-					logger.log("getting pending-updates-list", error, result);
-					timer.done();
-					if (error != null) { return callback(error); }
-					if ((result == null)) { return callback(); }
-					const [list_name, doc_key] = Array.from(result);
-					const [project_id, doc_id] = Array.from(Keys.splitProjectIdAndDocId(doc_key));
-					// Dispatch this in the background
-					const backgroundTask = cb => UpdateManager.processOutstandingUpdatesWithLock(project_id, doc_id, function(error) {
-                        // log everything except OpRangeNotAvailable errors, these are normal
-                        if (error != null) { 
-                            // downgrade OpRangeNotAvailable and "Delete component" errors so they are not sent to sentry
-                            const logAsWarning = (error instanceof Errors.OpRangeNotAvailableError) || (error instanceof Errors.DeleteMismatchError);
-                            if (logAsWarning) {
-                                logger.warn({err: error, project_id, doc_id}, "error processing update");
-                            } else {
-                                logger.error({err: error, project_id, doc_id}, "error processing update");
-                            }
-                        }
-                        return cb();
-                    });
-					return RateLimiter.run(backgroundTask, callback);
-				});
-			},
-						
-			run() {
-				if (Settings.shuttingDown) { return; }
-				return worker._waitForUpdateThenDispatchWorker(error => {
-					if (error != null) {
-						logger.error({err: error}, "Error in worker process");
-						throw error;
-					} else {
-						return worker.run();
-					}
-				});
-			}
-		};
-		
-		return worker;
-	},
-		
-	createAndStartDispatchers(number) {
-		const RateLimiter = new RateLimitManager(number);
-		return (() => {
-			const result = [];
-			for (let i = 1, end = number, asc = end >= 1; asc ? i <= end : i >= end; asc ? i++ : i--) {
-				const worker = DispatchManager.createDispatcher(RateLimiter);
-				result.push(worker.run());
-			}
-			return result;
-		})();
-	}
-});
+module.exports = DispatchManager = {
+  createDispatcher(RateLimiter) {
+    const client = redis.createClient(Settings.redis.documentupdater)
+    var worker = {
+      client,
+      _waitForUpdateThenDispatchWorker(callback) {
+        if (callback == null) {
+          callback = function (error) {}
+        }
+        const timer = new Metrics.Timer('worker.waiting')
+        return worker.client.blpop('pending-updates-list', 0, function (
+          error,
+          result
+        ) {
+          logger.log('getting pending-updates-list', error, result)
+          timer.done()
+          if (error != null) {
+            return callback(error)
+          }
+          if (result == null) {
+            return callback()
+          }
+          const [list_name, doc_key] = Array.from(result)
+          const [project_id, doc_id] = Array.from(
+            Keys.splitProjectIdAndDocId(doc_key)
+          )
+          // Dispatch this in the background
+          const backgroundTask = (cb) =>
+            UpdateManager.processOutstandingUpdatesWithLock(
+              project_id,
+              doc_id,
+              function (error) {
+                // log everything except OpRangeNotAvailable errors, these are normal
+                if (error != null) {
+                  // downgrade OpRangeNotAvailable and "Delete component" errors so they are not sent to sentry
+                  const logAsWarning =
+                    error instanceof Errors.OpRangeNotAvailableError ||
+                    error instanceof Errors.DeleteMismatchError
+                  if (logAsWarning) {
+                    logger.warn(
+                      { err: error, project_id, doc_id },
+                      'error processing update'
+                    )
+                  } else {
+                    logger.error(
+                      { err: error, project_id, doc_id },
+                      'error processing update'
+                    )
+                  }
+                }
+                return cb()
+              }
+            )
+          return RateLimiter.run(backgroundTask, callback)
+        })
+      },
+
+      run() {
+        if (Settings.shuttingDown) {
+          return
+        }
+        return worker._waitForUpdateThenDispatchWorker((error) => {
+          if (error != null) {
+            logger.error({ err: error }, 'Error in worker process')
+            throw error
+          } else {
+            return worker.run()
+          }
+        })
+      }
+    }
+
+    return worker
+  },
+
+  createAndStartDispatchers(number) {
+    const RateLimiter = new RateLimitManager(number)
+    return (() => {
+      const result = []
+      for (
+        let i = 1, end = number, asc = end >= 1;
+        asc ? i <= end : i >= end;
+        asc ? i++ : i--
+      ) {
+        const worker = DispatchManager.createDispatcher(RateLimiter)
+        result.push(worker.run())
+      }
+      return result
+    })()
+  }
+}

+ 759 - 333
services/document-updater/app/js/DocumentManager.js

@@ -12,336 +12,762 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let DocumentManager;
-const RedisManager = require("./RedisManager");
-const ProjectHistoryRedisManager = require("./ProjectHistoryRedisManager");
-const PersistenceManager = require("./PersistenceManager");
-const DiffCodec = require("./DiffCodec");
-const logger = require("logger-sharelatex");
-const Metrics = require("./Metrics");
-const HistoryManager = require("./HistoryManager");
-const RealTimeRedisManager = require("./RealTimeRedisManager");
-const Errors = require("./Errors");
-const RangesManager = require("./RangesManager");
-const async = require("async");
-
-const MAX_UNFLUSHED_AGE = 300 * 1000; // 5 mins, document should be flushed to mongo this time after a change
-
-module.exports = (DocumentManager = {
-	getDoc(project_id, doc_id, _callback) {
-		if (_callback == null) { _callback = function(error, lines, version, ranges, pathname, projectHistoryId, unflushedTime, alreadyLoaded) {}; }
-		const timer = new Metrics.Timer("docManager.getDoc");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return RedisManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId, unflushedTime) {
-			if (error != null) { return callback(error); }
-			if ((lines == null) || (version == null)) {
-				logger.log({project_id, doc_id}, "doc not in redis so getting from persistence API");
-				return PersistenceManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId, projectHistoryType) {
-					if (error != null) { return callback(error); }
-					logger.log({project_id, doc_id, lines, version, pathname, projectHistoryId, projectHistoryType}, "got doc from persistence API");
-					return RedisManager.putDocInMemory(project_id, doc_id, lines, version, ranges, pathname, projectHistoryId, function(error) {
-						if (error != null) { return callback(error); }
-						return RedisManager.setHistoryType(doc_id, projectHistoryType, function(error) {
-							if (error != null) { return callback(error); }
-							return callback(null, lines, version, ranges || {}, pathname, projectHistoryId, null, false);
-						});
-					});
-				});
-			} else {
-				return callback(null, lines, version, ranges, pathname, projectHistoryId, unflushedTime, true);
-			}
-		});
-	},
-
-	getDocAndRecentOps(project_id, doc_id, fromVersion, _callback) {
-		if (_callback == null) { _callback = function(error, lines, version, ops, ranges, pathname, projectHistoryId) {}; }
-		const timer = new Metrics.Timer("docManager.getDocAndRecentOps");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return DocumentManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId) {
-			if (error != null) { return callback(error); }
-			if (fromVersion === -1) {
-				return callback(null, lines, version, [], ranges, pathname, projectHistoryId);
-			} else {
-				return RedisManager.getPreviousDocOps(doc_id, fromVersion, version, function(error, ops) {
-					if (error != null) { return callback(error); }
-					return callback(null, lines, version, ops, ranges, pathname, projectHistoryId);
-				});
-			}
-		});
-	},
-
-	setDoc(project_id, doc_id, newLines, source, user_id, undoing, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("docManager.setDoc");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		if ((newLines == null)) {
-			return callback(new Error("No lines were provided to setDoc"));
-		}
-
-		const UpdateManager = require("./UpdateManager");
-		return DocumentManager.getDoc(project_id, doc_id, function(error, oldLines, version, ranges, pathname, projectHistoryId, unflushedTime, alreadyLoaded) {
-			if (error != null) { return callback(error); }
-
-			if ((oldLines != null) && (oldLines.length > 0) && (oldLines[0].text != null)) {
-				logger.log({doc_id, project_id, oldLines, newLines}, "document is JSON so not updating");
-				return callback(null);
-			}
-
-			logger.log({doc_id, project_id, oldLines, newLines}, "setting a document via http");
-			return DiffCodec.diffAsShareJsOp(oldLines, newLines, function(error, op) {
-				if (error != null) { return callback(error); }
-				if (undoing) {
-					for (const o of Array.from(op || [])) {
-						o.u = true;
-					} // Turn on undo flag for each op for track changes
-				}
-				const update = {
-					doc: doc_id,
-					op,
-					v: version,
-					meta: {
-						type: "external",
-						source,
-						user_id
-					}
-				};
-				return UpdateManager.applyUpdate(project_id, doc_id, update, function(error) {
-					if (error != null) { return callback(error); }
-					// If the document was loaded already, then someone has it open
-					// in a project, and the usual flushing mechanism will happen.
-					// Otherwise we should remove it immediately since nothing else
-					// is using it.
-					if (alreadyLoaded) {
-						return DocumentManager.flushDocIfLoaded(project_id, doc_id, function(error) {
-							if (error != null) { return callback(error); }
-							return callback(null);
-						});
-					} else {
-						return DocumentManager.flushAndDeleteDoc(project_id, doc_id, {}, function(error) {
-							// There is no harm in flushing project history if the previous
-							// call failed and sometimes it is required
-							HistoryManager.flushProjectChangesAsync(project_id);
-
-							if (error != null) { return callback(error); }
-							return callback(null);
-						});
-					}
-				});
-			});
-		});
-	},
-
-	flushDocIfLoaded(project_id, doc_id, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("docManager.flushDocIfLoaded");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-		return RedisManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId, unflushedTime, lastUpdatedAt, lastUpdatedBy) {
-			if (error != null) { return callback(error); }
-			if ((lines == null) || (version == null)) {
-				logger.log({project_id, doc_id}, "doc is not loaded so not flushing");
-				return callback(null);  // TODO: return a flag to bail out, as we go on to remove doc from memory?
-			} else {
-				logger.log({project_id, doc_id, version}, "flushing doc");
-				return PersistenceManager.setDoc(project_id, doc_id, lines, version, ranges, lastUpdatedAt, lastUpdatedBy, function(error) {
-					if (error != null) { return callback(error); }
-					return RedisManager.clearUnflushedTime(doc_id, callback);
-				});
-			}
-		});
-	},
-
-	flushAndDeleteDoc(project_id, doc_id, options, _callback) {
-		const timer = new Metrics.Timer("docManager.flushAndDeleteDoc");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return DocumentManager.flushDocIfLoaded(project_id, doc_id, function(error) {
-			if (error != null) {
-				if (options.ignoreFlushErrors) {
-					logger.warn({project_id, doc_id, err: error}, "ignoring flush error while deleting document");
-				} else {
-					return callback(error);
-				}
-			}
-
-			// Flush in the background since it requires a http request
-			HistoryManager.flushDocChangesAsync(project_id, doc_id);
-
-			return RedisManager.removeDocFromMemory(project_id, doc_id, function(error) {
-				if (error != null) { return callback(error); }
-				return callback(null);
-			});
-		});
-	},
-
-	acceptChanges(project_id, doc_id, change_ids, _callback) {
-		if (change_ids == null) { change_ids = []; }
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("docManager.acceptChanges");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return DocumentManager.getDoc(project_id, doc_id, function(error, lines, version, ranges) {
-			if (error != null) { return callback(error); }
-			if ((lines == null) || (version == null)) {
-				return callback(new Errors.NotFoundError(`document not found: ${doc_id}`));
-			}
-			return RangesManager.acceptChanges(change_ids, ranges, function(error, new_ranges) {
-				if (error != null) { return callback(error); }
-				return RedisManager.updateDocument(project_id, doc_id, lines, version, [], new_ranges, {}, function(error) {
-					if (error != null) { return callback(error); }
-					return callback();
-				});
-			});
-		});
-	},
-
-	deleteComment(project_id, doc_id, comment_id, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("docManager.deleteComment");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return DocumentManager.getDoc(project_id, doc_id, function(error, lines, version, ranges) {
-			if (error != null) { return callback(error); }
-			if ((lines == null) || (version == null)) {
-				return callback(new Errors.NotFoundError(`document not found: ${doc_id}`));
-			}
-			return RangesManager.deleteComment(comment_id, ranges, function(error, new_ranges) {
-				if (error != null) { return callback(error); }
-				return RedisManager.updateDocument(project_id, doc_id, lines, version, [], new_ranges, {}, function(error) {
-					if (error != null) { return callback(error); }
-					return callback();
-				});
-			});
-		});
-	},
-
-	renameDoc(project_id, doc_id, user_id, update, projectHistoryId, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("docManager.updateProject");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return RedisManager.renameDoc(project_id, doc_id, user_id, update, projectHistoryId, callback);
-	},
-
-	getDocAndFlushIfOld(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error, doc) {}; }
-		return DocumentManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId, unflushedTime, alreadyLoaded) {
-			if (error != null) { return callback(error); }
-			// if doc was already loaded see if it needs to be flushed
-			if (alreadyLoaded && (unflushedTime != null) && ((Date.now() - unflushedTime) > MAX_UNFLUSHED_AGE)) {
-				return DocumentManager.flushDocIfLoaded(project_id, doc_id, function(error) {
-					if (error != null) { return callback(error); }
-					return callback(null, lines, version);
-				});
-			} else {
-				return callback(null, lines, version);
-			}
-		});
-	},
-
-	resyncDocContents(project_id, doc_id, callback) {
-		logger.log({project_id, doc_id}, "start resyncing doc contents");
-		return RedisManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId) {
-			if (error != null) { return callback(error); }
-
-			if ((lines == null) || (version == null)) {
-				logger.log({project_id, doc_id}, "resyncing doc contents - not found in redis - retrieving from web");
-				return PersistenceManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId) {
-					if (error != null) {
-						logger.error({project_id, doc_id, getDocError: error}, "resyncing doc contents - error retrieving from web");
-						return callback(error);
-					}
-					return ProjectHistoryRedisManager.queueResyncDocContent(project_id, projectHistoryId, doc_id, lines, version, pathname, callback);
-				});
-			} else {
-				logger.log({project_id, doc_id}, "resyncing doc contents - doc in redis - will queue in redis");
-				return ProjectHistoryRedisManager.queueResyncDocContent(project_id, projectHistoryId, doc_id, lines, version, pathname, callback);
-			}
-		});
-	},
-
-	getDocWithLock(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error, lines, version) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.getDoc, project_id, doc_id, callback);
-	},
-
-	getDocAndRecentOpsWithLock(project_id, doc_id, fromVersion, callback) {
-		if (callback == null) { callback = function(error, lines, version, ops, ranges, pathname, projectHistoryId) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.getDocAndRecentOps, project_id, doc_id, fromVersion, callback);
-	},
-
-	getDocAndFlushIfOldWithLock(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error, doc) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.getDocAndFlushIfOld, project_id, doc_id, callback);
-	},
-
-	setDocWithLock(project_id, doc_id, lines, source, user_id, undoing, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.setDoc, project_id, doc_id, lines, source, user_id, undoing, callback);
-	},
-
-	flushDocIfLoadedWithLock(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.flushDocIfLoaded, project_id, doc_id, callback);
-	},
-
-	flushAndDeleteDocWithLock(project_id, doc_id, options, callback) {
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.flushAndDeleteDoc, project_id, doc_id, options, callback);
-	},
-
-	acceptChangesWithLock(project_id, doc_id, change_ids, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.acceptChanges, project_id, doc_id, change_ids, callback);
-	},
-
-	deleteCommentWithLock(project_id, doc_id, thread_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.deleteComment, project_id, doc_id, thread_id, callback);
-	},
-
-	renameDocWithLock(project_id, doc_id, user_id, update, projectHistoryId, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.renameDoc, project_id, doc_id, user_id, update, projectHistoryId, callback);
-	},
-
-	resyncDocContentsWithLock(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const UpdateManager = require("./UpdateManager");
-		return UpdateManager.lockUpdatesAndDo(DocumentManager.resyncDocContents, project_id, doc_id, callback);
-	}
-});
+let DocumentManager
+const RedisManager = require('./RedisManager')
+const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
+const PersistenceManager = require('./PersistenceManager')
+const DiffCodec = require('./DiffCodec')
+const logger = require('logger-sharelatex')
+const Metrics = require('./Metrics')
+const HistoryManager = require('./HistoryManager')
+const RealTimeRedisManager = require('./RealTimeRedisManager')
+const Errors = require('./Errors')
+const RangesManager = require('./RangesManager')
+const async = require('async')
+
+const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change
+
+module.exports = DocumentManager = {
+  getDoc(project_id, doc_id, _callback) {
+    if (_callback == null) {
+      _callback = function (
+        error,
+        lines,
+        version,
+        ranges,
+        pathname,
+        projectHistoryId,
+        unflushedTime,
+        alreadyLoaded
+      ) {}
+    }
+    const timer = new Metrics.Timer('docManager.getDoc')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return RedisManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId,
+      unflushedTime
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (lines == null || version == null) {
+        logger.log(
+          { project_id, doc_id },
+          'doc not in redis so getting from persistence API'
+        )
+        return PersistenceManager.getDoc(project_id, doc_id, function (
+          error,
+          lines,
+          version,
+          ranges,
+          pathname,
+          projectHistoryId,
+          projectHistoryType
+        ) {
+          if (error != null) {
+            return callback(error)
+          }
+          logger.log(
+            {
+              project_id,
+              doc_id,
+              lines,
+              version,
+              pathname,
+              projectHistoryId,
+              projectHistoryType
+            },
+            'got doc from persistence API'
+          )
+          return RedisManager.putDocInMemory(
+            project_id,
+            doc_id,
+            lines,
+            version,
+            ranges,
+            pathname,
+            projectHistoryId,
+            function (error) {
+              if (error != null) {
+                return callback(error)
+              }
+              return RedisManager.setHistoryType(
+                doc_id,
+                projectHistoryType,
+                function (error) {
+                  if (error != null) {
+                    return callback(error)
+                  }
+                  return callback(
+                    null,
+                    lines,
+                    version,
+                    ranges || {},
+                    pathname,
+                    projectHistoryId,
+                    null,
+                    false
+                  )
+                }
+              )
+            }
+          )
+        })
+      } else {
+        return callback(
+          null,
+          lines,
+          version,
+          ranges,
+          pathname,
+          projectHistoryId,
+          unflushedTime,
+          true
+        )
+      }
+    })
+  },
+
+  getDocAndRecentOps(project_id, doc_id, fromVersion, _callback) {
+    if (_callback == null) {
+      _callback = function (
+        error,
+        lines,
+        version,
+        ops,
+        ranges,
+        pathname,
+        projectHistoryId
+      ) {}
+    }
+    const timer = new Metrics.Timer('docManager.getDocAndRecentOps')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return DocumentManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (fromVersion === -1) {
+        return callback(
+          null,
+          lines,
+          version,
+          [],
+          ranges,
+          pathname,
+          projectHistoryId
+        )
+      } else {
+        return RedisManager.getPreviousDocOps(
+          doc_id,
+          fromVersion,
+          version,
+          function (error, ops) {
+            if (error != null) {
+              return callback(error)
+            }
+            return callback(
+              null,
+              lines,
+              version,
+              ops,
+              ranges,
+              pathname,
+              projectHistoryId
+            )
+          }
+        )
+      }
+    })
+  },
+
+  setDoc(project_id, doc_id, newLines, source, user_id, undoing, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('docManager.setDoc')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    if (newLines == null) {
+      return callback(new Error('No lines were provided to setDoc'))
+    }
+
+    const UpdateManager = require('./UpdateManager')
+    return DocumentManager.getDoc(project_id, doc_id, function (
+      error,
+      oldLines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId,
+      unflushedTime,
+      alreadyLoaded
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+
+      if (oldLines != null && oldLines.length > 0 && oldLines[0].text != null) {
+        logger.log(
+          { doc_id, project_id, oldLines, newLines },
+          'document is JSON so not updating'
+        )
+        return callback(null)
+      }
+
+      logger.log(
+        { doc_id, project_id, oldLines, newLines },
+        'setting a document via http'
+      )
+      return DiffCodec.diffAsShareJsOp(oldLines, newLines, function (
+        error,
+        op
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        if (undoing) {
+          for (const o of Array.from(op || [])) {
+            o.u = true
+          } // Turn on undo flag for each op for track changes
+        }
+        const update = {
+          doc: doc_id,
+          op,
+          v: version,
+          meta: {
+            type: 'external',
+            source,
+            user_id
+          }
+        }
+        return UpdateManager.applyUpdate(project_id, doc_id, update, function (
+          error
+        ) {
+          if (error != null) {
+            return callback(error)
+          }
+          // If the document was loaded already, then someone has it open
+          // in a project, and the usual flushing mechanism will happen.
+          // Otherwise we should remove it immediately since nothing else
+          // is using it.
+          if (alreadyLoaded) {
+            return DocumentManager.flushDocIfLoaded(
+              project_id,
+              doc_id,
+              function (error) {
+                if (error != null) {
+                  return callback(error)
+                }
+                return callback(null)
+              }
+            )
+          } else {
+            return DocumentManager.flushAndDeleteDoc(
+              project_id,
+              doc_id,
+              {},
+              function (error) {
+                // There is no harm in flushing project history if the previous
+                // call failed and sometimes it is required
+                HistoryManager.flushProjectChangesAsync(project_id)
+
+                if (error != null) {
+                  return callback(error)
+                }
+                return callback(null)
+              }
+            )
+          }
+        })
+      })
+    })
+  },
+
+  flushDocIfLoaded(project_id, doc_id, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('docManager.flushDocIfLoaded')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+    return RedisManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId,
+      unflushedTime,
+      lastUpdatedAt,
+      lastUpdatedBy
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (lines == null || version == null) {
+        logger.log({ project_id, doc_id }, 'doc is not loaded so not flushing')
+        return callback(null) // TODO: return a flag to bail out, as we go on to remove doc from memory?
+      } else {
+        logger.log({ project_id, doc_id, version }, 'flushing doc')
+        return PersistenceManager.setDoc(
+          project_id,
+          doc_id,
+          lines,
+          version,
+          ranges,
+          lastUpdatedAt,
+          lastUpdatedBy,
+          function (error) {
+            if (error != null) {
+              return callback(error)
+            }
+            return RedisManager.clearUnflushedTime(doc_id, callback)
+          }
+        )
+      }
+    })
+  },
+
+  flushAndDeleteDoc(project_id, doc_id, options, _callback) {
+    const timer = new Metrics.Timer('docManager.flushAndDeleteDoc')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return DocumentManager.flushDocIfLoaded(project_id, doc_id, function (
+      error
+    ) {
+      if (error != null) {
+        if (options.ignoreFlushErrors) {
+          logger.warn(
+            { project_id, doc_id, err: error },
+            'ignoring flush error while deleting document'
+          )
+        } else {
+          return callback(error)
+        }
+      }
+
+      // Flush in the background since it requires a http request
+      HistoryManager.flushDocChangesAsync(project_id, doc_id)
+
+      return RedisManager.removeDocFromMemory(project_id, doc_id, function (
+        error
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        return callback(null)
+      })
+    })
+  },
+
+  acceptChanges(project_id, doc_id, change_ids, _callback) {
+    if (change_ids == null) {
+      change_ids = []
+    }
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('docManager.acceptChanges')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return DocumentManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (lines == null || version == null) {
+        return callback(
+          new Errors.NotFoundError(`document not found: ${doc_id}`)
+        )
+      }
+      return RangesManager.acceptChanges(change_ids, ranges, function (
+        error,
+        new_ranges
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        return RedisManager.updateDocument(
+          project_id,
+          doc_id,
+          lines,
+          version,
+          [],
+          new_ranges,
+          {},
+          function (error) {
+            if (error != null) {
+              return callback(error)
+            }
+            return callback()
+          }
+        )
+      })
+    })
+  },
+
+  deleteComment(project_id, doc_id, comment_id, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('docManager.deleteComment')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return DocumentManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (lines == null || version == null) {
+        return callback(
+          new Errors.NotFoundError(`document not found: ${doc_id}`)
+        )
+      }
+      return RangesManager.deleteComment(comment_id, ranges, function (
+        error,
+        new_ranges
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        return RedisManager.updateDocument(
+          project_id,
+          doc_id,
+          lines,
+          version,
+          [],
+          new_ranges,
+          {},
+          function (error) {
+            if (error != null) {
+              return callback(error)
+            }
+            return callback()
+          }
+        )
+      })
+    })
+  },
+
+  renameDoc(project_id, doc_id, user_id, update, projectHistoryId, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('docManager.updateProject')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return RedisManager.renameDoc(
+      project_id,
+      doc_id,
+      user_id,
+      update,
+      projectHistoryId,
+      callback
+    )
+  },
+
+  getDocAndFlushIfOld(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, doc) {}
+    }
+    return DocumentManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId,
+      unflushedTime,
+      alreadyLoaded
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      // if doc was already loaded see if it needs to be flushed
+      if (
+        alreadyLoaded &&
+        unflushedTime != null &&
+        Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
+      ) {
+        return DocumentManager.flushDocIfLoaded(project_id, doc_id, function (
+          error
+        ) {
+          if (error != null) {
+            return callback(error)
+          }
+          return callback(null, lines, version)
+        })
+      } else {
+        return callback(null, lines, version)
+      }
+    })
+  },
+
+  resyncDocContents(project_id, doc_id, callback) {
+    logger.log({ project_id, doc_id }, 'start resyncing doc contents')
+    return RedisManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+
+      if (lines == null || version == null) {
+        logger.log(
+          { project_id, doc_id },
+          'resyncing doc contents - not found in redis - retrieving from web'
+        )
+        return PersistenceManager.getDoc(project_id, doc_id, function (
+          error,
+          lines,
+          version,
+          ranges,
+          pathname,
+          projectHistoryId
+        ) {
+          if (error != null) {
+            logger.error(
+              { project_id, doc_id, getDocError: error },
+              'resyncing doc contents - error retrieving from web'
+            )
+            return callback(error)
+          }
+          return ProjectHistoryRedisManager.queueResyncDocContent(
+            project_id,
+            projectHistoryId,
+            doc_id,
+            lines,
+            version,
+            pathname,
+            callback
+          )
+        })
+      } else {
+        logger.log(
+          { project_id, doc_id },
+          'resyncing doc contents - doc in redis - will queue in redis'
+        )
+        return ProjectHistoryRedisManager.queueResyncDocContent(
+          project_id,
+          projectHistoryId,
+          doc_id,
+          lines,
+          version,
+          pathname,
+          callback
+        )
+      }
+    })
+  },
+
+  getDocWithLock(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, lines, version) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.getDoc,
+      project_id,
+      doc_id,
+      callback
+    )
+  },
+
+  getDocAndRecentOpsWithLock(project_id, doc_id, fromVersion, callback) {
+    if (callback == null) {
+      callback = function (
+        error,
+        lines,
+        version,
+        ops,
+        ranges,
+        pathname,
+        projectHistoryId
+      ) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.getDocAndRecentOps,
+      project_id,
+      doc_id,
+      fromVersion,
+      callback
+    )
+  },
+
+  getDocAndFlushIfOldWithLock(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, doc) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.getDocAndFlushIfOld,
+      project_id,
+      doc_id,
+      callback
+    )
+  },
+
+  setDocWithLock(
+    project_id,
+    doc_id,
+    lines,
+    source,
+    user_id,
+    undoing,
+    callback
+  ) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.setDoc,
+      project_id,
+      doc_id,
+      lines,
+      source,
+      user_id,
+      undoing,
+      callback
+    )
+  },
+
+  flushDocIfLoadedWithLock(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.flushDocIfLoaded,
+      project_id,
+      doc_id,
+      callback
+    )
+  },
+
+  flushAndDeleteDocWithLock(project_id, doc_id, options, callback) {
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.flushAndDeleteDoc,
+      project_id,
+      doc_id,
+      options,
+      callback
+    )
+  },
+
+  acceptChangesWithLock(project_id, doc_id, change_ids, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.acceptChanges,
+      project_id,
+      doc_id,
+      change_ids,
+      callback
+    )
+  },
+
+  deleteCommentWithLock(project_id, doc_id, thread_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.deleteComment,
+      project_id,
+      doc_id,
+      thread_id,
+      callback
+    )
+  },
+
+  renameDocWithLock(
+    project_id,
+    doc_id,
+    user_id,
+    update,
+    projectHistoryId,
+    callback
+  ) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.renameDoc,
+      project_id,
+      doc_id,
+      user_id,
+      update,
+      projectHistoryId,
+      callback
+    )
+  },
+
+  resyncDocContentsWithLock(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const UpdateManager = require('./UpdateManager')
+    return UpdateManager.lockUpdatesAndDo(
+      DocumentManager.resyncDocContents,
+      project_id,
+      doc_id,
+      callback
+    )
+  }
+}

+ 35 - 35
services/document-updater/app/js/Errors.js

@@ -4,42 +4,42 @@
 */
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
-let Errors;
-var NotFoundError = function(message) {
-	const error = new Error(message);
-	error.name = "NotFoundError";
-	error.__proto__ = NotFoundError.prototype;
-	return error;
-};
-NotFoundError.prototype.__proto__ = Error.prototype;
+let Errors
+var NotFoundError = function (message) {
+  const error = new Error(message)
+  error.name = 'NotFoundError'
+  error.__proto__ = NotFoundError.prototype
+  return error
+}
+NotFoundError.prototype.__proto__ = Error.prototype
 
-var OpRangeNotAvailableError = function(message) {
-	const error = new Error(message);
-	error.name = "OpRangeNotAvailableError";
-	error.__proto__ = OpRangeNotAvailableError.prototype;
-	return error;
-};
-OpRangeNotAvailableError.prototype.__proto__ = Error.prototype;
+var OpRangeNotAvailableError = function (message) {
+  const error = new Error(message)
+  error.name = 'OpRangeNotAvailableError'
+  error.__proto__ = OpRangeNotAvailableError.prototype
+  return error
+}
+OpRangeNotAvailableError.prototype.__proto__ = Error.prototype
 
-var ProjectStateChangedError = function(message) {
-	const error = new Error(message);
-	error.name = "ProjectStateChangedError";
-	error.__proto__ = ProjectStateChangedError.prototype;
-	return error;
-};
-ProjectStateChangedError.prototype.__proto__ = Error.prototype;
+var ProjectStateChangedError = function (message) {
+  const error = new Error(message)
+  error.name = 'ProjectStateChangedError'
+  error.__proto__ = ProjectStateChangedError.prototype
+  return error
+}
+ProjectStateChangedError.prototype.__proto__ = Error.prototype
 
-var DeleteMismatchError = function(message) {
-	const error = new Error(message);
-	error.name = "DeleteMismatchError";
-	error.__proto__ = DeleteMismatchError.prototype;
-	return error;
-};
-DeleteMismatchError.prototype.__proto__ = Error.prototype;
+var DeleteMismatchError = function (message) {
+  const error = new Error(message)
+  error.name = 'DeleteMismatchError'
+  error.__proto__ = DeleteMismatchError.prototype
+  return error
+}
+DeleteMismatchError.prototype.__proto__ = Error.prototype
 
-module.exports = (Errors = {
-	NotFoundError,
-	OpRangeNotAvailableError,
-	ProjectStateChangedError,
-	DeleteMismatchError
-});
+module.exports = Errors = {
+  NotFoundError,
+  OpRangeNotAvailableError,
+  ProjectStateChangedError,
+  DeleteMismatchError
+}

+ 237 - 124
services/document-updater/app/js/HistoryManager.js

@@ -11,140 +11,253 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let HistoryManager;
-const async = require("async");
-const logger = require("logger-sharelatex");
-const request  = require("request");
-const Settings = require("settings-sharelatex");
-const HistoryRedisManager = require("./HistoryRedisManager");
-const ProjectHistoryRedisManager = require("./ProjectHistoryRedisManager");
-const RedisManager = require("./RedisManager");
-const metrics = require("./Metrics");
+let HistoryManager
+const async = require('async')
+const logger = require('logger-sharelatex')
+const request = require('request')
+const Settings = require('settings-sharelatex')
+const HistoryRedisManager = require('./HistoryRedisManager')
+const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
+const RedisManager = require('./RedisManager')
+const metrics = require('./Metrics')
 
-module.exports = (HistoryManager = {
-	flushDocChangesAsync(project_id, doc_id) {
-		if (((Settings.apis != null ? Settings.apis.trackchanges : undefined) == null)) {
-			logger.warn({ doc_id }, "track changes API is not configured, so not flushing");
-			return;
-		}
-		return RedisManager.getHistoryType(doc_id, function(err, projectHistoryType) {
-			if (err != null) {
-				logger.warn({err, doc_id}, "error getting history type");
-			}
-				// if there's an error continue and flush to track-changes for safety
-			if (Settings.disableDoubleFlush && (projectHistoryType === "project-history")) {
-				return logger.debug({doc_id, projectHistoryType}, "skipping track-changes flush");
-			} else {
-				metrics.inc('history-flush', 1, { status: 'track-changes'});
-				const url = `${Settings.apis.trackchanges.url}/project/${project_id}/doc/${doc_id}/flush`;
-				logger.log({ project_id, doc_id, url, projectHistoryType }, "flushing doc in track changes api");
-				return request.post(url, function(error, res, body){
-					if (error != null) {
-						return logger.error({ error, doc_id, project_id}, "track changes doc to track changes api");
-					} else if ((res.statusCode < 200) && (res.statusCode >= 300)) {
-						return logger.error({ doc_id, project_id }, `track changes api returned a failure status code: ${res.statusCode}`);
-					}
-				});
-			}
-		});
-	},
+module.exports = HistoryManager = {
+  flushDocChangesAsync(project_id, doc_id) {
+    if (
+      (Settings.apis != null ? Settings.apis.trackchanges : undefined) == null
+    ) {
+      logger.warn(
+        { doc_id },
+        'track changes API is not configured, so not flushing'
+      )
+      return
+    }
+    return RedisManager.getHistoryType(doc_id, function (
+      err,
+      projectHistoryType
+    ) {
+      if (err != null) {
+        logger.warn({ err, doc_id }, 'error getting history type')
+      }
+      // if there's an error continue and flush to track-changes for safety
+      if (
+        Settings.disableDoubleFlush &&
+        projectHistoryType === 'project-history'
+      ) {
+        return logger.debug(
+          { doc_id, projectHistoryType },
+          'skipping track-changes flush'
+        )
+      } else {
+        metrics.inc('history-flush', 1, { status: 'track-changes' })
+        const url = `${Settings.apis.trackchanges.url}/project/${project_id}/doc/${doc_id}/flush`
+        logger.log(
+          { project_id, doc_id, url, projectHistoryType },
+          'flushing doc in track changes api'
+        )
+        return request.post(url, function (error, res, body) {
+          if (error != null) {
+            return logger.error(
+              { error, doc_id, project_id },
+              'track changes doc to track changes api'
+            )
+          } else if (res.statusCode < 200 && res.statusCode >= 300) {
+            return logger.error(
+              { doc_id, project_id },
+              `track changes api returned a failure status code: ${res.statusCode}`
+            )
+          }
+        })
+      }
+    })
+  },
 
-	// flush changes in the background
-	flushProjectChangesAsync(project_id) {
-		if (!__guard__(Settings.apis != null ? Settings.apis.project_history : undefined, x => x.enabled)) { return; }
-		return HistoryManager.flushProjectChanges(project_id, {background:true},  function() {});
-	},
+  // flush changes in the background
+  flushProjectChangesAsync(project_id) {
+    if (
+      !__guard__(
+        Settings.apis != null ? Settings.apis.project_history : undefined,
+        (x) => x.enabled
+      )
+    ) {
+      return
+    }
+    return HistoryManager.flushProjectChanges(
+      project_id,
+      { background: true },
+      function () {}
+    )
+  },
 
-	// flush changes and callback (for when we need to know the queue is flushed)
-	flushProjectChanges(project_id, options, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		if (!__guard__(Settings.apis != null ? Settings.apis.project_history : undefined, x => x.enabled)) { return callback(); }
-		if (options.skip_history_flush) {
-			logger.log({project_id}, "skipping flush of project history");
-			return callback();
-		}
-		metrics.inc('history-flush', 1, { status: 'project-history'});
-		const url = `${Settings.apis.project_history.url}/project/${project_id}/flush`;
-		const qs = {};
-		if (options.background) { qs.background = true; } // pass on the background flush option if present
-		logger.log({ project_id, url, qs }, "flushing doc in project history api");
-		return request.post({url, qs}, function(error, res, body){
-			if (error != null) {
-				logger.error({ error, project_id}, "project history doc to track changes api");
-				return callback(error);
-			} else if ((res.statusCode < 200) && (res.statusCode >= 300)) {
-				logger.error({ project_id }, `project history api returned a failure status code: ${res.statusCode}`);
-				return callback(error);
-			} else {
-				return callback();
-			}
-		});
-	},
+  // flush changes and callback (for when we need to know the queue is flushed)
+  flushProjectChanges(project_id, options, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    if (
+      !__guard__(
+        Settings.apis != null ? Settings.apis.project_history : undefined,
+        (x) => x.enabled
+      )
+    ) {
+      return callback()
+    }
+    if (options.skip_history_flush) {
+      logger.log({ project_id }, 'skipping flush of project history')
+      return callback()
+    }
+    metrics.inc('history-flush', 1, { status: 'project-history' })
+    const url = `${Settings.apis.project_history.url}/project/${project_id}/flush`
+    const qs = {}
+    if (options.background) {
+      qs.background = true
+    } // pass on the background flush option if present
+    logger.log({ project_id, url, qs }, 'flushing doc in project history api')
+    return request.post({ url, qs }, function (error, res, body) {
+      if (error != null) {
+        logger.error(
+          { error, project_id },
+          'project history doc to track changes api'
+        )
+        return callback(error)
+      } else if (res.statusCode < 200 && res.statusCode >= 300) {
+        logger.error(
+          { project_id },
+          `project history api returned a failure status code: ${res.statusCode}`
+        )
+        return callback(error)
+      } else {
+        return callback()
+      }
+    })
+  },
 
-	FLUSH_DOC_EVERY_N_OPS: 100,
-	FLUSH_PROJECT_EVERY_N_OPS: 500,
+  FLUSH_DOC_EVERY_N_OPS: 100,
+  FLUSH_PROJECT_EVERY_N_OPS: 500,
 
-	recordAndFlushHistoryOps(project_id, doc_id, ops, doc_ops_length, project_ops_length, callback) {
-		if (ops == null) { ops = []; }
-		if (callback == null) { callback = function(error) {}; }
-		if (ops.length === 0) {
-			return callback();
-		}
+  recordAndFlushHistoryOps(
+    project_id,
+    doc_id,
+    ops,
+    doc_ops_length,
+    project_ops_length,
+    callback
+  ) {
+    if (ops == null) {
+      ops = []
+    }
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    if (ops.length === 0) {
+      return callback()
+    }
 
-		// record updates for project history
-		if (__guard__(Settings.apis != null ? Settings.apis.project_history : undefined, x => x.enabled)) {
-			if (HistoryManager.shouldFlushHistoryOps(project_ops_length, ops.length, HistoryManager.FLUSH_PROJECT_EVERY_N_OPS)) {
-				// Do this in the background since it uses HTTP and so may be too
-				// slow to wait for when processing a doc update.
-				logger.log({ project_ops_length, project_id }, "flushing project history api");
-				HistoryManager.flushProjectChangesAsync(project_id);
-			}
-		}
+    // record updates for project history
+    if (
+      __guard__(
+        Settings.apis != null ? Settings.apis.project_history : undefined,
+        (x) => x.enabled
+      )
+    ) {
+      if (
+        HistoryManager.shouldFlushHistoryOps(
+          project_ops_length,
+          ops.length,
+          HistoryManager.FLUSH_PROJECT_EVERY_N_OPS
+        )
+      ) {
+        // Do this in the background since it uses HTTP and so may be too
+        // slow to wait for when processing a doc update.
+        logger.log(
+          { project_ops_length, project_id },
+          'flushing project history api'
+        )
+        HistoryManager.flushProjectChangesAsync(project_id)
+      }
+    }
 
-		// if the doc_ops_length is undefined it means the project is not using track-changes
-		// so we can bail out here
-		if (typeof(doc_ops_length) === 'undefined') {
-			logger.debug({ project_id, doc_id}, "skipping flush to track-changes, only using project-history");
-			return callback();
-		}
+    // if the doc_ops_length is undefined it means the project is not using track-changes
+    // so we can bail out here
+    if (typeof doc_ops_length === 'undefined') {
+      logger.debug(
+        { project_id, doc_id },
+        'skipping flush to track-changes, only using project-history'
+      )
+      return callback()
+    }
 
-		// record updates for track-changes
-		return HistoryRedisManager.recordDocHasHistoryOps(project_id, doc_id, ops, function(error) {
-			if (error != null) { return callback(error); }
-			if (HistoryManager.shouldFlushHistoryOps(doc_ops_length, ops.length, HistoryManager.FLUSH_DOC_EVERY_N_OPS)) {
-				// Do this in the background since it uses HTTP and so may be too
-				// slow to wait for when processing a doc update.
-				logger.log({ doc_ops_length, doc_id, project_id }, "flushing track changes api");
-				HistoryManager.flushDocChangesAsync(project_id, doc_id);
-			}
-			return callback();
-		});
-	},
+    // record updates for track-changes
+    return HistoryRedisManager.recordDocHasHistoryOps(
+      project_id,
+      doc_id,
+      ops,
+      function (error) {
+        if (error != null) {
+          return callback(error)
+        }
+        if (
+          HistoryManager.shouldFlushHistoryOps(
+            doc_ops_length,
+            ops.length,
+            HistoryManager.FLUSH_DOC_EVERY_N_OPS
+          )
+        ) {
+          // Do this in the background since it uses HTTP and so may be too
+          // slow to wait for when processing a doc update.
+          logger.log(
+            { doc_ops_length, doc_id, project_id },
+            'flushing track changes api'
+          )
+          HistoryManager.flushDocChangesAsync(project_id, doc_id)
+        }
+        return callback()
+      }
+    )
+  },
 
-	shouldFlushHistoryOps(length, ops_length, threshold) {
-		if (!length) { return false; } // don't flush unless we know the length
-		// We want to flush every 100 ops, i.e. 100, 200, 300, etc
-		// Find out which 'block' (i.e. 0-99, 100-199) we were in before and after pushing these
-		// ops. If we've changed, then we've gone over a multiple of 100 and should flush.
-		// (Most of the time, we will only hit 100 and then flushing will put us back to 0)
-		const previousLength = length - ops_length;
-		const prevBlock = Math.floor(previousLength / threshold);
-		const newBlock  = Math.floor(length / threshold);
-		return newBlock !== prevBlock;
-	},
+  shouldFlushHistoryOps(length, ops_length, threshold) {
+    if (!length) {
+      return false
+    } // don't flush unless we know the length
+    // We want to flush every 100 ops, i.e. 100, 200, 300, etc
+    // Find out which 'block' (i.e. 0-99, 100-199) we were in before and after pushing these
+    // ops. If we've changed, then we've gone over a multiple of 100 and should flush.
+    // (Most of the time, we will only hit 100 and then flushing will put us back to 0)
+    const previousLength = length - ops_length
+    const prevBlock = Math.floor(previousLength / threshold)
+    const newBlock = Math.floor(length / threshold)
+    return newBlock !== prevBlock
+  },
 
-	MAX_PARALLEL_REQUESTS: 4,
+  MAX_PARALLEL_REQUESTS: 4,
 
-	resyncProjectHistory(project_id, projectHistoryId, docs, files, callback) {
-		return ProjectHistoryRedisManager.queueResyncProjectStructure(project_id, projectHistoryId, docs, files, function(error) {
-			if (error != null) { return callback(error); }
-			const DocumentManager = require("./DocumentManager");
-			const resyncDoc = (doc, cb) => DocumentManager.resyncDocContentsWithLock(project_id, doc.doc, cb);
-			return async.eachLimit(docs, HistoryManager.MAX_PARALLEL_REQUESTS, resyncDoc, callback);
-		});
-	}
-});
+  resyncProjectHistory(project_id, projectHistoryId, docs, files, callback) {
+    return ProjectHistoryRedisManager.queueResyncProjectStructure(
+      project_id,
+      projectHistoryId,
+      docs,
+      files,
+      function (error) {
+        if (error != null) {
+          return callback(error)
+        }
+        const DocumentManager = require('./DocumentManager')
+        const resyncDoc = (doc, cb) =>
+          DocumentManager.resyncDocContentsWithLock(project_id, doc.doc, cb)
+        return async.eachLimit(
+          docs,
+          HistoryManager.MAX_PARALLEL_REQUESTS,
+          resyncDoc,
+          callback
+        )
+      }
+    )
+  }
+}
 
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 29 - 19
services/document-updater/app/js/HistoryRedisManager.js

@@ -11,23 +11,33 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let HistoryRedisManager;
-const Settings = require('settings-sharelatex');
-const rclient = require("redis-sharelatex").createClient(Settings.redis.history);
-const Keys = Settings.redis.history.key_schema;
-const logger = require('logger-sharelatex');
+let HistoryRedisManager
+const Settings = require('settings-sharelatex')
+const rclient = require('redis-sharelatex').createClient(Settings.redis.history)
+const Keys = Settings.redis.history.key_schema
+const logger = require('logger-sharelatex')
 
-module.exports = (HistoryRedisManager = {
-	recordDocHasHistoryOps(project_id, doc_id, ops, callback) {
-		if (ops == null) { ops = []; }
-		if (callback == null) { callback = function(error) {}; }
-		if (ops.length === 0) {
-			return callback(new Error("cannot push no ops")); // This should never be called with no ops, but protect against a redis error if we sent an empty array to rpush
-		}
-		logger.log({project_id, doc_id}, "marking doc in project for history ops");
-		return rclient.sadd(Keys.docsWithHistoryOps({project_id}), doc_id, function(error) {
-			if (error != null) { return callback(error); }
-			return callback();
-		});
-	}
-});
+module.exports = HistoryRedisManager = {
+  recordDocHasHistoryOps(project_id, doc_id, ops, callback) {
+    if (ops == null) {
+      ops = []
+    }
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    if (ops.length === 0) {
+      return callback(new Error('cannot push no ops')) // This should never be called with no ops, but protect against a redis error if we sent an empty array to rpush
+    }
+    logger.log({ project_id, doc_id }, 'marking doc in project for history ops')
+    return rclient.sadd(
+      Keys.docsWithHistoryOps({ project_id }),
+      doc_id,
+      function (error) {
+        if (error != null) {
+          return callback(error)
+        }
+        return callback()
+      }
+    )
+  }
+}

+ 443 - 304
services/document-updater/app/js/HttpController.js

@@ -12,331 +12,470 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let HttpController;
-const DocumentManager = require("./DocumentManager");
-const HistoryManager = require("./HistoryManager");
-const ProjectManager = require("./ProjectManager");
-const Errors = require("./Errors");
-const logger = require("logger-sharelatex");
-const Metrics = require("./Metrics");
-const ProjectFlusher = require("./ProjectFlusher");
-const DeleteQueueManager = require("./DeleteQueueManager");
-const async = require("async");
+let HttpController
+const DocumentManager = require('./DocumentManager')
+const HistoryManager = require('./HistoryManager')
+const ProjectManager = require('./ProjectManager')
+const Errors = require('./Errors')
+const logger = require('logger-sharelatex')
+const Metrics = require('./Metrics')
+const ProjectFlusher = require('./ProjectFlusher')
+const DeleteQueueManager = require('./DeleteQueueManager')
+const async = require('async')
 
-const TWO_MEGABYTES = 2 * 1024 * 1024;
+const TWO_MEGABYTES = 2 * 1024 * 1024
 
-module.exports = (HttpController = {
-	getDoc(req, res, next) {
-		let fromVersion;
-		if (next == null) { next = function(error) {}; }
-		const {
-            doc_id
-        } = req.params;
-		const {
-            project_id
-        } = req.params;
-		logger.log({project_id, doc_id}, "getting doc via http");
-		const timer = new Metrics.Timer("http.getDoc");
+module.exports = HttpController = {
+  getDoc(req, res, next) {
+    let fromVersion
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { doc_id } = req.params
+    const { project_id } = req.params
+    logger.log({ project_id, doc_id }, 'getting doc via http')
+    const timer = new Metrics.Timer('http.getDoc')
 
-		if ((req.query != null ? req.query.fromVersion : undefined) != null) {
-			fromVersion = parseInt(req.query.fromVersion, 10);
-		} else {
-			fromVersion = -1;
-		}
+    if ((req.query != null ? req.query.fromVersion : undefined) != null) {
+      fromVersion = parseInt(req.query.fromVersion, 10)
+    } else {
+      fromVersion = -1
+    }
 
-		return DocumentManager.getDocAndRecentOpsWithLock(project_id, doc_id, fromVersion, function(error, lines, version, ops, ranges, pathname) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id, doc_id}, "got doc via http");
-			if ((lines == null) || (version == null)) {
-				return next(new Errors.NotFoundError("document not found"));
-			}
-			return res.json({
-				id: doc_id,
-				lines,
-				version,
-				ops,
-				ranges,
-				pathname
-			});
-		});
-	},
+    return DocumentManager.getDocAndRecentOpsWithLock(
+      project_id,
+      doc_id,
+      fromVersion,
+      function (error, lines, version, ops, ranges, pathname) {
+        timer.done()
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id, doc_id }, 'got doc via http')
+        if (lines == null || version == null) {
+          return next(new Errors.NotFoundError('document not found'))
+        }
+        return res.json({
+          id: doc_id,
+          lines,
+          version,
+          ops,
+          ranges,
+          pathname
+        })
+      }
+    )
+  },
 
-	_getTotalSizeOfLines(lines) {
-		let size = 0;
-		for (const line of Array.from(lines)) {
-			size += (line.length + 1);
-		}
-		return size;
-	},
+  _getTotalSizeOfLines(lines) {
+    let size = 0
+    for (const line of Array.from(lines)) {
+      size += line.length + 1
+    }
+    return size
+  },
 
-	getProjectDocsAndFlushIfOld(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            project_id
-        } = req.params;
-		const projectStateHash = req.query != null ? req.query.state : undefined;
-		// exclude is string of existing docs "id:version,id:version,..."
-		const excludeItems = __guard__(req.query != null ? req.query.exclude : undefined, x => x.split(',')) || [];
-		logger.log({project_id, exclude: excludeItems}, "getting docs via http");
-		const timer = new Metrics.Timer("http.getAllDocs");
-		const excludeVersions = {};
-		for (const item of Array.from(excludeItems)) {
-			const [id,version] = Array.from(item != null ? item.split(':') : undefined);
-			excludeVersions[id] = version;
-		}
-		logger.log({project_id, projectStateHash, excludeVersions}, "excluding versions");
-		return ProjectManager.getProjectDocsAndFlushIfOld(project_id, projectStateHash, excludeVersions, function(error, result) {
-			timer.done();
-			if (error instanceof Errors.ProjectStateChangedError) {
-				return res.sendStatus(409); // conflict
-			} else if (error != null) {
-				return next(error);
-			} else {
-				logger.log({project_id, result: ((Array.from(result).map((doc) => `${doc._id}:${doc.v}`)))}, "got docs via http");
-				return res.send(result);
-			}
-		});
-	},
+  getProjectDocsAndFlushIfOld(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id } = req.params
+    const projectStateHash = req.query != null ? req.query.state : undefined
+    // exclude is string of existing docs "id:version,id:version,..."
+    const excludeItems =
+      __guard__(req.query != null ? req.query.exclude : undefined, (x) =>
+        x.split(',')
+      ) || []
+    logger.log({ project_id, exclude: excludeItems }, 'getting docs via http')
+    const timer = new Metrics.Timer('http.getAllDocs')
+    const excludeVersions = {}
+    for (const item of Array.from(excludeItems)) {
+      const [id, version] = Array.from(
+        item != null ? item.split(':') : undefined
+      )
+      excludeVersions[id] = version
+    }
+    logger.log(
+      { project_id, projectStateHash, excludeVersions },
+      'excluding versions'
+    )
+    return ProjectManager.getProjectDocsAndFlushIfOld(
+      project_id,
+      projectStateHash,
+      excludeVersions,
+      function (error, result) {
+        timer.done()
+        if (error instanceof Errors.ProjectStateChangedError) {
+          return res.sendStatus(409) // conflict
+        } else if (error != null) {
+          return next(error)
+        } else {
+          logger.log(
+            {
+              project_id,
+              result: Array.from(result).map((doc) => `${doc._id}:${doc.v}`)
+            },
+            'got docs via http'
+          )
+          return res.send(result)
+        }
+      }
+    )
+  },
 
-	clearProjectState(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            project_id
-        } = req.params;
-		const timer = new Metrics.Timer("http.clearProjectState");
-		logger.log({project_id}, "clearing project state via http");
-		return ProjectManager.clearProjectState(project_id, function(error) {
-			timer.done();
-			if (error != null) {
-				return next(error);
-			} else {
-				return res.sendStatus(200);
-			}
-		});
-	},
+  clearProjectState(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id } = req.params
+    const timer = new Metrics.Timer('http.clearProjectState')
+    logger.log({ project_id }, 'clearing project state via http')
+    return ProjectManager.clearProjectState(project_id, function (error) {
+      timer.done()
+      if (error != null) {
+        return next(error)
+      } else {
+        return res.sendStatus(200)
+      }
+    })
+  },
 
-	setDoc(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            doc_id
-        } = req.params;
-		const {
-            project_id
-        } = req.params;
-		const {lines, source, user_id, undoing} = req.body;
-		const lineSize = HttpController._getTotalSizeOfLines(lines);
-		if (lineSize > TWO_MEGABYTES) {
-			logger.log({project_id, doc_id, source, lineSize, user_id}, "document too large, returning 406 response");
-			return res.sendStatus(406);
-		}
-		logger.log({project_id, doc_id, lines, source, user_id, undoing}, "setting doc via http");
-		const timer = new Metrics.Timer("http.setDoc");
-		return DocumentManager.setDocWithLock(project_id, doc_id, lines, source, user_id, undoing, function(error) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id, doc_id}, "set doc via http");
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  setDoc(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { doc_id } = req.params
+    const { project_id } = req.params
+    const { lines, source, user_id, undoing } = req.body
+    const lineSize = HttpController._getTotalSizeOfLines(lines)
+    if (lineSize > TWO_MEGABYTES) {
+      logger.log(
+        { project_id, doc_id, source, lineSize, user_id },
+        'document too large, returning 406 response'
+      )
+      return res.sendStatus(406)
+    }
+    logger.log(
+      { project_id, doc_id, lines, source, user_id, undoing },
+      'setting doc via http'
+    )
+    const timer = new Metrics.Timer('http.setDoc')
+    return DocumentManager.setDocWithLock(
+      project_id,
+      doc_id,
+      lines,
+      source,
+      user_id,
+      undoing,
+      function (error) {
+        timer.done()
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id, doc_id }, 'set doc via http')
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
+  flushDocIfLoaded(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { doc_id } = req.params
+    const { project_id } = req.params
+    logger.log({ project_id, doc_id }, 'flushing doc via http')
+    const timer = new Metrics.Timer('http.flushDoc')
+    return DocumentManager.flushDocIfLoadedWithLock(
+      project_id,
+      doc_id,
+      function (error) {
+        timer.done()
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id, doc_id }, 'flushed doc via http')
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
-	flushDocIfLoaded(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            doc_id
-        } = req.params;
-		const {
-            project_id
-        } = req.params;
-		logger.log({project_id, doc_id}, "flushing doc via http");
-		const timer = new Metrics.Timer("http.flushDoc");
-		return DocumentManager.flushDocIfLoadedWithLock(project_id, doc_id, function(error) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id, doc_id}, "flushed doc via http");
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  deleteDoc(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { doc_id } = req.params
+    const { project_id } = req.params
+    const ignoreFlushErrors = req.query.ignore_flush_errors === 'true'
+    const timer = new Metrics.Timer('http.deleteDoc')
+    logger.log({ project_id, doc_id }, 'deleting doc via http')
+    return DocumentManager.flushAndDeleteDocWithLock(
+      project_id,
+      doc_id,
+      { ignoreFlushErrors },
+      function (error) {
+        timer.done()
+        // There is no harm in flushing project history if the previous call
+        // failed and sometimes it is required
+        HistoryManager.flushProjectChangesAsync(project_id)
 
-	deleteDoc(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            doc_id
-        } = req.params;
-		const {
-            project_id
-        } = req.params;
-		const ignoreFlushErrors = req.query.ignore_flush_errors === 'true';
-		const timer = new Metrics.Timer("http.deleteDoc");
-		logger.log({project_id, doc_id}, "deleting doc via http");
-		return DocumentManager.flushAndDeleteDocWithLock(project_id, doc_id, { ignoreFlushErrors }, function(error) {
-			timer.done();
-			// There is no harm in flushing project history if the previous call
-			// failed and sometimes it is required
-			HistoryManager.flushProjectChangesAsync(project_id);
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id, doc_id }, 'deleted doc via http')
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
-			if (error != null) { return next(error); }
-			logger.log({project_id, doc_id}, "deleted doc via http");
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  flushProject(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id } = req.params
+    logger.log({ project_id }, 'flushing project via http')
+    const timer = new Metrics.Timer('http.flushProject')
+    return ProjectManager.flushProjectWithLocks(project_id, function (error) {
+      timer.done()
+      if (error != null) {
+        return next(error)
+      }
+      logger.log({ project_id }, 'flushed project via http')
+      return res.sendStatus(204)
+    })
+  }, // No Content
 
-	flushProject(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            project_id
-        } = req.params;
-		logger.log({project_id}, "flushing project via http");
-		const timer = new Metrics.Timer("http.flushProject");
-		return ProjectManager.flushProjectWithLocks(project_id, function(error) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id}, "flushed project via http");
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  deleteProject(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id } = req.params
+    logger.log({ project_id }, 'deleting project via http')
+    const options = {}
+    if (req.query != null ? req.query.background : undefined) {
+      options.background = true
+    } // allow non-urgent flushes to be queued
+    if (req.query != null ? req.query.shutdown : undefined) {
+      options.skip_history_flush = true
+    } // don't flush history when realtime shuts down
+    if (req.query != null ? req.query.background : undefined) {
+      return ProjectManager.queueFlushAndDeleteProject(project_id, function (
+        error
+      ) {
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id }, 'queue delete of project via http')
+        return res.sendStatus(204)
+      }) // No Content
+    } else {
+      const timer = new Metrics.Timer('http.deleteProject')
+      return ProjectManager.flushAndDeleteProjectWithLocks(
+        project_id,
+        options,
+        function (error) {
+          timer.done()
+          if (error != null) {
+            return next(error)
+          }
+          logger.log({ project_id }, 'deleted project via http')
+          return res.sendStatus(204)
+        }
+      )
+    }
+  }, // No Content
 
-	deleteProject(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            project_id
-        } = req.params;
-		logger.log({project_id}, "deleting project via http");
-		const options = {};
-		if (req.query != null ? req.query.background : undefined) { options.background = true; } // allow non-urgent flushes to be queued
-		if (req.query != null ? req.query.shutdown : undefined) { options.skip_history_flush = true; } // don't flush history when realtime shuts down
-		if (req.query != null ? req.query.background : undefined) {
-			return ProjectManager.queueFlushAndDeleteProject(project_id, function(error) {
-				if (error != null) { return next(error); }
-				logger.log({project_id}, "queue delete of project via http");
-				return res.sendStatus(204);
-			}); // No Content
-		} else {
-			const timer = new Metrics.Timer("http.deleteProject");
-			return ProjectManager.flushAndDeleteProjectWithLocks(project_id, options, function(error) {
-				timer.done();
-				if (error != null) { return next(error); }
-				logger.log({project_id}, "deleted project via http");
-				return res.sendStatus(204);
-			});
-		}
-	}, // No Content
+  deleteMultipleProjects(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const project_ids =
+      (req.body != null ? req.body.project_ids : undefined) || []
+    logger.log({ project_ids }, 'deleting multiple projects via http')
+    return async.eachSeries(
+      project_ids,
+      function (project_id, cb) {
+        logger.log({ project_id }, 'queue delete of project via http')
+        return ProjectManager.queueFlushAndDeleteProject(project_id, cb)
+      },
+      function (error) {
+        if (error != null) {
+          return next(error)
+        }
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
-	deleteMultipleProjects(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const project_ids = (req.body != null ? req.body.project_ids : undefined) || [];
-		logger.log({project_ids}, "deleting multiple projects via http");
-		return async.eachSeries(project_ids, function(project_id, cb) {
-			logger.log({project_id}, "queue delete of project via http");
-			return ProjectManager.queueFlushAndDeleteProject(project_id, cb);
-		}
-		, function(error) {
-			if (error != null) { return next(error); }
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  acceptChanges(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id, doc_id } = req.params
+    let change_ids = req.body != null ? req.body.change_ids : undefined
+    if (change_ids == null) {
+      change_ids = [req.params.change_id]
+    }
+    logger.log(
+      { project_id, doc_id },
+      `accepting ${change_ids.length} changes via http`
+    )
+    const timer = new Metrics.Timer('http.acceptChanges')
+    return DocumentManager.acceptChangesWithLock(
+      project_id,
+      doc_id,
+      change_ids,
+      function (error) {
+        timer.done()
+        if (error != null) {
+          return next(error)
+        }
+        logger.log(
+          { project_id, doc_id },
+          `accepted ${change_ids.length} changes via http`
+        )
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
-	acceptChanges(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {project_id, doc_id} = req.params;
-		let change_ids = req.body != null ? req.body.change_ids : undefined;
-		if ((change_ids == null)) {
-			change_ids = [ req.params.change_id ];
-		}
-		logger.log({project_id, doc_id}, `accepting ${ change_ids.length } changes via http`);
-		const timer = new Metrics.Timer("http.acceptChanges");
-		return DocumentManager.acceptChangesWithLock(project_id, doc_id, change_ids, function(error) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id, doc_id}, `accepted ${ change_ids.length } changes via http`);
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  deleteComment(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id, doc_id, comment_id } = req.params
+    logger.log({ project_id, doc_id, comment_id }, 'deleting comment via http')
+    const timer = new Metrics.Timer('http.deleteComment')
+    return DocumentManager.deleteCommentWithLock(
+      project_id,
+      doc_id,
+      comment_id,
+      function (error) {
+        timer.done()
+        if (error != null) {
+          return next(error)
+        }
+        logger.log(
+          { project_id, doc_id, comment_id },
+          'deleted comment via http'
+        )
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
-	deleteComment(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {project_id, doc_id, comment_id} = req.params;
-		logger.log({project_id, doc_id, comment_id}, "deleting comment via http");
-		const timer = new Metrics.Timer("http.deleteComment");
-		return DocumentManager.deleteCommentWithLock(project_id, doc_id, comment_id, function(error) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id, doc_id, comment_id}, "deleted comment via http");
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  updateProject(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const timer = new Metrics.Timer('http.updateProject')
+    const { project_id } = req.params
+    const {
+      projectHistoryId,
+      userId,
+      docUpdates,
+      fileUpdates,
+      version
+    } = req.body
+    logger.log(
+      { project_id, docUpdates, fileUpdates, version },
+      'updating project via http'
+    )
 
-	updateProject(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const timer = new Metrics.Timer("http.updateProject");
-		const {
-            project_id
-        } = req.params;
-		const {projectHistoryId, userId, docUpdates, fileUpdates, version} = req.body;
-		logger.log({project_id, docUpdates, fileUpdates, version}, "updating project via http");
+    return ProjectManager.updateProjectWithLocks(
+      project_id,
+      projectHistoryId,
+      userId,
+      docUpdates,
+      fileUpdates,
+      version,
+      function (error) {
+        timer.done()
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id }, 'updated project via http')
+        return res.sendStatus(204)
+      }
+    )
+  }, // No Content
 
-		return ProjectManager.updateProjectWithLocks(project_id, projectHistoryId, userId, docUpdates, fileUpdates, version, function(error) {
-			timer.done();
-			if (error != null) { return next(error); }
-			logger.log({project_id}, "updated project via http");
-			return res.sendStatus(204);
-		});
-	}, // No Content
+  resyncProjectHistory(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    const { project_id } = req.params
+    const { projectHistoryId, docs, files } = req.body
 
-	resyncProjectHistory(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		const {
-            project_id
-        } = req.params;
-		const {projectHistoryId, docs, files} = req.body;
+    logger.log(
+      { project_id, docs, files },
+      'queuing project history resync via http'
+    )
+    return HistoryManager.resyncProjectHistory(
+      project_id,
+      projectHistoryId,
+      docs,
+      files,
+      function (error) {
+        if (error != null) {
+          return next(error)
+        }
+        logger.log({ project_id }, 'queued project history resync via http')
+        return res.sendStatus(204)
+      }
+    )
+  },
 
-		logger.log({project_id, docs, files}, "queuing project history resync via http");
-		return HistoryManager.resyncProjectHistory(project_id, projectHistoryId, docs, files, function(error) {
-			if (error != null) { return next(error); }
-			logger.log({project_id}, "queued project history resync via http");
-			return res.sendStatus(204);
-		});
-	},
+  flushAllProjects(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    res.setTimeout(5 * 60 * 1000)
+    const options = {
+      limit: req.query.limit || 1000,
+      concurrency: req.query.concurrency || 5,
+      dryRun: req.query.dryRun || false
+    }
+    return ProjectFlusher.flushAllProjects(options, function (
+      err,
+      project_ids
+    ) {
+      if (err != null) {
+        logger.err({ err }, 'error bulk flushing projects')
+        return res.sendStatus(500)
+      } else {
+        return res.send(project_ids)
+      }
+    })
+  },
 
-	flushAllProjects(req, res, next ){
-		if (next == null) { next = function(error){}; }
-		res.setTimeout(5 * 60 * 1000);
-		const options = {
-			limit : req.query.limit || 1000,
-			concurrency : req.query.concurrency || 5,
-			dryRun : req.query.dryRun || false
-		};
-		return ProjectFlusher.flushAllProjects(options, function(err, project_ids){
-			if (err != null) {
-				logger.err({err}, "error bulk flushing projects");
-				return res.sendStatus(500);
-			} else {
-				return res.send(project_ids);
-			}
-		});
-	},
-
-	flushQueuedProjects(req, res, next) {
-		if (next == null) { next = function(error) {}; }
-		res.setTimeout(10 * 60 * 1000);
-		const options = {
-			limit : req.query.limit || 1000,
-			timeout: 5 * 60 * 1000,
-			min_delete_age: req.query.min_delete_age || (5 * 60 * 1000)
-		};
-		return DeleteQueueManager.flushAndDeleteOldProjects(options, function(err, flushed){
-			if (err != null) {
-				logger.err({err}, "error flushing old projects");
-				return res.sendStatus(500);
-			} else {
-				logger.log({flushed}, "flush of queued projects completed");
-				return res.send({flushed});
-			}
-	});
-	}
-});
+  flushQueuedProjects(req, res, next) {
+    if (next == null) {
+      next = function (error) {}
+    }
+    res.setTimeout(10 * 60 * 1000)
+    const options = {
+      limit: req.query.limit || 1000,
+      timeout: 5 * 60 * 1000,
+      min_delete_age: req.query.min_delete_age || 5 * 60 * 1000
+    }
+    return DeleteQueueManager.flushAndDeleteOldProjects(options, function (
+      err,
+      flushed
+    ) {
+      if (err != null) {
+        logger.err({ err }, 'error flushing old projects')
+        return res.sendStatus(500)
+      } else {
+        logger.log({ flushed }, 'flush of queued projects completed')
+        return res.send({ flushed })
+      }
+    })
+  }
+}
 
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 145 - 114
services/document-updater/app/js/LockManager.js

@@ -11,128 +11,159 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let LockManager;
-const metrics = require('./Metrics');
-const Settings = require('settings-sharelatex');
-const redis = require("redis-sharelatex");
-const rclient = redis.createClient(Settings.redis.lock);
-const keys = Settings.redis.lock.key_schema;
-const logger = require("logger-sharelatex");
-const os = require("os");
-const crypto = require("crypto");
+let LockManager
+const metrics = require('./Metrics')
+const Settings = require('settings-sharelatex')
+const redis = require('redis-sharelatex')
+const rclient = redis.createClient(Settings.redis.lock)
+const keys = Settings.redis.lock.key_schema
+const logger = require('logger-sharelatex')
+const os = require('os')
+const crypto = require('crypto')
 
-const Profiler = require("./Profiler");
+const Profiler = require('./Profiler')
 
-const HOST = os.hostname();
-const PID = process.pid;
-const RND = crypto.randomBytes(4).toString('hex');
-let COUNT = 0;
+const HOST = os.hostname()
+const PID = process.pid
+const RND = crypto.randomBytes(4).toString('hex')
+let COUNT = 0
 
-const MAX_REDIS_REQUEST_LENGTH = 5000; // 5 seconds
+const MAX_REDIS_REQUEST_LENGTH = 5000 // 5 seconds
 
-module.exports = (LockManager = {
-	LOCK_TEST_INTERVAL: 50, // 50ms between each test of the lock
-	MAX_TEST_INTERVAL: 1000, // back off to 1s between each test of the lock
-	MAX_LOCK_WAIT_TIME: 10000, // 10s maximum time to spend trying to get the lock
-	LOCK_TTL: 30, // seconds. Time until lock auto expires in redis.
+module.exports = LockManager = {
+  LOCK_TEST_INTERVAL: 50, // 50ms between each test of the lock
+  MAX_TEST_INTERVAL: 1000, // back off to 1s between each test of the lock
+  MAX_LOCK_WAIT_TIME: 10000, // 10s maximum time to spend trying to get the lock
+  LOCK_TTL: 30, // seconds. Time until lock auto expires in redis.
 
-	// Use a signed lock value as described in
-	// http://redis.io/topics/distlock#correct-implementation-with-a-single-instance
-	// to prevent accidental unlocking by multiple processes
-	randomLock() {
-		const time = Date.now();
-		return `locked:host=${HOST}:pid=${PID}:random=${RND}:time=${time}:count=${COUNT++}`;
-	},
+  // Use a signed lock value as described in
+  // http://redis.io/topics/distlock#correct-implementation-with-a-single-instance
+  // to prevent accidental unlocking by multiple processes
+  randomLock() {
+    const time = Date.now()
+    return `locked:host=${HOST}:pid=${PID}:random=${RND}:time=${time}:count=${COUNT++}`
+  },
 
-	unlockScript: 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
+  unlockScript:
+    'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
 
-	tryLock(doc_id, callback){
-		if (callback == null) { callback = function(err, isFree){}; }
-		const lockValue = LockManager.randomLock();
-		const key = keys.blockingKey({doc_id});
-		const profile = new Profiler("tryLock", {doc_id, key, lockValue});
-		return rclient.set(key, lockValue, "EX", this.LOCK_TTL, "NX", function(err, gotLock){
-			if (err != null) { return callback(err); }
-			if (gotLock === "OK") {
-				metrics.inc("doc-not-blocking");
-				const timeTaken = profile.log("got lock").end();
-				if (timeTaken > MAX_REDIS_REQUEST_LENGTH) {
-					// took too long, so try to free the lock
-					return LockManager.releaseLock(doc_id, lockValue, function(err, result) {
-						if (err != null) { return callback(err); } // error freeing lock
-						return callback(null, false);
-					}); // tell caller they didn't get the lock
-				} else {
-					return callback(null, true, lockValue);
-				}
-			} else {
-				metrics.inc("doc-blocking");
-				profile.log("doc is locked").end();
-				return callback(null, false);
-			}
-		});
-	},
+  tryLock(doc_id, callback) {
+    if (callback == null) {
+      callback = function (err, isFree) {}
+    }
+    const lockValue = LockManager.randomLock()
+    const key = keys.blockingKey({ doc_id })
+    const profile = new Profiler('tryLock', { doc_id, key, lockValue })
+    return rclient.set(key, lockValue, 'EX', this.LOCK_TTL, 'NX', function (
+      err,
+      gotLock
+    ) {
+      if (err != null) {
+        return callback(err)
+      }
+      if (gotLock === 'OK') {
+        metrics.inc('doc-not-blocking')
+        const timeTaken = profile.log('got lock').end()
+        if (timeTaken > MAX_REDIS_REQUEST_LENGTH) {
+          // took too long, so try to free the lock
+          return LockManager.releaseLock(doc_id, lockValue, function (
+            err,
+            result
+          ) {
+            if (err != null) {
+              return callback(err)
+            } // error freeing lock
+            return callback(null, false)
+          }) // tell caller they didn't get the lock
+        } else {
+          return callback(null, true, lockValue)
+        }
+      } else {
+        metrics.inc('doc-blocking')
+        profile.log('doc is locked').end()
+        return callback(null, false)
+      }
+    })
+  },
 
-	getLock(doc_id, callback) {
-		let attempt;
-		if (callback == null) { callback = function(error, lockValue) {}; }
-		const startTime = Date.now();
-		let testInterval = LockManager.LOCK_TEST_INTERVAL;
-		const profile = new Profiler("getLock", {doc_id});
-		return (attempt = function() {
-			if ((Date.now() - startTime) > LockManager.MAX_LOCK_WAIT_TIME) {
-				const e = new Error("Timeout");
-				e.doc_id = doc_id;
-				profile.log("timeout").end();
-				return callback(e);
-			}
+  getLock(doc_id, callback) {
+    let attempt
+    if (callback == null) {
+      callback = function (error, lockValue) {}
+    }
+    const startTime = Date.now()
+    let testInterval = LockManager.LOCK_TEST_INTERVAL
+    const profile = new Profiler('getLock', { doc_id })
+    return (attempt = function () {
+      if (Date.now() - startTime > LockManager.MAX_LOCK_WAIT_TIME) {
+        const e = new Error('Timeout')
+        e.doc_id = doc_id
+        profile.log('timeout').end()
+        return callback(e)
+      }
 
-			return LockManager.tryLock(doc_id, function(error, gotLock, lockValue) {
-				if (error != null) { return callback(error); }
-				profile.log("tryLock");
-				if (gotLock) {
-					profile.end();
-					return callback(null, lockValue);
-				} else {
-					setTimeout(attempt, testInterval);
-					// back off when the lock is taken to avoid overloading
-					return testInterval = Math.min(testInterval * 2, LockManager.MAX_TEST_INTERVAL);
-				}
-			});
-		})();
-	},
+      return LockManager.tryLock(doc_id, function (error, gotLock, lockValue) {
+        if (error != null) {
+          return callback(error)
+        }
+        profile.log('tryLock')
+        if (gotLock) {
+          profile.end()
+          return callback(null, lockValue)
+        } else {
+          setTimeout(attempt, testInterval)
+          // back off when the lock is taken to avoid overloading
+          return (testInterval = Math.min(
+            testInterval * 2,
+            LockManager.MAX_TEST_INTERVAL
+          ))
+        }
+      })
+    })()
+  },
 
-	checkLock(doc_id, callback){
-		if (callback == null) { callback = function(err, isFree){}; }
-		const key = keys.blockingKey({doc_id});
-		return rclient.exists(key, function(err, exists) {
-			if (err != null) { return callback(err); }
-			exists = parseInt(exists);
-			if (exists === 1) {
-				metrics.inc("doc-blocking");
-				return callback(null, false);
-			} else {
-				metrics.inc("doc-not-blocking");
-				return callback(null, true);
-			}
-		});
-	},
+  checkLock(doc_id, callback) {
+    if (callback == null) {
+      callback = function (err, isFree) {}
+    }
+    const key = keys.blockingKey({ doc_id })
+    return rclient.exists(key, function (err, exists) {
+      if (err != null) {
+        return callback(err)
+      }
+      exists = parseInt(exists)
+      if (exists === 1) {
+        metrics.inc('doc-blocking')
+        return callback(null, false)
+      } else {
+        metrics.inc('doc-not-blocking')
+        return callback(null, true)
+      }
+    })
+  },
 
-	releaseLock(doc_id, lockValue, callback){
-		const key = keys.blockingKey({doc_id});
-		const profile = new Profiler("releaseLock", {doc_id, key, lockValue});
-		return rclient.eval(LockManager.unlockScript, 1, key, lockValue, function(err, result) {
-			if (err != null) {
-				return callback(err);
-			} else if ((result != null) && (result !== 1)) { // successful unlock should release exactly one key
-				profile.log("unlockScript:expired-lock").end();
-				logger.error({doc_id, key, lockValue, redis_err:err, redis_result:result}, "unlocking error");
-				metrics.inc("unlock-error");
-				return callback(new Error("tried to release timed out lock"));
-			} else {
-				profile.log("unlockScript:ok").end();
-				return callback(null,result);
-			}
-		});
-	}
-});
+  releaseLock(doc_id, lockValue, callback) {
+    const key = keys.blockingKey({ doc_id })
+    const profile = new Profiler('releaseLock', { doc_id, key, lockValue })
+    return rclient.eval(LockManager.unlockScript, 1, key, lockValue, function (
+      err,
+      result
+    ) {
+      if (err != null) {
+        return callback(err)
+      } else if (result != null && result !== 1) {
+        // successful unlock should release exactly one key
+        profile.log('unlockScript:expired-lock').end()
+        logger.error(
+          { doc_id, key, lockValue, redis_err: err, redis_result: result },
+          'unlocking error'
+        )
+        metrics.inc('unlock-error')
+        return callback(new Error('tried to release timed out lock'))
+      } else {
+        profile.log('unlockScript:ok').end()
+        return callback(null, result)
+      }
+    })
+  }
+}

+ 49 - 28
services/document-updater/app/js/LoggerSerializers.js

@@ -10,37 +10,58 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const _ = require('lodash');
+const _ = require('lodash')
 
-const showLength = function(thing) {
-	if ((thing != null ? thing.length : undefined)) { return thing.length; } else { return thing; }
-};
+const showLength = function (thing) {
+  if (thing != null ? thing.length : undefined) {
+    return thing.length
+  } else {
+    return thing
+  }
+}
 
-const showUpdateLength = function(update) {
-	if ((update != null ? update.op : undefined) instanceof Array) {
-		const copy = _.cloneDeep(update);
-		copy.op.forEach(function(element, index) {
-			if (__guard__(element != null ? element.i : undefined, x => x.length) != null) { copy.op[index].i = element.i.length; }
-			if (__guard__(element != null ? element.d : undefined, x1 => x1.length) != null) { copy.op[index].d = element.d.length; }
-			if (__guard__(element != null ? element.c : undefined, x2 => x2.length) != null) { return copy.op[index].c = element.c.length; }
-		});
-		return copy;
-	} else {
-		return update;
-	}
-};
+const showUpdateLength = function (update) {
+  if ((update != null ? update.op : undefined) instanceof Array) {
+    const copy = _.cloneDeep(update)
+    copy.op.forEach(function (element, index) {
+      if (
+        __guard__(element != null ? element.i : undefined, (x) => x.length) !=
+        null
+      ) {
+        copy.op[index].i = element.i.length
+      }
+      if (
+        __guard__(element != null ? element.d : undefined, (x1) => x1.length) !=
+        null
+      ) {
+        copy.op[index].d = element.d.length
+      }
+      if (
+        __guard__(element != null ? element.c : undefined, (x2) => x2.length) !=
+        null
+      ) {
+        return (copy.op[index].c = element.c.length)
+      }
+    })
+    return copy
+  } else {
+    return update
+  }
+}
 
 module.exports = {
-	// replace long values with their length
-	lines: showLength,
-	oldLines: showLength,
-	newLines: showLength,
-	docLines: showLength,
-	newDocLines: showLength,
-	ranges: showLength,
-	update: showUpdateLength
-};
+  // replace long values with their length
+  lines: showLength,
+  oldLines: showLength,
+  newLines: showLength,
+  docLines: showLength,
+  newDocLines: showLength,
+  ranges: showLength,
+  update: showUpdateLength
+}
 
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 1 - 1
services/document-updater/app/js/Metrics.js

@@ -1,3 +1,3 @@
 // TODO: This file was created by bulk-decaffeinate.
 // Sanity-check the conversion and remove this comment.
-module.exports = require("metrics-sharelatex");
+module.exports = require('metrics-sharelatex')

+ 163 - 117
services/document-updater/app/js/PersistenceManager.js

@@ -14,129 +14,175 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let PersistenceManager;
-const Settings = require("settings-sharelatex");
-const Errors = require("./Errors");
-const Metrics = require("./Metrics");
-const logger = require("logger-sharelatex");
-const request = (require("requestretry")).defaults({
-	maxAttempts: 2,
-	retryDelay: 10
-});
+let PersistenceManager
+const Settings = require('settings-sharelatex')
+const Errors = require('./Errors')
+const Metrics = require('./Metrics')
+const logger = require('logger-sharelatex')
+const request = require('requestretry').defaults({
+  maxAttempts: 2,
+  retryDelay: 10
+})
 
 // We have to be quick with HTTP calls because we're holding a lock that
 // expires after 30 seconds. We can't let any errors in the rest of the stack
 // hold us up, and need to bail out quickly if there is a problem.
-const MAX_HTTP_REQUEST_LENGTH = 5000; // 5 seconds
+const MAX_HTTP_REQUEST_LENGTH = 5000 // 5 seconds
 
-const updateMetric = function(method, error, response) {
-	// find the status, with special handling for connection timeouts
-	// https://github.com/request/request#timeouts
-	const status = (() => {
-		if ((error != null ? error.connect : undefined) === true) {
-			return `${error.code} (connect)`;
-		} else if (error != null) {
-			return error.code;
-		} else if (response != null) {
-			return response.statusCode;
-		}
-	})();
-	Metrics.inc(method, 1, {status});
-	if ((error != null ? error.attempts : undefined) > 1) {
-		Metrics.inc(`${method}-retries`, 1, {status: 'error'});
-	}
-	if ((response != null ? response.attempts : undefined) > 1) {
-		return Metrics.inc(`${method}-retries`, 1, {status: 'success'});
-	}
-};
+const updateMetric = function (method, error, response) {
+  // find the status, with special handling for connection timeouts
+  // https://github.com/request/request#timeouts
+  const status = (() => {
+    if ((error != null ? error.connect : undefined) === true) {
+      return `${error.code} (connect)`
+    } else if (error != null) {
+      return error.code
+    } else if (response != null) {
+      return response.statusCode
+    }
+  })()
+  Metrics.inc(method, 1, { status })
+  if ((error != null ? error.attempts : undefined) > 1) {
+    Metrics.inc(`${method}-retries`, 1, { status: 'error' })
+  }
+  if ((response != null ? response.attempts : undefined) > 1) {
+    return Metrics.inc(`${method}-retries`, 1, { status: 'success' })
+  }
+}
 
-module.exports = (PersistenceManager = {
-	getDoc(project_id, doc_id, _callback) {
-		if (_callback == null) { _callback = function(error, lines, version, ranges, pathname, projectHistoryId, projectHistoryType) {}; }
-		const timer = new Metrics.Timer("persistenceManager.getDoc");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
+module.exports = PersistenceManager = {
+  getDoc(project_id, doc_id, _callback) {
+    if (_callback == null) {
+      _callback = function (
+        error,
+        lines,
+        version,
+        ranges,
+        pathname,
+        projectHistoryId,
+        projectHistoryType
+      ) {}
+    }
+    const timer = new Metrics.Timer('persistenceManager.getDoc')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
 
-		const url = `${Settings.apis.web.url}/project/${project_id}/doc/${doc_id}`;
-		return request({
-			url,
-			method: "GET",
-			headers: {
-				"accept": "application/json"
-			},
-			auth: {
-				user: Settings.apis.web.user,
-				pass: Settings.apis.web.pass,
-				sendImmediately: true
-			},
-			jar: false,
-			timeout: MAX_HTTP_REQUEST_LENGTH
-		}, function(error, res, body) {
-			updateMetric('getDoc', error, res);
-			if (error != null) { return callback(error); }
-			if ((res.statusCode >= 200) && (res.statusCode < 300)) {
-				try {
-					body = JSON.parse(body);
-				} catch (e) {
-					return callback(e);
-				}
-				if ((body.lines == null)) {
-					return callback(new Error("web API response had no doc lines"));
-				}
-				if ((body.version == null) || !body.version instanceof Number) {
-					return callback(new Error("web API response had no valid doc version"));
-				}
-				if ((body.pathname == null)) {
-					return callback(new Error("web API response had no valid doc pathname"));
-				}
-				return callback(null, body.lines, body.version, body.ranges, body.pathname, body.projectHistoryId, body.projectHistoryType);
-			} else if (res.statusCode === 404) {
-				return callback(new Errors.NotFoundError(`doc not not found: ${url}`));
-			} else {
-				return callback(new Error(`error accessing web API: ${url} ${res.statusCode}`));
-			}
-		});
-	},
+    const url = `${Settings.apis.web.url}/project/${project_id}/doc/${doc_id}`
+    return request(
+      {
+        url,
+        method: 'GET',
+        headers: {
+          accept: 'application/json'
+        },
+        auth: {
+          user: Settings.apis.web.user,
+          pass: Settings.apis.web.pass,
+          sendImmediately: true
+        },
+        jar: false,
+        timeout: MAX_HTTP_REQUEST_LENGTH
+      },
+      function (error, res, body) {
+        updateMetric('getDoc', error, res)
+        if (error != null) {
+          return callback(error)
+        }
+        if (res.statusCode >= 200 && res.statusCode < 300) {
+          try {
+            body = JSON.parse(body)
+          } catch (e) {
+            return callback(e)
+          }
+          if (body.lines == null) {
+            return callback(new Error('web API response had no doc lines'))
+          }
+          if (body.version == null || !body.version instanceof Number) {
+            return callback(
+              new Error('web API response had no valid doc version')
+            )
+          }
+          if (body.pathname == null) {
+            return callback(
+              new Error('web API response had no valid doc pathname')
+            )
+          }
+          return callback(
+            null,
+            body.lines,
+            body.version,
+            body.ranges,
+            body.pathname,
+            body.projectHistoryId,
+            body.projectHistoryType
+          )
+        } else if (res.statusCode === 404) {
+          return callback(new Errors.NotFoundError(`doc not not found: ${url}`))
+        } else {
+          return callback(
+            new Error(`error accessing web API: ${url} ${res.statusCode}`)
+          )
+        }
+      }
+    )
+  },
 
-	setDoc(project_id, doc_id, lines, version, ranges, lastUpdatedAt, lastUpdatedBy,_callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("persistenceManager.setDoc");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		const url = `${Settings.apis.web.url}/project/${project_id}/doc/${doc_id}`;
-		return request({
-			url,
-			method: "POST",
-			json: {
-				lines,
-				ranges,
-				version,
-				lastUpdatedBy,
-				lastUpdatedAt
-			},
-			auth: {
-				user: Settings.apis.web.user,
-				pass: Settings.apis.web.pass,
-				sendImmediately: true
-			},
-			jar: false,
-			timeout: MAX_HTTP_REQUEST_LENGTH
-		}, function(error, res, body) {
-			updateMetric('setDoc', error, res);
-			if (error != null) { return callback(error); }
-			if ((res.statusCode >= 200) && (res.statusCode < 300)) {
-				return callback(null);
-			} else if (res.statusCode === 404) {
-				return callback(new Errors.NotFoundError(`doc not not found: ${url}`));
-			} else {
-				return callback(new Error(`error accessing web API: ${url} ${res.statusCode}`));
-			}
-		});
-	}
-});
+  setDoc(
+    project_id,
+    doc_id,
+    lines,
+    version,
+    ranges,
+    lastUpdatedAt,
+    lastUpdatedBy,
+    _callback
+  ) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('persistenceManager.setDoc')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
 
+    const url = `${Settings.apis.web.url}/project/${project_id}/doc/${doc_id}`
+    return request(
+      {
+        url,
+        method: 'POST',
+        json: {
+          lines,
+          ranges,
+          version,
+          lastUpdatedBy,
+          lastUpdatedAt
+        },
+        auth: {
+          user: Settings.apis.web.user,
+          pass: Settings.apis.web.pass,
+          sendImmediately: true
+        },
+        jar: false,
+        timeout: MAX_HTTP_REQUEST_LENGTH
+      },
+      function (error, res, body) {
+        updateMetric('setDoc', error, res)
+        if (error != null) {
+          return callback(error)
+        }
+        if (res.statusCode >= 200 && res.statusCode < 300) {
+          return callback(null)
+        } else if (res.statusCode === 404) {
+          return callback(new Errors.NotFoundError(`doc not not found: ${url}`))
+        } else {
+          return callback(
+            new Error(`error accessing web API: ${url} ${res.statusCode}`)
+          )
+        }
+      }
+    )
+  }
+}

+ 47 - 46
services/document-updater/app/js/Profiler.js

@@ -8,54 +8,55 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let Profiler;
-const Settings = require('settings-sharelatex');
-const logger = require('logger-sharelatex');
+let Profiler
+const Settings = require('settings-sharelatex')
+const logger = require('logger-sharelatex')
 
-const deltaMs = function(ta, tb) {
-	const nanoSeconds = ((ta[0]-tb[0])*1e9) + (ta[1]-tb[1]);
-	const milliSeconds = Math.floor(nanoSeconds*1e-6);
-	return milliSeconds;
-};
+const deltaMs = function (ta, tb) {
+  const nanoSeconds = (ta[0] - tb[0]) * 1e9 + (ta[1] - tb[1])
+  const milliSeconds = Math.floor(nanoSeconds * 1e-6)
+  return milliSeconds
+}
 
-module.exports = (Profiler = (function() {
-	Profiler = class Profiler {
-		static initClass() {
-			this.prototype.LOG_CUTOFF_TIME = 1000;
-		}
+module.exports = Profiler = (function () {
+  Profiler = class Profiler {
+    static initClass() {
+      this.prototype.LOG_CUTOFF_TIME = 1000
+    }
 
-		constructor(name, args) {
-			this.name = name;
-			this.args = args;
-			this.t0 = (this.t = process.hrtime());
-			this.start = new Date();
-			this.updateTimes = [];
-		}
+    constructor(name, args) {
+      this.name = name
+      this.args = args
+      this.t0 = this.t = process.hrtime()
+      this.start = new Date()
+      this.updateTimes = []
+    }
 
-		log(label) {
-			const t1 = process.hrtime();
-			const dtMilliSec = deltaMs(t1, this.t);
-			this.t = t1;
-			this.updateTimes.push([label, dtMilliSec]); // timings in ms
-			return this; // make it chainable
-		}
+    log(label) {
+      const t1 = process.hrtime()
+      const dtMilliSec = deltaMs(t1, this.t)
+      this.t = t1
+      this.updateTimes.push([label, dtMilliSec]) // timings in ms
+      return this // make it chainable
+    }
 
-		end(message) {
-			const totalTime = deltaMs(this.t, this.t0);
-			if (totalTime > this.LOG_CUTOFF_TIME) { // log anything greater than cutoff
-				const args = {};
-				for (const k in this.args) {
-					const v = this.args[k];
-					args[k] = v;
-				}
-				args.updateTimes = this.updateTimes;
-				args.start = this.start;
-				args.end = new Date();
-				logger.log(args, this.name);
-			}
-			return totalTime;
-		}
-	};
-	Profiler.initClass();
-	return Profiler;
-})());
+    end(message) {
+      const totalTime = deltaMs(this.t, this.t0)
+      if (totalTime > this.LOG_CUTOFF_TIME) {
+        // log anything greater than cutoff
+        const args = {}
+        for (const k in this.args) {
+          const v = this.args[k]
+          args[k] = v
+        }
+        args.updateTimes = this.updateTimes
+        args.start = this.start
+        args.end = new Date()
+        logger.log(args, this.name)
+      }
+      return totalTime
+    }
+  }
+  Profiler.initClass()
+  return Profiler
+})()

+ 110 - 88
services/document-updater/app/js/ProjectFlusher.js

@@ -12,96 +12,118 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const request = require("request");
-const Settings = require('settings-sharelatex');
-const RedisManager = require("./RedisManager");
-const {
-    rclient
-} = RedisManager;
-const docUpdaterKeys = Settings.redis.documentupdater.key_schema;
-const async = require("async");
-const ProjectManager = require("./ProjectManager");
-const _ = require("lodash");
-const logger = require("logger-sharelatex");
+const request = require('request')
+const Settings = require('settings-sharelatex')
+const RedisManager = require('./RedisManager')
+const { rclient } = RedisManager
+const docUpdaterKeys = Settings.redis.documentupdater.key_schema
+const async = require('async')
+const ProjectManager = require('./ProjectManager')
+const _ = require('lodash')
+const logger = require('logger-sharelatex')
 
-var ProjectFlusher = { 
+var ProjectFlusher = {
+  // iterate over keys asynchronously using redis scan (non-blocking)
+  // handle all the cluster nodes or single redis server
+  _getKeys(pattern, limit, callback) {
+    const nodes = (typeof rclient.nodes === 'function'
+      ? rclient.nodes('master')
+      : undefined) || [rclient]
+    const doKeyLookupForNode = (node, cb) =>
+      ProjectFlusher._getKeysFromNode(node, pattern, limit, cb)
+    return async.concatSeries(nodes, doKeyLookupForNode, callback)
+  },
 
-	// iterate over keys asynchronously using redis scan (non-blocking)
-	// handle all the cluster nodes or single redis server
-	_getKeys(pattern, limit, callback) {
-		const nodes = (typeof rclient.nodes === 'function' ? rclient.nodes('master') : undefined) || [ rclient ];
-		const doKeyLookupForNode = (node, cb) => ProjectFlusher._getKeysFromNode(node, pattern, limit, cb);
-		return async.concatSeries(nodes, doKeyLookupForNode, callback);
-	},
+  _getKeysFromNode(node, pattern, limit, callback) {
+    if (limit == null) {
+      limit = 1000
+    }
+    let cursor = 0 // redis iterator
+    const keySet = {} // use hash to avoid duplicate results
+    const batchSize = limit != null ? Math.min(limit, 1000) : 1000
+    // scan over all keys looking for pattern
+    var doIteration = (
+      cb // avoid hitting redis too hard
+    ) =>
+      node.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize, function (
+        error,
+        reply
+      ) {
+        let keys
+        if (error != null) {
+          return callback(error)
+        }
+        ;[cursor, keys] = Array.from(reply)
+        for (const key of Array.from(keys)) {
+          keySet[key] = true
+        }
+        keys = Object.keys(keySet)
+        const noResults = cursor === '0' // redis returns string results not numeric
+        const limitReached = limit != null && keys.length >= limit
+        if (noResults || limitReached) {
+          return callback(null, keys)
+        } else {
+          return setTimeout(doIteration, 10)
+        }
+      })
+    return doIteration()
+  },
 
-	_getKeysFromNode(node, pattern, limit, callback) {
-		if (limit == null) { limit = 1000; }
-		let cursor = 0;  // redis iterator
-		const keySet = {}; // use hash to avoid duplicate results
-		const batchSize = (limit != null) ? Math.min(limit, 1000) : 1000;
-		// scan over all keys looking for pattern
-		var doIteration = cb => // avoid hitting redis too hard
-        node.scan(cursor, "MATCH", pattern, "COUNT", batchSize, function(error, reply) {
-            let keys;
-            if (error != null) { return callback(error); }
-            [cursor, keys] = Array.from(reply);
-            for (const key of Array.from(keys)) {
-                keySet[key] = true;
-            }
-            keys = Object.keys(keySet);
-            const noResults = cursor === "0"; // redis returns string results not numeric
-            const limitReached = ((limit != null) && (keys.length >= limit));
-            if (noResults || limitReached) {
-                return callback(null, keys);
-            } else {
-                return setTimeout(doIteration, 10);
-            }
-        });
-		return doIteration();
-	},
+  // extract ids from keys like DocsWithHistoryOps:57fd0b1f53a8396d22b2c24b
+  // or docsInProject:{57fd0b1f53a8396d22b2c24b} (for redis cluster)
+  _extractIds(keyList) {
+    const ids = (() => {
+      const result = []
+      for (const key of Array.from(keyList)) {
+        const m = key.match(/:\{?([0-9a-f]{24})\}?/) // extract object id
+        result.push(m[1])
+      }
+      return result
+    })()
+    return ids
+  },
 
-	// extract ids from keys like DocsWithHistoryOps:57fd0b1f53a8396d22b2c24b
-	// or docsInProject:{57fd0b1f53a8396d22b2c24b} (for redis cluster)
-	_extractIds(keyList) {
-		const ids = (() => {
-			const result = [];
-			for (const key of Array.from(keyList)) {
-				const m = key.match(/:\{?([0-9a-f]{24})\}?/); // extract object id
-				result.push(m[1]);
-			}
-			return result;
-		})();
-		return ids;
-	},
+  flushAllProjects(options, callback) {
+    logger.log({ options }, 'flushing all projects')
+    return ProjectFlusher._getKeys(
+      docUpdaterKeys.docsInProject({ project_id: '*' }),
+      options.limit,
+      function (error, project_keys) {
+        if (error != null) {
+          logger.err({ err: error }, 'error getting keys for flushing')
+          return callback(error)
+        }
+        const project_ids = ProjectFlusher._extractIds(project_keys)
+        if (options.dryRun) {
+          return callback(null, project_ids)
+        }
+        const jobs = _.map(project_ids, (project_id) => (cb) =>
+          ProjectManager.flushAndDeleteProjectWithLocks(
+            project_id,
+            { background: true },
+            cb
+          )
+        )
+        return async.parallelLimit(
+          async.reflectAll(jobs),
+          options.concurrency,
+          function (error, results) {
+            const success = []
+            const failure = []
+            _.each(results, function (result, i) {
+              if (result.error != null) {
+                return failure.push(project_ids[i])
+              } else {
+                return success.push(project_ids[i])
+              }
+            })
+            logger.log({ success, failure }, 'finished flushing all projects')
+            return callback(error, { success, failure })
+          }
+        )
+      }
+    )
+  }
+}
 
-	flushAllProjects(options, callback){
-		logger.log({options}, "flushing all projects");
-		return ProjectFlusher._getKeys(docUpdaterKeys.docsInProject({project_id:"*"}), options.limit, function(error, project_keys) {
-			if (error != null) {
-				logger.err({err:error}, "error getting keys for flushing");
-				return callback(error);
-			}
-			const project_ids = ProjectFlusher._extractIds(project_keys);
-			if (options.dryRun) {
-				return callback(null, project_ids);
-			}
-			const jobs = _.map(project_ids, project_id => cb => ProjectManager.flushAndDeleteProjectWithLocks(project_id, {background:true}, cb));
-			return async.parallelLimit(async.reflectAll(jobs), options.concurrency, function(error, results){
-				const success = [];
-				const failure = [];
-				_.each(results, function(result, i){
-					if (result.error != null) {
-						return failure.push(project_ids[i]);
-					} else { 
-						return success.push(project_ids[i]);
-					}
-				});
-				logger.log({success, failure}, "finished flushing all projects");
-				return callback(error, {success, failure});
-			});
-		});
-	}
-};
-
-
-module.exports = ProjectFlusher;
+module.exports = ProjectFlusher

+ 150 - 91
services/document-updater/app/js/ProjectHistoryRedisManager.js

@@ -13,105 +13,164 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let ProjectHistoryRedisManager;
-const Settings = require('settings-sharelatex');
-const projectHistoryKeys = __guard__(Settings.redis != null ? Settings.redis.project_history : undefined, x => x.key_schema);
-const rclient = require("redis-sharelatex").createClient(Settings.redis.project_history);
-const logger = require('logger-sharelatex');
-const metrics = require('./Metrics');
+let ProjectHistoryRedisManager
+const Settings = require('settings-sharelatex')
+const projectHistoryKeys = __guard__(
+  Settings.redis != null ? Settings.redis.project_history : undefined,
+  (x) => x.key_schema
+)
+const rclient = require('redis-sharelatex').createClient(
+  Settings.redis.project_history
+)
+const logger = require('logger-sharelatex')
+const metrics = require('./Metrics')
 
-module.exports = (ProjectHistoryRedisManager = {
-	queueOps(project_id, ...rest) {
-		// Record metric for ops pushed onto queue
-		const adjustedLength = Math.max(rest.length, 1); const ops = rest.slice(0, adjustedLength - 1); const val = rest[adjustedLength - 1]; const callback = val != null ? val : function(error, projectUpdateCount) {};
-		for (const op of Array.from(ops)) {
-			metrics.summary("redis.projectHistoryOps", op.length, {status: "push"});
-		}
-		const multi = rclient.multi();
-		// Push the ops onto the project history queue
-		multi.rpush(projectHistoryKeys.projectHistoryOps({project_id}), ...Array.from(ops));
-		// To record the age of the oldest op on the queue set a timestamp if not
-		// already present (SETNX).
-		multi.setnx(projectHistoryKeys.projectHistoryFirstOpTimestamp({project_id}), Date.now());
-		return multi.exec(function(error, result) {
-			if (error != null) { return callback(error); }
-			// return the number of entries pushed onto the project history queue
-			return callback(null, result[0]);});
-	},
+module.exports = ProjectHistoryRedisManager = {
+  queueOps(project_id, ...rest) {
+    // Record metric for ops pushed onto queue
+    const adjustedLength = Math.max(rest.length, 1)
+    const ops = rest.slice(0, adjustedLength - 1)
+    const val = rest[adjustedLength - 1]
+    const callback = val != null ? val : function (error, projectUpdateCount) {}
+    for (const op of Array.from(ops)) {
+      metrics.summary('redis.projectHistoryOps', op.length, { status: 'push' })
+    }
+    const multi = rclient.multi()
+    // Push the ops onto the project history queue
+    multi.rpush(
+      projectHistoryKeys.projectHistoryOps({ project_id }),
+      ...Array.from(ops)
+    )
+    // To record the age of the oldest op on the queue set a timestamp if not
+    // already present (SETNX).
+    multi.setnx(
+      projectHistoryKeys.projectHistoryFirstOpTimestamp({ project_id }),
+      Date.now()
+    )
+    return multi.exec(function (error, result) {
+      if (error != null) {
+        return callback(error)
+      }
+      // return the number of entries pushed onto the project history queue
+      return callback(null, result[0])
+    })
+  },
 
+  queueRenameEntity(
+    project_id,
+    projectHistoryId,
+    entity_type,
+    entity_id,
+    user_id,
+    projectUpdate,
+    callback
+  ) {
+    projectUpdate = {
+      pathname: projectUpdate.pathname,
+      new_pathname: projectUpdate.newPathname,
+      meta: {
+        user_id,
+        ts: new Date()
+      },
+      version: projectUpdate.version,
+      projectHistoryId
+    }
+    projectUpdate[entity_type] = entity_id
 
-	queueRenameEntity(project_id, projectHistoryId, entity_type, entity_id, user_id, projectUpdate, callback) {
-		projectUpdate = {
-			pathname: projectUpdate.pathname,
-			new_pathname: projectUpdate.newPathname,
-			meta: {
-				user_id,
-				ts: new Date()
-			},
-			version: projectUpdate.version,
-			projectHistoryId
-		};
-		projectUpdate[entity_type] = entity_id;
+    logger.log(
+      { project_id, projectUpdate },
+      'queue rename operation to project-history'
+    )
+    const jsonUpdate = JSON.stringify(projectUpdate)
 
-		logger.log({project_id, projectUpdate}, "queue rename operation to project-history");
-		const jsonUpdate = JSON.stringify(projectUpdate);
+    return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback)
+  },
 
-		return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback);
-	},
+  queueAddEntity(
+    project_id,
+    projectHistoryId,
+    entity_type,
+    entitiy_id,
+    user_id,
+    projectUpdate,
+    callback
+  ) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    projectUpdate = {
+      pathname: projectUpdate.pathname,
+      docLines: projectUpdate.docLines,
+      url: projectUpdate.url,
+      meta: {
+        user_id,
+        ts: new Date()
+      },
+      version: projectUpdate.version,
+      projectHistoryId
+    }
+    projectUpdate[entity_type] = entitiy_id
 
-	queueAddEntity(project_id, projectHistoryId, entity_type, entitiy_id, user_id, projectUpdate, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		projectUpdate = {
-			pathname: projectUpdate.pathname,
-			docLines: projectUpdate.docLines,
-			url: projectUpdate.url,
-			meta: {
-				user_id,
-				ts: new Date()
-			},
-			version: projectUpdate.version,
-			projectHistoryId
-		};
-		projectUpdate[entity_type] = entitiy_id;
+    logger.log(
+      { project_id, projectUpdate },
+      'queue add operation to project-history'
+    )
+    const jsonUpdate = JSON.stringify(projectUpdate)
 
-		logger.log({project_id, projectUpdate}, "queue add operation to project-history");
-		const jsonUpdate = JSON.stringify(projectUpdate);
+    return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback)
+  },
 
-		return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback);
-	},
+  queueResyncProjectStructure(
+    project_id,
+    projectHistoryId,
+    docs,
+    files,
+    callback
+  ) {
+    logger.log({ project_id, docs, files }, 'queue project structure resync')
+    const projectUpdate = {
+      resyncProjectStructure: { docs, files },
+      projectHistoryId,
+      meta: {
+        ts: new Date()
+      }
+    }
+    const jsonUpdate = JSON.stringify(projectUpdate)
+    return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback)
+  },
 
-	queueResyncProjectStructure(project_id, projectHistoryId, docs, files, callback) {
-		logger.log({project_id, docs, files}, "queue project structure resync");
-		const projectUpdate = {
-			resyncProjectStructure: { docs, files },
-			projectHistoryId,
-			meta: {
-				ts: new Date()
-			}
-		};
-		const jsonUpdate = JSON.stringify(projectUpdate);
-		return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback);
-	},
-
-	queueResyncDocContent(project_id, projectHistoryId, doc_id, lines, version, pathname, callback) {
-		logger.log({project_id, doc_id, lines, version, pathname}, "queue doc content resync");
-		const projectUpdate = {
-			resyncDocContent: {
-				content: lines.join("\n"),
-				version
-			},
-			projectHistoryId,
-			path: pathname,
-			doc: doc_id,
-			meta: {
-				ts: new Date()
-			}
-		};
-		const jsonUpdate = JSON.stringify(projectUpdate);
-		return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback);
-	}
-});
+  queueResyncDocContent(
+    project_id,
+    projectHistoryId,
+    doc_id,
+    lines,
+    version,
+    pathname,
+    callback
+  ) {
+    logger.log(
+      { project_id, doc_id, lines, version, pathname },
+      'queue doc content resync'
+    )
+    const projectUpdate = {
+      resyncDocContent: {
+        content: lines.join('\n'),
+        version
+      },
+      projectHistoryId,
+      path: pathname,
+      doc: doc_id,
+      meta: {
+        ts: new Date()
+      }
+    }
+    const jsonUpdate = JSON.stringify(projectUpdate)
+    return ProjectHistoryRedisManager.queueOps(project_id, jsonUpdate, callback)
+  }
+}
 
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 376 - 215
services/document-updater/app/js/ProjectManager.js

@@ -12,221 +12,382 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let ProjectManager;
-const RedisManager = require("./RedisManager");
-const ProjectHistoryRedisManager = require("./ProjectHistoryRedisManager");
-const DocumentManager = require("./DocumentManager");
-const HistoryManager = require("./HistoryManager");
-const async = require("async");
-const logger = require("logger-sharelatex");
-const Metrics = require("./Metrics");
-const Errors = require("./Errors");
-
-module.exports = (ProjectManager = {
-	flushProjectWithLocks(project_id, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("projectManager.flushProjectWithLocks");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return RedisManager.getDocIdsInProject(project_id, function(error, doc_ids) {
-			if (error != null) { return callback(error); }
-			const jobs = [];
-			const errors = [];
-			for (const doc_id of Array.from((doc_ids || []))) {
-				((doc_id => jobs.push(callback => DocumentManager.flushDocIfLoadedWithLock(project_id, doc_id, function(error) {
-                    if ((error != null) && error instanceof Errors.NotFoundError) {
-                        logger.warn({err: error, project_id, doc_id}, "found deleted doc when flushing");
-                        return callback();
-                    } else if (error != null) {
-                        logger.error({err: error, project_id, doc_id}, "error flushing doc");
-                        errors.push(error);
-                        return callback();
-                    } else {
-                        return callback();
-                    }
-                }))))(doc_id);
-			}
-
-			logger.log({project_id, doc_ids}, "flushing docs");
-			return async.series(jobs, function() {
-				if (errors.length > 0) {
-					return callback(new Error("Errors flushing docs. See log for details"));
-				} else {
-					return callback(null);
-				}
-			});
-		});
-	},
-
-	flushAndDeleteProjectWithLocks(project_id, options, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("projectManager.flushAndDeleteProjectWithLocks");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return RedisManager.getDocIdsInProject(project_id, function(error, doc_ids) {
-			if (error != null) { return callback(error); }
-			const jobs = [];
-			const errors = [];
-			for (const doc_id of Array.from((doc_ids || []))) {
-				((doc_id => jobs.push(callback => DocumentManager.flushAndDeleteDocWithLock(project_id, doc_id, {}, function(error) {
-                    if (error != null) {
-                        logger.error({err: error, project_id, doc_id}, "error deleting doc");
-                        errors.push(error);
-                    }
-                    return callback();
-                }))))(doc_id);
-			}
-
-			logger.log({project_id, doc_ids}, "deleting docs");
-			return async.series(jobs, () => // When deleting the project here we want to ensure that project
-            // history is completely flushed because the project may be
-            // deleted in web after this call completes, and so further
-            // attempts to flush would fail after that.
-            HistoryManager.flushProjectChanges(project_id, options, function(error) {
-                if (errors.length > 0) {
-                    return callback(new Error("Errors deleting docs. See log for details"));
+let ProjectManager
+const RedisManager = require('./RedisManager')
+const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
+const DocumentManager = require('./DocumentManager')
+const HistoryManager = require('./HistoryManager')
+const async = require('async')
+const logger = require('logger-sharelatex')
+const Metrics = require('./Metrics')
+const Errors = require('./Errors')
+
+module.exports = ProjectManager = {
+  flushProjectWithLocks(project_id, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('projectManager.flushProjectWithLocks')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return RedisManager.getDocIdsInProject(project_id, function (
+      error,
+      doc_ids
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      const jobs = []
+      const errors = []
+      for (const doc_id of Array.from(doc_ids || [])) {
+        ;((doc_id) =>
+          jobs.push((callback) =>
+            DocumentManager.flushDocIfLoadedWithLock(
+              project_id,
+              doc_id,
+              function (error) {
+                if (error != null && error instanceof Errors.NotFoundError) {
+                  logger.warn(
+                    { err: error, project_id, doc_id },
+                    'found deleted doc when flushing'
+                  )
+                  return callback()
                 } else if (error != null) {
-                    return callback(error);
+                  logger.error(
+                    { err: error, project_id, doc_id },
+                    'error flushing doc'
+                  )
+                  errors.push(error)
+                  return callback()
                 } else {
-                    return callback(null);
+                  return callback()
                 }
-            }));
-		});
-	},
-
-	queueFlushAndDeleteProject(project_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return RedisManager.queueFlushAndDeleteProject(project_id, function(error) {
-			if (error != null) {
-				logger.error({project_id, error}, "error adding project to flush and delete queue");
-				return callback(error);
-			}
-			Metrics.inc("queued-delete");
-			return callback();
-		});
-	},
-
-	getProjectDocsTimestamps(project_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return RedisManager.getDocIdsInProject(project_id, function(error, doc_ids) {
-			if (error != null) { return callback(error); }
-			if (!(doc_ids != null ? doc_ids.length : undefined)) { return callback(null, []); }
-			return RedisManager.getDocTimestamps(doc_ids, function(error, timestamps) {
-				if (error != null) { return callback(error); }
-				return callback(null, timestamps);
-			});
-		});
-	},
-
-	getProjectDocsAndFlushIfOld(project_id, projectStateHash, excludeVersions, _callback) {
-		if (excludeVersions == null) { excludeVersions = {}; }
-		if (_callback == null) { _callback = function(error, docs) {}; }
-		const timer = new Metrics.Timer("projectManager.getProjectDocsAndFlushIfOld");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		return RedisManager.checkOrSetProjectState(project_id, projectStateHash, function(error, projectStateChanged) {
-			if (error != null) {
-				logger.error({err: error, project_id}, "error getting/setting project state in getProjectDocsAndFlushIfOld");
-				return callback(error);
-			}
-			// we can't return docs if project structure has changed
-			if (projectStateChanged) {
-				return callback(Errors.ProjectStateChangedError("project state changed"));
-			}
-			// project structure hasn't changed, return doc content from redis
-			return RedisManager.getDocIdsInProject(project_id, function(error, doc_ids) {
-				if (error != null) {
-					logger.error({err: error, project_id}, "error getting doc ids in getProjectDocs");
-					return callback(error);
-				}
-				const jobs = [];
-				for (const doc_id of Array.from(doc_ids || [])) {
-					((doc_id => jobs.push(cb => // get the doc lines from redis
-                    DocumentManager.getDocAndFlushIfOldWithLock(project_id, doc_id, function(err, lines, version) {
-                        if (err != null) {
-                            logger.error({err, project_id, doc_id}, "error getting project doc lines in getProjectDocsAndFlushIfOld");
-                            return cb(err);
-                        }
-                        const doc = {_id:doc_id, lines, v:version}; // create a doc object to return
-                        return cb(null, doc);
-                    }))))(doc_id);
-				}
-				return async.series(jobs, function(error, docs) {
-					if (error != null) { return callback(error); }
-					return callback(null, docs);
-				});
-			});
-		});
-	},
-
-	clearProjectState(project_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return RedisManager.clearProjectState(project_id, callback);
-	},
-
-	updateProjectWithLocks(project_id, projectHistoryId, user_id, docUpdates, fileUpdates, version, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const timer = new Metrics.Timer("projectManager.updateProject");
-		const callback = function(...args) {
-			timer.done();
-			return _callback(...Array.from(args || []));
-		};
-
-		const project_version = version; 
-		let project_subversion = 0; 		// project versions can have multiple operations
-
-		let project_ops_length = 0;
-
-		const handleDocUpdate = function(projectUpdate, cb) {
-			const doc_id = projectUpdate.id;
-			projectUpdate.version = `${project_version}.${project_subversion++}`;
-			if (projectUpdate.docLines != null) {
-				return ProjectHistoryRedisManager.queueAddEntity(project_id, projectHistoryId, 'doc', doc_id, user_id, projectUpdate, function(error, count) {
-					project_ops_length = count;
-					return cb(error);
-				});
-			} else {
-				return DocumentManager.renameDocWithLock(project_id, doc_id, user_id, projectUpdate, projectHistoryId, function(error, count) {
-					project_ops_length = count;
-					return cb(error);
-				});
-			}
-		};
-
-		const handleFileUpdate = function(projectUpdate, cb) {
-			const file_id = projectUpdate.id;
-			projectUpdate.version = `${project_version}.${project_subversion++}`;
-			if (projectUpdate.url != null) {
-				return ProjectHistoryRedisManager.queueAddEntity(project_id, projectHistoryId, 'file', file_id, user_id, projectUpdate, function(error, count) {
-					project_ops_length = count;
-					return cb(error);
-				});
-			} else {
-				return ProjectHistoryRedisManager.queueRenameEntity(project_id, projectHistoryId, 'file', file_id, user_id, projectUpdate, function(error, count) {
-					project_ops_length = count;
-					return cb(error);
-				});
-			}
-		};
-
-		return async.eachSeries(docUpdates, handleDocUpdate, function(error) {
-			if (error != null) { return callback(error); }
-			return async.eachSeries(fileUpdates, handleFileUpdate, function(error) {
-				if (error != null) { return callback(error); }
-				if (HistoryManager.shouldFlushHistoryOps(project_ops_length, docUpdates.length + fileUpdates.length, HistoryManager.FLUSH_PROJECT_EVERY_N_OPS)) {
-					HistoryManager.flushProjectChangesAsync(project_id);
-				}
-				return callback();
-			});
-		});
-	}
-});
+              }
+            )
+          ))(doc_id)
+      }
+
+      logger.log({ project_id, doc_ids }, 'flushing docs')
+      return async.series(jobs, function () {
+        if (errors.length > 0) {
+          return callback(
+            new Error('Errors flushing docs. See log for details')
+          )
+        } else {
+          return callback(null)
+        }
+      })
+    })
+  },
+
+  flushAndDeleteProjectWithLocks(project_id, options, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer(
+      'projectManager.flushAndDeleteProjectWithLocks'
+    )
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return RedisManager.getDocIdsInProject(project_id, function (
+      error,
+      doc_ids
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      const jobs = []
+      const errors = []
+      for (const doc_id of Array.from(doc_ids || [])) {
+        ;((doc_id) =>
+          jobs.push((callback) =>
+            DocumentManager.flushAndDeleteDocWithLock(
+              project_id,
+              doc_id,
+              {},
+              function (error) {
+                if (error != null) {
+                  logger.error(
+                    { err: error, project_id, doc_id },
+                    'error deleting doc'
+                  )
+                  errors.push(error)
+                }
+                return callback()
+              }
+            )
+          ))(doc_id)
+      }
+
+      logger.log({ project_id, doc_ids }, 'deleting docs')
+      return async.series(jobs, () =>
+        // When deleting the project here we want to ensure that project
+        // history is completely flushed because the project may be
+        // deleted in web after this call completes, and so further
+        // attempts to flush would fail after that.
+        HistoryManager.flushProjectChanges(project_id, options, function (
+          error
+        ) {
+          if (errors.length > 0) {
+            return callback(
+              new Error('Errors deleting docs. See log for details')
+            )
+          } else if (error != null) {
+            return callback(error)
+          } else {
+            return callback(null)
+          }
+        })
+      )
+    })
+  },
+
+  queueFlushAndDeleteProject(project_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return RedisManager.queueFlushAndDeleteProject(project_id, function (
+      error
+    ) {
+      if (error != null) {
+        logger.error(
+          { project_id, error },
+          'error adding project to flush and delete queue'
+        )
+        return callback(error)
+      }
+      Metrics.inc('queued-delete')
+      return callback()
+    })
+  },
+
+  getProjectDocsTimestamps(project_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return RedisManager.getDocIdsInProject(project_id, function (
+      error,
+      doc_ids
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (!(doc_ids != null ? doc_ids.length : undefined)) {
+        return callback(null, [])
+      }
+      return RedisManager.getDocTimestamps(doc_ids, function (
+        error,
+        timestamps
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        return callback(null, timestamps)
+      })
+    })
+  },
+
+  getProjectDocsAndFlushIfOld(
+    project_id,
+    projectStateHash,
+    excludeVersions,
+    _callback
+  ) {
+    if (excludeVersions == null) {
+      excludeVersions = {}
+    }
+    if (_callback == null) {
+      _callback = function (error, docs) {}
+    }
+    const timer = new Metrics.Timer(
+      'projectManager.getProjectDocsAndFlushIfOld'
+    )
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    return RedisManager.checkOrSetProjectState(
+      project_id,
+      projectStateHash,
+      function (error, projectStateChanged) {
+        if (error != null) {
+          logger.error(
+            { err: error, project_id },
+            'error getting/setting project state in getProjectDocsAndFlushIfOld'
+          )
+          return callback(error)
+        }
+        // we can't return docs if project structure has changed
+        if (projectStateChanged) {
+          return callback(
+            Errors.ProjectStateChangedError('project state changed')
+          )
+        }
+        // project structure hasn't changed, return doc content from redis
+        return RedisManager.getDocIdsInProject(project_id, function (
+          error,
+          doc_ids
+        ) {
+          if (error != null) {
+            logger.error(
+              { err: error, project_id },
+              'error getting doc ids in getProjectDocs'
+            )
+            return callback(error)
+          }
+          const jobs = []
+          for (const doc_id of Array.from(doc_ids || [])) {
+            ;((doc_id) =>
+              jobs.push((
+                cb // get the doc lines from redis
+              ) =>
+                DocumentManager.getDocAndFlushIfOldWithLock(
+                  project_id,
+                  doc_id,
+                  function (err, lines, version) {
+                    if (err != null) {
+                      logger.error(
+                        { err, project_id, doc_id },
+                        'error getting project doc lines in getProjectDocsAndFlushIfOld'
+                      )
+                      return cb(err)
+                    }
+                    const doc = { _id: doc_id, lines, v: version } // create a doc object to return
+                    return cb(null, doc)
+                  }
+                )
+              ))(doc_id)
+          }
+          return async.series(jobs, function (error, docs) {
+            if (error != null) {
+              return callback(error)
+            }
+            return callback(null, docs)
+          })
+        })
+      }
+    )
+  },
+
+  clearProjectState(project_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return RedisManager.clearProjectState(project_id, callback)
+  },
+
+  updateProjectWithLocks(
+    project_id,
+    projectHistoryId,
+    user_id,
+    docUpdates,
+    fileUpdates,
+    version,
+    _callback
+  ) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('projectManager.updateProject')
+    const callback = function (...args) {
+      timer.done()
+      return _callback(...Array.from(args || []))
+    }
+
+    const project_version = version
+    let project_subversion = 0 // project versions can have multiple operations
+
+    let project_ops_length = 0
+
+    const handleDocUpdate = function (projectUpdate, cb) {
+      const doc_id = projectUpdate.id
+      projectUpdate.version = `${project_version}.${project_subversion++}`
+      if (projectUpdate.docLines != null) {
+        return ProjectHistoryRedisManager.queueAddEntity(
+          project_id,
+          projectHistoryId,
+          'doc',
+          doc_id,
+          user_id,
+          projectUpdate,
+          function (error, count) {
+            project_ops_length = count
+            return cb(error)
+          }
+        )
+      } else {
+        return DocumentManager.renameDocWithLock(
+          project_id,
+          doc_id,
+          user_id,
+          projectUpdate,
+          projectHistoryId,
+          function (error, count) {
+            project_ops_length = count
+            return cb(error)
+          }
+        )
+      }
+    }
+
+    const handleFileUpdate = function (projectUpdate, cb) {
+      const file_id = projectUpdate.id
+      projectUpdate.version = `${project_version}.${project_subversion++}`
+      if (projectUpdate.url != null) {
+        return ProjectHistoryRedisManager.queueAddEntity(
+          project_id,
+          projectHistoryId,
+          'file',
+          file_id,
+          user_id,
+          projectUpdate,
+          function (error, count) {
+            project_ops_length = count
+            return cb(error)
+          }
+        )
+      } else {
+        return ProjectHistoryRedisManager.queueRenameEntity(
+          project_id,
+          projectHistoryId,
+          'file',
+          file_id,
+          user_id,
+          projectUpdate,
+          function (error, count) {
+            project_ops_length = count
+            return cb(error)
+          }
+        )
+      }
+    }
+
+    return async.eachSeries(docUpdates, handleDocUpdate, function (error) {
+      if (error != null) {
+        return callback(error)
+      }
+      return async.eachSeries(fileUpdates, handleFileUpdate, function (error) {
+        if (error != null) {
+          return callback(error)
+        }
+        if (
+          HistoryManager.shouldFlushHistoryOps(
+            project_ops_length,
+            docUpdates.length + fileUpdates.length,
+            HistoryManager.FLUSH_PROJECT_EVERY_N_OPS
+          )
+        ) {
+          HistoryManager.flushProjectChangesAsync(project_id)
+        }
+        return callback()
+      })
+    })
+  }
+}

+ 143 - 98
services/document-updater/app/js/RangesManager.js

@@ -11,108 +11,153 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let RangesManager;
-const RangesTracker = require("./RangesTracker");
-const logger = require("logger-sharelatex");
-const _ = require("lodash");
+let RangesManager
+const RangesTracker = require('./RangesTracker')
+const logger = require('logger-sharelatex')
+const _ = require('lodash')
 
-module.exports = (RangesManager = {
-	MAX_COMMENTS: 500,
-	MAX_CHANGES: 2000,
+module.exports = RangesManager = {
+  MAX_COMMENTS: 500,
+  MAX_CHANGES: 2000,
 
-	applyUpdate(project_id, doc_id, entries, updates, newDocLines, callback) {
-		let error;
-		if (entries == null) { entries = {}; }
-		if (updates == null) { updates = []; }
-		if (callback == null) { callback = function(error, new_entries, ranges_were_collapsed) {}; }
-		const {changes, comments} = _.cloneDeep(entries);
-		const rangesTracker = new RangesTracker(changes, comments);
-		const emptyRangeCountBefore = RangesManager._emptyRangesCount(rangesTracker);
-		for (const update of Array.from(updates)) {
-			rangesTracker.track_changes = !!update.meta.tc;
-			if (update.meta.tc) {
-				rangesTracker.setIdSeed(update.meta.tc);
-			}
-			for (const op of Array.from(update.op)) {
-				try {
-					rangesTracker.applyOp(op, { user_id: (update.meta != null ? update.meta.user_id : undefined) });
-				} catch (error1) {
-					error = error1;
-					return callback(error);
-				}
-			}
-		}
-		
-		if (((rangesTracker.changes != null ? rangesTracker.changes.length : undefined) > RangesManager.MAX_CHANGES) || ((rangesTracker.comments != null ? rangesTracker.comments.length : undefined) > RangesManager.MAX_COMMENTS)) {
-			return callback(new Error("too many comments or tracked changes"));
-		}
+  applyUpdate(project_id, doc_id, entries, updates, newDocLines, callback) {
+    let error
+    if (entries == null) {
+      entries = {}
+    }
+    if (updates == null) {
+      updates = []
+    }
+    if (callback == null) {
+      callback = function (error, new_entries, ranges_were_collapsed) {}
+    }
+    const { changes, comments } = _.cloneDeep(entries)
+    const rangesTracker = new RangesTracker(changes, comments)
+    const emptyRangeCountBefore = RangesManager._emptyRangesCount(rangesTracker)
+    for (const update of Array.from(updates)) {
+      rangesTracker.track_changes = !!update.meta.tc
+      if (update.meta.tc) {
+        rangesTracker.setIdSeed(update.meta.tc)
+      }
+      for (const op of Array.from(update.op)) {
+        try {
+          rangesTracker.applyOp(op, {
+            user_id: update.meta != null ? update.meta.user_id : undefined
+          })
+        } catch (error1) {
+          error = error1
+          return callback(error)
+        }
+      }
+    }
 
-		try {
-			// This is a consistency check that all of our ranges and
-			// comments still match the corresponding text
-			rangesTracker.validate(newDocLines.join("\n"));
-		} catch (error2) {
-			error = error2;
-			logger.error({err: error, project_id, doc_id, newDocLines, updates}, "error validating ranges");
-			return callback(error);
-		}
+    if (
+      (rangesTracker.changes != null
+        ? rangesTracker.changes.length
+        : undefined) > RangesManager.MAX_CHANGES ||
+      (rangesTracker.comments != null
+        ? rangesTracker.comments.length
+        : undefined) > RangesManager.MAX_COMMENTS
+    ) {
+      return callback(new Error('too many comments or tracked changes'))
+    }
 
-		const emptyRangeCountAfter = RangesManager._emptyRangesCount(rangesTracker);
-		const rangesWereCollapsed = emptyRangeCountAfter > emptyRangeCountBefore;
-		const response = RangesManager._getRanges(rangesTracker);
-		logger.log({project_id, doc_id, changesCount: (response.changes != null ? response.changes.length : undefined), commentsCount: (response.comments != null ? response.comments.length : undefined), rangesWereCollapsed}, "applied updates to ranges");
-		return callback(null, response, rangesWereCollapsed);
-	},
+    try {
+      // This is a consistency check that all of our ranges and
+      // comments still match the corresponding text
+      rangesTracker.validate(newDocLines.join('\n'))
+    } catch (error2) {
+      error = error2
+      logger.error(
+        { err: error, project_id, doc_id, newDocLines, updates },
+        'error validating ranges'
+      )
+      return callback(error)
+    }
 
-	acceptChanges(change_ids, ranges, callback) {
-		if (callback == null) { callback = function(error, ranges) {}; }
-		const {changes, comments} = ranges;
-		logger.log(`accepting ${ change_ids.length } changes in ranges`);
-		const rangesTracker = new RangesTracker(changes, comments);
-		rangesTracker.removeChangeIds(change_ids);
-		const response = RangesManager._getRanges(rangesTracker);
-		return callback(null, response);
-	},
+    const emptyRangeCountAfter = RangesManager._emptyRangesCount(rangesTracker)
+    const rangesWereCollapsed = emptyRangeCountAfter > emptyRangeCountBefore
+    const response = RangesManager._getRanges(rangesTracker)
+    logger.log(
+      {
+        project_id,
+        doc_id,
+        changesCount:
+          response.changes != null ? response.changes.length : undefined,
+        commentsCount:
+          response.comments != null ? response.comments.length : undefined,
+        rangesWereCollapsed
+      },
+      'applied updates to ranges'
+    )
+    return callback(null, response, rangesWereCollapsed)
+  },
 
-	deleteComment(comment_id, ranges, callback) {
-		if (callback == null) { callback = function(error, ranges) {}; }
-		const {changes, comments} = ranges;
-		logger.log({comment_id}, "deleting comment in ranges");
-		const rangesTracker = new RangesTracker(changes, comments);
-		rangesTracker.removeCommentId(comment_id);
-		const response = RangesManager._getRanges(rangesTracker);
-		return callback(null, response);
-	},
-	
-	_getRanges(rangesTracker) {
-		// Return the minimal data structure needed, since most documents won't have any
-		// changes or comments
-		let response = {};
-		if ((rangesTracker.changes != null ? rangesTracker.changes.length : undefined) > 0) {
-			if (response == null) { response = {}; }
-			response.changes = rangesTracker.changes;
-		}
-		if ((rangesTracker.comments != null ? rangesTracker.comments.length : undefined) > 0) {
-			if (response == null) { response = {}; }
-			response.comments = rangesTracker.comments;
-		}
-		return response;
-	},
+  acceptChanges(change_ids, ranges, callback) {
+    if (callback == null) {
+      callback = function (error, ranges) {}
+    }
+    const { changes, comments } = ranges
+    logger.log(`accepting ${change_ids.length} changes in ranges`)
+    const rangesTracker = new RangesTracker(changes, comments)
+    rangesTracker.removeChangeIds(change_ids)
+    const response = RangesManager._getRanges(rangesTracker)
+    return callback(null, response)
+  },
 
-	_emptyRangesCount(ranges) {
-		let count = 0;
-		for (const comment of Array.from((ranges.comments || []))) {
-			if (comment.op.c === "") {
-				count++;
-			}
-		}
-		for (const change of Array.from((ranges.changes || []))) {
-			if (change.op.i != null) {
-				if (change.op.i === "") {
-					count++;
-				}
-			}
-		}
-		return count;
-	}
-});
+  deleteComment(comment_id, ranges, callback) {
+    if (callback == null) {
+      callback = function (error, ranges) {}
+    }
+    const { changes, comments } = ranges
+    logger.log({ comment_id }, 'deleting comment in ranges')
+    const rangesTracker = new RangesTracker(changes, comments)
+    rangesTracker.removeCommentId(comment_id)
+    const response = RangesManager._getRanges(rangesTracker)
+    return callback(null, response)
+  },
+
+  _getRanges(rangesTracker) {
+    // Return the minimal data structure needed, since most documents won't have any
+    // changes or comments
+    let response = {}
+    if (
+      (rangesTracker.changes != null
+        ? rangesTracker.changes.length
+        : undefined) > 0
+    ) {
+      if (response == null) {
+        response = {}
+      }
+      response.changes = rangesTracker.changes
+    }
+    if (
+      (rangesTracker.comments != null
+        ? rangesTracker.comments.length
+        : undefined) > 0
+    ) {
+      if (response == null) {
+        response = {}
+      }
+      response.comments = rangesTracker.comments
+    }
+    return response
+  },
+
+  _emptyRangesCount(ranges) {
+    let count = 0
+    for (const comment of Array.from(ranges.comments || [])) {
+      if (comment.op.c === '') {
+        count++
+      }
+    }
+    for (const change of Array.from(ranges.changes || [])) {
+      if (change.op.i != null) {
+        if (change.op.i === '') {
+          count++
+        }
+      }
+    }
+    return count
+  }
+}

+ 822 - 702
services/document-updater/app/js/RangesTracker.js

@@ -17,709 +17,829 @@
 // This file is shared between document-updater and web, so that the server and client share
 // an identical track changes implementation. Do not edit it directly in web or document-updater,
 // instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests
-const load = function() {
-	let RangesTracker;
-	return RangesTracker = class RangesTracker {
-		// The purpose of this class is to track a set of inserts and deletes to a document, like
-		// track changes in Word. We store these as a set of ShareJs style ranges:
-		//   {i: "foo", p: 42} # Insert 'foo' at offset 42
-		//   {d: "bar", p: 37} # Delete 'bar' at offset 37
-		// We only track the inserts and deletes, not the whole document, but by being given all
-		// updates that are applied to a document, we can update these appropriately.
-		// 
-		// Note that the set of inserts and deletes we store applies to the document as-is at the moment.
-		// So inserts correspond to text which is in the document, while deletes correspond to text which
-		// is no longer there, so their lengths do not affect the position of later offsets.
-		// E.g.
-		//             this is the current text of the document
-		//                         |-----|            |
-		//  {i: "current ", p:12} -^                   ^- {d: "old ", p: 31}
-		//
-		// Track changes rules (should be consistent with Word):
-		//   * When text is inserted at a delete, the text goes to the left of the delete
-		//       I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
-		//   * Deleting content flagged as 'inserted' does not create a new delete marker, it only
-		//     removes the insert marker. E.g.
-		//       * "abdefghijkl"        -> "abfghijkl"        when 'de' is deleted. No delete marker added
-		//           |---| <- inserted       |-| <- inserted
-		//       * Deletes overlapping regular text and inserted text will insert a delete marker for the
-		//         regular text:
-		//         "abcdefghijkl"    ->    "abcdejkl"   when 'fghi' is deleted
-		//           |----|                  |--||
-		//           ^- inserted 'bcdefg'      \ ^- deleted 'hi'
-		//                                      \--inserted 'bcde'
-		//   * Deletes overlapping other deletes are merged. E.g.
-		//      "abcghijkl"        ->   "ahijkl"     when 'bcg is deleted'
-		//          | <- delete 'def'     | <- delete 'bcdefg'
-		//   * Deletes by another user will consume deletes by the first user
-		//   * Inserts by another user will not combine with inserts by the first user. If they are in the
-		//     middle of a previous insert by the first user, the original insert will be split into two.
-		constructor(changes, comments) {
-			if (changes == null) { changes = []; }
-			this.changes = changes;
-			if (comments == null) { comments = []; }
-			this.comments = comments;
-			this.setIdSeed(RangesTracker.generateIdSeed());
-			this.resetDirtyState();
-		}
-
-		getIdSeed() {
-			return this.id_seed;
-		}
-
-		setIdSeed(seed) {
-			this.id_seed = seed;
-			return this.id_increment = 0;
-		}
-		
-		static generateIdSeed() {
-			// Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part
-			// Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js
-			const pid = Math.floor(Math.random() * (32767)).toString(16);
-			const machine = Math.floor(Math.random() * (16777216)).toString(16);
-			const timestamp = Math.floor(new Date().valueOf() / 1000).toString(16);
-			return '00000000'.substr(0, 8 - timestamp.length) + timestamp +
-				'000000'.substr(0, 6 - machine.length) + machine +
-				'0000'.substr(0, 4 - pid.length) + pid;
-		}
-		
-		static generateId() {
-			return this.generateIdSeed() + "000001";
-		}
-
-		newId() {
-			this.id_increment++;
-			const increment = this.id_increment.toString(16);
-			const id = this.id_seed + '000000'.substr(0, 6 - increment.length) + increment;
-			return id;
-		}
-		
-		getComment(comment_id) {
-			let comment = null;
-			for (const c of Array.from(this.comments)) {
-				if (c.id === comment_id) {
-					comment = c;
-					break;
-				}
-			}
-			return comment;
-		}
-		
-		removeCommentId(comment_id) {
-			const comment = this.getComment(comment_id);
-			if ((comment == null)) { return; }
-			this.comments = this.comments.filter(c => c.id !== comment_id);
-			return this._markAsDirty(comment, "comment", "removed");
-		}
-		
-		moveCommentId(comment_id, position, text) {
-			return (() => {
-				const result = [];
-				for (const comment of Array.from(this.comments)) {
-					if (comment.id === comment_id) {
-						comment.op.p = position;
-						comment.op.c = text;
-						result.push(this._markAsDirty(comment, "comment", "moved"));
-					} else {
-						result.push(undefined);
-					}
-				}
-				return result;
-			})();
-		}
-
-		getChange(change_id) {
-			let change = null;
-			for (const c of Array.from(this.changes)) {
-				if (c.id === change_id) {
-					change = c;
-					break;
-				}
-			}
-			return change;
-		}
-
-		getChanges(change_ids) {
-			const changes_response = [];
-			const ids_map = {};
-
-			for (const change_id of Array.from(change_ids)) {
-				ids_map[change_id] = true;
-			}
-
-			for (const change of Array.from(this.changes)) {
-				if (ids_map[change.id]) {
-					delete ids_map[change.id];
-					changes_response.push(change);
-				}
-			}
-
-			return changes_response;
-		}
-
-		removeChangeId(change_id) {
-			const change = this.getChange(change_id);
-			if ((change == null)) { return; }
-			return this._removeChange(change);
-		}
-
-		removeChangeIds(change_to_remove_ids) {
-			if (!(change_to_remove_ids != null ? change_to_remove_ids.length : undefined) > 0) { return; }
-			const i = this.changes.length;
-			const remove_change_id = {}; 
-			for (const change_id of Array.from(change_to_remove_ids)) {
-				remove_change_id[change_id] = true;
-			}
-
-			const remaining_changes = [];
-
-			for (const change of Array.from(this.changes)) {
-				if (remove_change_id[change.id]) {
-					delete remove_change_id[change.id];
-					this._markAsDirty(change, "change", "removed");
-				} else {
-					remaining_changes.push(change);
-				}
-			}
-
-			return this.changes = remaining_changes;
-		}
-		
-		validate(text) {
-			let content;
-			for (const change of Array.from(this.changes)) {
-				if (change.op.i != null) {
-					content = text.slice(change.op.p, change.op.p + change.op.i.length);
-					if (content !== change.op.i) {
-						throw new Error(`Change (${JSON.stringify(change)}) doesn't match text (${JSON.stringify(content)})`);
-					}
-				}
-			}
-			for (const comment of Array.from(this.comments)) {
-				content = text.slice(comment.op.p, comment.op.p + comment.op.c.length);
-				if (content !== comment.op.c) {
-					throw new Error(`Comment (${JSON.stringify(comment)}) doesn't match text (${JSON.stringify(content)})`);
-				}
-			}
-			return true;
-		}
-
-		applyOp(op, metadata) {
-			if (metadata == null) { metadata = {}; }
-			if (metadata.ts == null) { metadata.ts = new Date(); }
-			// Apply an op that has been applied to the document to our changes to keep them up to date
-			if (op.i != null) {
-				this.applyInsertToChanges(op, metadata);
-				return this.applyInsertToComments(op);
-			} else if (op.d != null) {
-				this.applyDeleteToChanges(op, metadata);
-				return this.applyDeleteToComments(op);
-			} else if (op.c != null) {
-				return this.addComment(op, metadata);
-			} else {
-				throw new Error("unknown op type");
-			}
-		}
-
-		applyOps(ops, metadata) {
-			if (metadata == null) { metadata = {}; }
-			return Array.from(ops).map((op) =>
-				this.applyOp(op, metadata));
-		}
-
-		addComment(op, metadata) {
-			const existing = this.getComment(op.t);
-			if (existing != null) {
-				this.moveCommentId(op.t, op.p, op.c);
-				return existing;
-			} else {
-				let comment;
-				this.comments.push(comment = {
-					id: op.t || this.newId(),
-					op: { // Copy because we'll modify in place
-						c: op.c,
-						p: op.p,
-						t: op.t
-					},
-					metadata
-				});
-				this._markAsDirty(comment, "comment", "added");
-				return comment;
-			}
-		}
-		
-		applyInsertToComments(op) {
-			return (() => {
-				const result = [];
-				for (const comment of Array.from(this.comments)) {
-					if (op.p <= comment.op.p) {
-						comment.op.p += op.i.length;
-						result.push(this._markAsDirty(comment, "comment", "moved"));
-					} else if (op.p < (comment.op.p + comment.op.c.length)) {
-						const offset = op.p - comment.op.p;
-						comment.op.c = comment.op.c.slice(0, +(offset-1) + 1 || undefined) + op.i + comment.op.c.slice(offset);
-						result.push(this._markAsDirty(comment, "comment", "moved"));
-					} else {
-						result.push(undefined);
-					}
-				}
-				return result;
-			})();
-		}
-
-		applyDeleteToComments(op) {
-			const op_start = op.p;
-			const op_length = op.d.length;
-			const op_end = op.p + op_length;
-			return (() => {
-				const result = [];
-				for (const comment of Array.from(this.comments)) {
-					const comment_start = comment.op.p;
-					const comment_end = comment.op.p + comment.op.c.length;
-					const comment_length = comment_end - comment_start;
-					if (op_end <= comment_start) {
-						// delete is fully before comment
-						comment.op.p -= op_length;
-						result.push(this._markAsDirty(comment, "comment", "moved"));
-					} else if (op_start >= comment_end) {
-						// delete is fully after comment, nothing to do
-					} else {
-						// delete and comment overlap
-						var remaining_after, remaining_before;
-						if (op_start <= comment_start) {
-							remaining_before = "";
-						} else {
-							remaining_before = comment.op.c.slice(0, op_start - comment_start);
-						}
-						if (op_end >= comment_end) {
-							remaining_after = "";
-						} else {
-							remaining_after = comment.op.c.slice(op_end - comment_start);
-						}
-					
-						// Check deleted content matches delete op
-						const deleted_comment = comment.op.c.slice(remaining_before.length, comment_length - remaining_after.length);
-						const offset = Math.max(0, comment_start - op_start);
-						const deleted_op_content = op.d.slice(offset).slice(0, deleted_comment.length);
-						if (deleted_comment !== deleted_op_content) {
-							throw new Error("deleted content does not match comment content");
-						}
-					
-						comment.op.p = Math.min(comment_start, op_start);
-						comment.op.c = remaining_before + remaining_after;
-						result.push(this._markAsDirty(comment, "comment", "moved"));
-					}
-				}
-				return result;
-			})();
-		}
-
-		applyInsertToChanges(op, metadata) {
-			let change;
-			const op_start = op.p;
-			const op_length = op.i.length;
-			const op_end = op.p + op_length;
-			const undoing = !!op.u;
-
-
-			let already_merged = false;
-			let previous_change = null;
-			const moved_changes = [];
-			const remove_changes = [];
-			const new_changes = [];
-
-			for (let i = 0; i < this.changes.length; i++) {
-				change = this.changes[i];
-				const change_start = change.op.p;
-				
-				if (change.op.d != null) {
-					// Shift any deletes after this along by the length of this insert
-					if (op_start < change_start) {
-						change.op.p += op_length;
-						moved_changes.push(change);
-					} else if (op_start === change_start) {
-						// If we are undoing, then we want to cancel any existing delete ranges if we can.
-						// Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
-						if (undoing && (change.op.d.length >= op.i.length) && (change.op.d.slice(0, op.i.length) === op.i)) {
-							change.op.d = change.op.d.slice(op.i.length);
-							change.op.p += op.i.length;
-							if (change.op.d === "") {
-								remove_changes.push(change);
-							} else {
-								moved_changes.push(change);
-							}
-							already_merged = true;
-						} else {
-							change.op.p += op_length;
-							moved_changes.push(change);
-						}
-					}
-				} else if (change.op.i != null) {
-					var offset;
-					const change_end = change_start + change.op.i.length;
-					const is_change_overlapping = ((op_start >= change_start) && (op_start <= change_end));
-					
-					// Only merge inserts if they are from the same user
-					const is_same_user = metadata.user_id === change.metadata.user_id;
-					
-					// If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
-					// an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
-					// E.g.
-					//                   foo|<--- about to insert 'b' here
-					//  inserted 'foo'  --^ ^-- deleted 'bar'
-					// should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
-					const next_change = this.changes[i+1];
-					const is_op_adjacent_to_next_delete = (next_change != null) && (next_change.op.d != null) && (op.p === change_end) && (next_change.op.p === op.p);
-					const will_op_cancel_next_delete = undoing && is_op_adjacent_to_next_delete && (next_change.op.d.slice(0, op.i.length) === op.i);
-					
-					// If there is a delete at the start of the insert, and we're inserting
-					// at the start, we SHOULDN'T merge since the delete acts as a partition.
-					// The previous op will be the delete, but it's already been shifted by this insert
-					//
-					// I.e.
-					// Originally: |-- existing insert --|
-					//             | <- existing delete at same offset
-					//
-					// Now:        |-- existing insert --| <- not shifted yet
-					//             |-- this insert --|| <- existing delete shifted along to end of this op
-					// 
-					// After:                         |-- existing insert --|  
-					//             |-- this insert --|| <- existing delete
-					// 
-					// Without the delete, the inserts would be merged.
-					const is_insert_blocked_by_delete = ((previous_change != null) && (previous_change.op.d != null) && (previous_change.op.p === op_end));
-
-					// If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
-					// then we merge them into one.
-					if (this.track_changes &&
-							is_change_overlapping &&
-							!is_insert_blocked_by_delete &&
-							!already_merged &&
-							!will_op_cancel_next_delete &&
-							is_same_user) {
-						offset = op_start - change_start;
-						change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset);
-						change.metadata.ts = metadata.ts;
-						already_merged = true;
-						moved_changes.push(change);
-					} else if (op_start <= change_start) {
-						// If we're fully before the other insert we can just shift the other insert by our length.
-						// If they are touching, and should have been merged, they will have been above.
-						// If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well
-						change.op.p += op_length;
-						moved_changes.push(change);
-					} else if ((!is_same_user || !this.track_changes) && (change_start < op_start && op_start < change_end)) {
-						// This user is inserting inside a change by another user, so we need to split the
-						// other user's change into one before and after this one.
-						offset = op_start - change_start;
-						const before_content = change.op.i.slice(0, offset);
-						const after_content = change.op.i.slice(offset);
-						
-						// The existing change can become the 'before' change
-						change.op.i = before_content;
-						moved_changes.push(change);
-						
-						// Create a new op afterwards
-						const after_change = {
-							op: {
-								i: after_content,
-								p: change_start + offset + op_length
-							},
-							metadata: {}
-						};
-						for (const key in change.metadata) { const value = change.metadata[key]; after_change.metadata[key] = value; }
-						new_changes.push(after_change);
-					}
-				}
-						
-				previous_change = change;
-			}
-
-			if (this.track_changes && !already_merged) {
-				this._addOp(op, metadata);
-			}
-			for ({op, metadata} of Array.from(new_changes)) {
-				this._addOp(op, metadata);
-			}
-		
-			for (change of Array.from(remove_changes)) {
-				this._removeChange(change);
-			}
-			
-			return (() => {
-				const result = [];
-				for (change of Array.from(moved_changes)) {
-					result.push(this._markAsDirty(change, "change", "moved"));
-				}
-				return result;
-			})();
-		}
-		
-		applyDeleteToChanges(op, metadata) {
-			let change;
-			const op_start = op.p;
-			const op_length = op.d.length;
-			const op_end = op.p + op_length;
-			const remove_changes = [];
-			let moved_changes = [];
-			
-			// We might end up modifying our delete op if it merges with existing deletes, or cancels out
-			// with an existing insert. Since we might do multiple modifications, we record them and do
-			// all the modifications after looping through the existing changes, so as not to mess up the
-			// offset indexes as we go.
-			const op_modifications = [];
-			for (change of Array.from(this.changes)) {
-				var change_start;
-				if (change.op.i != null) {
-					change_start = change.op.p;
-					const change_end = change_start + change.op.i.length;
-					if (op_end <= change_start) {
-						// Shift ops after us back by our length
-						change.op.p -= op_length;
-						moved_changes.push(change);
-					} else if (op_start >= change_end) {
-						// Delete is after insert, nothing to do
-					} else {
-						// When the new delete overlaps an insert, we should remove the part of the insert that
-						// is now deleted, and also remove the part of the new delete that overlapped. I.e.
-						// the two cancel out where they overlap.
-						var delete_remaining_after, delete_remaining_before, insert_remaining_after, insert_remaining_before;
-						if (op_start >= change_start) {
-							//                            |-- existing insert --|
-							// insert_remaining_before -> |.....||--   new delete   --|
-							delete_remaining_before = "";
-							insert_remaining_before = change.op.i.slice(0, op_start - change_start);
-						} else {
-							// delete_remaining_before -> |.....||-- existing insert --|
-							//                            |-- new delete   --|
-							delete_remaining_before = op.d.slice(0, change_start - op_start);
-							insert_remaining_before = "";
-						}
-
-						if (op_end <= change_end) {
-							//    |--  existing insert  --|
-							// |--  new delete   --||.....| <- insert_remaining_after
-							delete_remaining_after = "";
-							insert_remaining_after = change.op.i.slice(op_end - change_start);
-						} else {
-							// |--  existing insert  --||.....| <- delete_remaining_after
-							//            |--  new delete   --|
-							delete_remaining_after = op.d.slice(change_end - op_start);
-							insert_remaining_after = "";
-						}
-
-						const insert_remaining = insert_remaining_before + insert_remaining_after;
-						if (insert_remaining.length > 0) {
-							change.op.i = insert_remaining;
-							change.op.p = Math.min(change_start, op_start);
-							change.metadata.ts = metadata.ts;
-							moved_changes.push(change);
-						} else {
-							remove_changes.push(change);
-						}
-
-						// We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
-						// afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the 
-						// chunk in the middle not covered by these.
-						const delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length;
-						const delete_removed_start = delete_remaining_before.length;
-						const modification = {
-							d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length),
-							p: delete_removed_start
-						};
-						if (modification.d.length > 0) {
-							op_modifications.push(modification);
-						}
-					}
-				} else if (change.op.d != null) {
-					change_start = change.op.p;
-					if ((op_end < change_start) || (!this.track_changes && (op_end === change_start))) {
-						// Shift ops after us back by our length.
-						// If we're tracking changes, it must be strictly before, since we'll merge 
-						// below if they are touching. Otherwise, touching is fine.
-						change.op.p -= op_length;
-						moved_changes.push(change);
-					} else if (op_start <= change_start && change_start <= op_end) {
-						if (this.track_changes) {
-							// If we overlap a delete, add it in our content, and delete the existing change.
-							// It's easier to do it this way, rather than modifying the existing delete in case
-							// we overlap many deletes and we'd need to track that. We have a workaround to
-							// update the delete in place if possible below.
-							const offset = change_start - op_start;
-							op_modifications.push({ i: change.op.d, p: offset });
-							remove_changes.push(change);
-						} else {
-							change.op.p = op_start;
-							moved_changes.push(change);
-						}
-					}
-				}
-			}
-
-			// Copy rather than modify because we still need to apply it to comments
-			op = {
-				p: op.p,
-				d: this._applyOpModifications(op.d, op_modifications)
-			};
-
-			for (change of Array.from(remove_changes)) {
-				// This is a bit of hack to avoid removing one delete and replacing it with another.
-				// If we don't do this, it causes the UI to flicker
-				if ((op.d.length > 0) && (change.op.d != null) && (op.p <= change.op.p && change.op.p <= op.p + op.d.length)) {
-					change.op.p = op.p;
-					change.op.d = op.d;
-					change.metadata = metadata;
-					moved_changes.push(change);
-					op.d = ""; // stop it being added
-				} else {
-					this._removeChange(change);
-				}
-			}
-
-			if (this.track_changes && (op.d.length > 0)) {
-				this._addOp(op, metadata);
-			} else {
-				// It's possible that we deleted an insert between two other inserts. I.e.
-				// If we delete 'user_2 insert' in:
-				//   |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
-				// it becomes:
-				//   |-- user_1 insert --||-- user_1 insert --|
-				// We need to merge these together again
-				const results = this._scanAndMergeAdjacentUpdates();
-				moved_changes = moved_changes.concat(results.moved_changes);
-				for (change of Array.from(results.remove_changes)) {
-					this._removeChange(change);
-					moved_changes = moved_changes.filter(c => c !== change);
-				}
-			}
-			
-			return (() => {
-				const result = [];
-				for (change of Array.from(moved_changes)) {
-					result.push(this._markAsDirty(change, "change", "moved"));
-				}
-				return result;
-			})();
-		}
-
-		_addOp(op, metadata) {
-			const change = {
-				id: this.newId(),
-				op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes
-				metadata: this._clone(metadata)
-			};
-			this.changes.push(change);
-
-			// Keep ops in order of offset, with deletes before inserts
-			this.changes.sort(function(c1, c2) {
-				const result = c1.op.p - c2.op.p;
-				if (result !== 0) {
-					return result;
-				} else if ((c1.op.i != null) && (c2.op.d != null)) {
-					return 1;
-				} else {
-					return -1;
-				}
-			});
-
-			return this._markAsDirty(change, "change", "added");
-		}
-		
-		_removeChange(change) {
-			this.changes = this.changes.filter(c => c.id !== change.id);
-			return this._markAsDirty(change, "change", "removed");
-		}
-			
-		_applyOpModifications(content, op_modifications) {
-			// Put in descending position order, with deleting first if at the same offset 
-			// (Inserting first would modify the content that the delete will delete)
-			op_modifications.sort(function(a, b) {
-				const result = b.p - a.p; 
-				if (result !== 0) {
-					return result;
-				} else if ((a.i != null) && (b.d != null)) {
-					return 1;
-				} else {
-					return -1;
-				}
-			});
-
-			for (const modification of Array.from(op_modifications)) {
-				if (modification.i != null) {
-					content = content.slice(0, modification.p) + modification.i + content.slice(modification.p);
-				} else if (modification.d != null) {
-					if (content.slice(modification.p, modification.p + modification.d.length) !== modification.d) {
-						throw new Error(`deleted content does not match. content: ${JSON.stringify(content)}; modification: ${JSON.stringify(modification)}`);
-					}
-					content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length);
-				}
-			}
-			return content;
-		}
-		
-		_scanAndMergeAdjacentUpdates() {
-			// This should only need calling when deleting an update between two
-			// other updates. There's no other way to get two adjacent updates from the
-			// same user, since they would be merged on insert.
-			let previous_change = null;
-			const remove_changes = [];
-			const moved_changes = [];
-			for (const change of Array.from(this.changes)) {
-				if (((previous_change != null ? previous_change.op.i : undefined) != null) && (change.op.i != null)) {
-					const previous_change_end = previous_change.op.p + previous_change.op.i.length;
-					const previous_change_user_id = previous_change.metadata.user_id;
-					const change_start = change.op.p;
-					const change_user_id = change.metadata.user_id;
-					if ((previous_change_end === change_start) && (previous_change_user_id === change_user_id)) {
-						remove_changes.push(change);
-						previous_change.op.i += change.op.i;
-						moved_changes.push(previous_change);
-					}
-				} else if (((previous_change != null ? previous_change.op.d : undefined) != null) && (change.op.d != null) && (previous_change.op.p === change.op.p)) {
-					// Merge adjacent deletes
-					previous_change.op.d += change.op.d;
-					remove_changes.push(change);
-					moved_changes.push(previous_change);
-				} else { // Only update to the current change if we haven't removed it.
-					previous_change = change;
-				}
-			}
-			return { moved_changes, remove_changes };
-		}
-		
-		resetDirtyState() {
-			return this._dirtyState = {
-				comment: {
-					moved: {},
-					removed: {},
-					added: {}
-				},
-				change: {
-					moved: {},
-					removed: {},
-					added: {}
-				}
-			};
-		}
-		
-		getDirtyState() {
-			return this._dirtyState;
-		}
-		
-		_markAsDirty(object, type, action) {
-			return this._dirtyState[type][action][object.id] = object;
-		}
-		
-		_clone(object) {
-			const clone = {};
-			for (const k in object) { const v = object[k]; clone[k] = v; }
-			return clone;
-		}
-	};
-};
+const load = function () {
+  let RangesTracker
+  return (RangesTracker = class RangesTracker {
+    // The purpose of this class is to track a set of inserts and deletes to a document, like
+    // track changes in Word. We store these as a set of ShareJs style ranges:
+    //   {i: "foo", p: 42} # Insert 'foo' at offset 42
+    //   {d: "bar", p: 37} # Delete 'bar' at offset 37
+    // We only track the inserts and deletes, not the whole document, but by being given all
+    // updates that are applied to a document, we can update these appropriately.
+    //
+    // Note that the set of inserts and deletes we store applies to the document as-is at the moment.
+    // So inserts correspond to text which is in the document, while deletes correspond to text which
+    // is no longer there, so their lengths do not affect the position of later offsets.
+    // E.g.
+    //             this is the current text of the document
+    //                         |-----|            |
+    //  {i: "current ", p:12} -^                   ^- {d: "old ", p: 31}
+    //
+    // Track changes rules (should be consistent with Word):
+    //   * When text is inserted at a delete, the text goes to the left of the delete
+    //       I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
+    //   * Deleting content flagged as 'inserted' does not create a new delete marker, it only
+    //     removes the insert marker. E.g.
+    //       * "abdefghijkl"        -> "abfghijkl"        when 'de' is deleted. No delete marker added
+    //           |---| <- inserted       |-| <- inserted
+    //       * Deletes overlapping regular text and inserted text will insert a delete marker for the
+    //         regular text:
+    //         "abcdefghijkl"    ->    "abcdejkl"   when 'fghi' is deleted
+    //           |----|                  |--||
+    //           ^- inserted 'bcdefg'      \ ^- deleted 'hi'
+    //                                      \--inserted 'bcde'
+    //   * Deletes overlapping other deletes are merged. E.g.
+    //      "abcghijkl"        ->   "ahijkl"     when 'bcg is deleted'
+    //          | <- delete 'def'     | <- delete 'bcdefg'
+    //   * Deletes by another user will consume deletes by the first user
+    //   * Inserts by another user will not combine with inserts by the first user. If they are in the
+    //     middle of a previous insert by the first user, the original insert will be split into two.
+    constructor(changes, comments) {
+      if (changes == null) {
+        changes = []
+      }
+      this.changes = changes
+      if (comments == null) {
+        comments = []
+      }
+      this.comments = comments
+      this.setIdSeed(RangesTracker.generateIdSeed())
+      this.resetDirtyState()
+    }
+
+    getIdSeed() {
+      return this.id_seed
+    }
+
+    setIdSeed(seed) {
+      this.id_seed = seed
+      return (this.id_increment = 0)
+    }
+
+    static generateIdSeed() {
+      // Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part
+      // Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js
+      const pid = Math.floor(Math.random() * 32767).toString(16)
+      const machine = Math.floor(Math.random() * 16777216).toString(16)
+      const timestamp = Math.floor(new Date().valueOf() / 1000).toString(16)
+      return (
+        '00000000'.substr(0, 8 - timestamp.length) +
+        timestamp +
+        '000000'.substr(0, 6 - machine.length) +
+        machine +
+        '0000'.substr(0, 4 - pid.length) +
+        pid
+      )
+    }
+
+    static generateId() {
+      return this.generateIdSeed() + '000001'
+    }
+
+    newId() {
+      this.id_increment++
+      const increment = this.id_increment.toString(16)
+      const id =
+        this.id_seed + '000000'.substr(0, 6 - increment.length) + increment
+      return id
+    }
+
+    getComment(comment_id) {
+      let comment = null
+      for (const c of Array.from(this.comments)) {
+        if (c.id === comment_id) {
+          comment = c
+          break
+        }
+      }
+      return comment
+    }
+
+    removeCommentId(comment_id) {
+      const comment = this.getComment(comment_id)
+      if (comment == null) {
+        return
+      }
+      this.comments = this.comments.filter((c) => c.id !== comment_id)
+      return this._markAsDirty(comment, 'comment', 'removed')
+    }
+
+    moveCommentId(comment_id, position, text) {
+      return (() => {
+        const result = []
+        for (const comment of Array.from(this.comments)) {
+          if (comment.id === comment_id) {
+            comment.op.p = position
+            comment.op.c = text
+            result.push(this._markAsDirty(comment, 'comment', 'moved'))
+          } else {
+            result.push(undefined)
+          }
+        }
+        return result
+      })()
+    }
+
+    getChange(change_id) {
+      let change = null
+      for (const c of Array.from(this.changes)) {
+        if (c.id === change_id) {
+          change = c
+          break
+        }
+      }
+      return change
+    }
+
+    getChanges(change_ids) {
+      const changes_response = []
+      const ids_map = {}
+
+      for (const change_id of Array.from(change_ids)) {
+        ids_map[change_id] = true
+      }
+
+      for (const change of Array.from(this.changes)) {
+        if (ids_map[change.id]) {
+          delete ids_map[change.id]
+          changes_response.push(change)
+        }
+      }
+
+      return changes_response
+    }
+
+    removeChangeId(change_id) {
+      const change = this.getChange(change_id)
+      if (change == null) {
+        return
+      }
+      return this._removeChange(change)
+    }
+
+    removeChangeIds(change_to_remove_ids) {
+      if (
+        !(change_to_remove_ids != null
+          ? change_to_remove_ids.length
+          : undefined) > 0
+      ) {
+        return
+      }
+      const i = this.changes.length
+      const remove_change_id = {}
+      for (const change_id of Array.from(change_to_remove_ids)) {
+        remove_change_id[change_id] = true
+      }
+
+      const remaining_changes = []
+
+      for (const change of Array.from(this.changes)) {
+        if (remove_change_id[change.id]) {
+          delete remove_change_id[change.id]
+          this._markAsDirty(change, 'change', 'removed')
+        } else {
+          remaining_changes.push(change)
+        }
+      }
+
+      return (this.changes = remaining_changes)
+    }
+
+    validate(text) {
+      let content
+      for (const change of Array.from(this.changes)) {
+        if (change.op.i != null) {
+          content = text.slice(change.op.p, change.op.p + change.op.i.length)
+          if (content !== change.op.i) {
+            throw new Error(
+              `Change (${JSON.stringify(
+                change
+              )}) doesn't match text (${JSON.stringify(content)})`
+            )
+          }
+        }
+      }
+      for (const comment of Array.from(this.comments)) {
+        content = text.slice(comment.op.p, comment.op.p + comment.op.c.length)
+        if (content !== comment.op.c) {
+          throw new Error(
+            `Comment (${JSON.stringify(
+              comment
+            )}) doesn't match text (${JSON.stringify(content)})`
+          )
+        }
+      }
+      return true
+    }
+
+    applyOp(op, metadata) {
+      if (metadata == null) {
+        metadata = {}
+      }
+      if (metadata.ts == null) {
+        metadata.ts = new Date()
+      }
+      // Apply an op that has been applied to the document to our changes to keep them up to date
+      if (op.i != null) {
+        this.applyInsertToChanges(op, metadata)
+        return this.applyInsertToComments(op)
+      } else if (op.d != null) {
+        this.applyDeleteToChanges(op, metadata)
+        return this.applyDeleteToComments(op)
+      } else if (op.c != null) {
+        return this.addComment(op, metadata)
+      } else {
+        throw new Error('unknown op type')
+      }
+    }
+
+    applyOps(ops, metadata) {
+      if (metadata == null) {
+        metadata = {}
+      }
+      return Array.from(ops).map((op) => this.applyOp(op, metadata))
+    }
+
+    addComment(op, metadata) {
+      const existing = this.getComment(op.t)
+      if (existing != null) {
+        this.moveCommentId(op.t, op.p, op.c)
+        return existing
+      } else {
+        let comment
+        this.comments.push(
+          (comment = {
+            id: op.t || this.newId(),
+            op: {
+              // Copy because we'll modify in place
+              c: op.c,
+              p: op.p,
+              t: op.t
+            },
+            metadata
+          })
+        )
+        this._markAsDirty(comment, 'comment', 'added')
+        return comment
+      }
+    }
+
+    applyInsertToComments(op) {
+      return (() => {
+        const result = []
+        for (const comment of Array.from(this.comments)) {
+          if (op.p <= comment.op.p) {
+            comment.op.p += op.i.length
+            result.push(this._markAsDirty(comment, 'comment', 'moved'))
+          } else if (op.p < comment.op.p + comment.op.c.length) {
+            const offset = op.p - comment.op.p
+            comment.op.c =
+              comment.op.c.slice(0, +(offset - 1) + 1 || undefined) +
+              op.i +
+              comment.op.c.slice(offset)
+            result.push(this._markAsDirty(comment, 'comment', 'moved'))
+          } else {
+            result.push(undefined)
+          }
+        }
+        return result
+      })()
+    }
+
+    applyDeleteToComments(op) {
+      const op_start = op.p
+      const op_length = op.d.length
+      const op_end = op.p + op_length
+      return (() => {
+        const result = []
+        for (const comment of Array.from(this.comments)) {
+          const comment_start = comment.op.p
+          const comment_end = comment.op.p + comment.op.c.length
+          const comment_length = comment_end - comment_start
+          if (op_end <= comment_start) {
+            // delete is fully before comment
+            comment.op.p -= op_length
+            result.push(this._markAsDirty(comment, 'comment', 'moved'))
+          } else if (op_start >= comment_end) {
+            // delete is fully after comment, nothing to do
+          } else {
+            // delete and comment overlap
+            var remaining_after, remaining_before
+            if (op_start <= comment_start) {
+              remaining_before = ''
+            } else {
+              remaining_before = comment.op.c.slice(0, op_start - comment_start)
+            }
+            if (op_end >= comment_end) {
+              remaining_after = ''
+            } else {
+              remaining_after = comment.op.c.slice(op_end - comment_start)
+            }
+
+            // Check deleted content matches delete op
+            const deleted_comment = comment.op.c.slice(
+              remaining_before.length,
+              comment_length - remaining_after.length
+            )
+            const offset = Math.max(0, comment_start - op_start)
+            const deleted_op_content = op.d
+              .slice(offset)
+              .slice(0, deleted_comment.length)
+            if (deleted_comment !== deleted_op_content) {
+              throw new Error('deleted content does not match comment content')
+            }
+
+            comment.op.p = Math.min(comment_start, op_start)
+            comment.op.c = remaining_before + remaining_after
+            result.push(this._markAsDirty(comment, 'comment', 'moved'))
+          }
+        }
+        return result
+      })()
+    }
+
+    applyInsertToChanges(op, metadata) {
+      let change
+      const op_start = op.p
+      const op_length = op.i.length
+      const op_end = op.p + op_length
+      const undoing = !!op.u
+
+      let already_merged = false
+      let previous_change = null
+      const moved_changes = []
+      const remove_changes = []
+      const new_changes = []
+
+      for (let i = 0; i < this.changes.length; i++) {
+        change = this.changes[i]
+        const change_start = change.op.p
+
+        if (change.op.d != null) {
+          // Shift any deletes after this along by the length of this insert
+          if (op_start < change_start) {
+            change.op.p += op_length
+            moved_changes.push(change)
+          } else if (op_start === change_start) {
+            // If we are undoing, then we want to cancel any existing delete ranges if we can.
+            // Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
+            if (
+              undoing &&
+              change.op.d.length >= op.i.length &&
+              change.op.d.slice(0, op.i.length) === op.i
+            ) {
+              change.op.d = change.op.d.slice(op.i.length)
+              change.op.p += op.i.length
+              if (change.op.d === '') {
+                remove_changes.push(change)
+              } else {
+                moved_changes.push(change)
+              }
+              already_merged = true
+            } else {
+              change.op.p += op_length
+              moved_changes.push(change)
+            }
+          }
+        } else if (change.op.i != null) {
+          var offset
+          const change_end = change_start + change.op.i.length
+          const is_change_overlapping =
+            op_start >= change_start && op_start <= change_end
+
+          // Only merge inserts if they are from the same user
+          const is_same_user = metadata.user_id === change.metadata.user_id
+
+          // If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
+          // an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
+          // E.g.
+          //                   foo|<--- about to insert 'b' here
+          //  inserted 'foo'  --^ ^-- deleted 'bar'
+          // should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
+          const next_change = this.changes[i + 1]
+          const is_op_adjacent_to_next_delete =
+            next_change != null &&
+            next_change.op.d != null &&
+            op.p === change_end &&
+            next_change.op.p === op.p
+          const will_op_cancel_next_delete =
+            undoing &&
+            is_op_adjacent_to_next_delete &&
+            next_change.op.d.slice(0, op.i.length) === op.i
+
+          // If there is a delete at the start of the insert, and we're inserting
+          // at the start, we SHOULDN'T merge since the delete acts as a partition.
+          // The previous op will be the delete, but it's already been shifted by this insert
+          //
+          // I.e.
+          // Originally: |-- existing insert --|
+          //             | <- existing delete at same offset
+          //
+          // Now:        |-- existing insert --| <- not shifted yet
+          //             |-- this insert --|| <- existing delete shifted along to end of this op
+          //
+          // After:                         |-- existing insert --|
+          //             |-- this insert --|| <- existing delete
+          //
+          // Without the delete, the inserts would be merged.
+          const is_insert_blocked_by_delete =
+            previous_change != null &&
+            previous_change.op.d != null &&
+            previous_change.op.p === op_end
+
+          // If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
+          // then we merge them into one.
+          if (
+            this.track_changes &&
+            is_change_overlapping &&
+            !is_insert_blocked_by_delete &&
+            !already_merged &&
+            !will_op_cancel_next_delete &&
+            is_same_user
+          ) {
+            offset = op_start - change_start
+            change.op.i =
+              change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
+            change.metadata.ts = metadata.ts
+            already_merged = true
+            moved_changes.push(change)
+          } else if (op_start <= change_start) {
+            // If we're fully before the other insert we can just shift the other insert by our length.
+            // If they are touching, and should have been merged, they will have been above.
+            // If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well
+            change.op.p += op_length
+            moved_changes.push(change)
+          } else if (
+            (!is_same_user || !this.track_changes) &&
+            change_start < op_start &&
+            op_start < change_end
+          ) {
+            // This user is inserting inside a change by another user, so we need to split the
+            // other user's change into one before and after this one.
+            offset = op_start - change_start
+            const before_content = change.op.i.slice(0, offset)
+            const after_content = change.op.i.slice(offset)
+
+            // The existing change can become the 'before' change
+            change.op.i = before_content
+            moved_changes.push(change)
+
+            // Create a new op afterwards
+            const after_change = {
+              op: {
+                i: after_content,
+                p: change_start + offset + op_length
+              },
+              metadata: {}
+            }
+            for (const key in change.metadata) {
+              const value = change.metadata[key]
+              after_change.metadata[key] = value
+            }
+            new_changes.push(after_change)
+          }
+        }
+
+        previous_change = change
+      }
+
+      if (this.track_changes && !already_merged) {
+        this._addOp(op, metadata)
+      }
+      for ({ op, metadata } of Array.from(new_changes)) {
+        this._addOp(op, metadata)
+      }
+
+      for (change of Array.from(remove_changes)) {
+        this._removeChange(change)
+      }
+
+      return (() => {
+        const result = []
+        for (change of Array.from(moved_changes)) {
+          result.push(this._markAsDirty(change, 'change', 'moved'))
+        }
+        return result
+      })()
+    }
+
+    applyDeleteToChanges(op, metadata) {
+      let change
+      const op_start = op.p
+      const op_length = op.d.length
+      const op_end = op.p + op_length
+      const remove_changes = []
+      let moved_changes = []
+
+      // We might end up modifying our delete op if it merges with existing deletes, or cancels out
+      // with an existing insert. Since we might do multiple modifications, we record them and do
+      // all the modifications after looping through the existing changes, so as not to mess up the
+      // offset indexes as we go.
+      const op_modifications = []
+      for (change of Array.from(this.changes)) {
+        var change_start
+        if (change.op.i != null) {
+          change_start = change.op.p
+          const change_end = change_start + change.op.i.length
+          if (op_end <= change_start) {
+            // Shift ops after us back by our length
+            change.op.p -= op_length
+            moved_changes.push(change)
+          } else if (op_start >= change_end) {
+            // Delete is after insert, nothing to do
+          } else {
+            // When the new delete overlaps an insert, we should remove the part of the insert that
+            // is now deleted, and also remove the part of the new delete that overlapped. I.e.
+            // the two cancel out where they overlap.
+            var delete_remaining_after,
+              delete_remaining_before,
+              insert_remaining_after,
+              insert_remaining_before
+            if (op_start >= change_start) {
+              //                            |-- existing insert --|
+              // insert_remaining_before -> |.....||--   new delete   --|
+              delete_remaining_before = ''
+              insert_remaining_before = change.op.i.slice(
+                0,
+                op_start - change_start
+              )
+            } else {
+              // delete_remaining_before -> |.....||-- existing insert --|
+              //                            |-- new delete   --|
+              delete_remaining_before = op.d.slice(0, change_start - op_start)
+              insert_remaining_before = ''
+            }
+
+            if (op_end <= change_end) {
+              //    |--  existing insert  --|
+              // |--  new delete   --||.....| <- insert_remaining_after
+              delete_remaining_after = ''
+              insert_remaining_after = change.op.i.slice(op_end - change_start)
+            } else {
+              // |--  existing insert  --||.....| <- delete_remaining_after
+              //            |--  new delete   --|
+              delete_remaining_after = op.d.slice(change_end - op_start)
+              insert_remaining_after = ''
+            }
+
+            const insert_remaining =
+              insert_remaining_before + insert_remaining_after
+            if (insert_remaining.length > 0) {
+              change.op.i = insert_remaining
+              change.op.p = Math.min(change_start, op_start)
+              change.metadata.ts = metadata.ts
+              moved_changes.push(change)
+            } else {
+              remove_changes.push(change)
+            }
+
+            // We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
+            // afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the
+            // chunk in the middle not covered by these.
+            const delete_removed_length =
+              op.d.length -
+              delete_remaining_before.length -
+              delete_remaining_after.length
+            const delete_removed_start = delete_remaining_before.length
+            const modification = {
+              d: op.d.slice(
+                delete_removed_start,
+                delete_removed_start + delete_removed_length
+              ),
+              p: delete_removed_start
+            }
+            if (modification.d.length > 0) {
+              op_modifications.push(modification)
+            }
+          }
+        } else if (change.op.d != null) {
+          change_start = change.op.p
+          if (
+            op_end < change_start ||
+            (!this.track_changes && op_end === change_start)
+          ) {
+            // Shift ops after us back by our length.
+            // If we're tracking changes, it must be strictly before, since we'll merge
+            // below if they are touching. Otherwise, touching is fine.
+            change.op.p -= op_length
+            moved_changes.push(change)
+          } else if (op_start <= change_start && change_start <= op_end) {
+            if (this.track_changes) {
+              // If we overlap a delete, add it in our content, and delete the existing change.
+              // It's easier to do it this way, rather than modifying the existing delete in case
+              // we overlap many deletes and we'd need to track that. We have a workaround to
+              // update the delete in place if possible below.
+              const offset = change_start - op_start
+              op_modifications.push({ i: change.op.d, p: offset })
+              remove_changes.push(change)
+            } else {
+              change.op.p = op_start
+              moved_changes.push(change)
+            }
+          }
+        }
+      }
+
+      // Copy rather than modify because we still need to apply it to comments
+      op = {
+        p: op.p,
+        d: this._applyOpModifications(op.d, op_modifications)
+      }
+
+      for (change of Array.from(remove_changes)) {
+        // This is a bit of hack to avoid removing one delete and replacing it with another.
+        // If we don't do this, it causes the UI to flicker
+        if (
+          op.d.length > 0 &&
+          change.op.d != null &&
+          op.p <= change.op.p &&
+          change.op.p <= op.p + op.d.length
+        ) {
+          change.op.p = op.p
+          change.op.d = op.d
+          change.metadata = metadata
+          moved_changes.push(change)
+          op.d = '' // stop it being added
+        } else {
+          this._removeChange(change)
+        }
+      }
+
+      if (this.track_changes && op.d.length > 0) {
+        this._addOp(op, metadata)
+      } else {
+        // It's possible that we deleted an insert between two other inserts. I.e.
+        // If we delete 'user_2 insert' in:
+        //   |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
+        // it becomes:
+        //   |-- user_1 insert --||-- user_1 insert --|
+        // We need to merge these together again
+        const results = this._scanAndMergeAdjacentUpdates()
+        moved_changes = moved_changes.concat(results.moved_changes)
+        for (change of Array.from(results.remove_changes)) {
+          this._removeChange(change)
+          moved_changes = moved_changes.filter((c) => c !== change)
+        }
+      }
+
+      return (() => {
+        const result = []
+        for (change of Array.from(moved_changes)) {
+          result.push(this._markAsDirty(change, 'change', 'moved'))
+        }
+        return result
+      })()
+    }
+
+    _addOp(op, metadata) {
+      const change = {
+        id: this.newId(),
+        op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes
+        metadata: this._clone(metadata)
+      }
+      this.changes.push(change)
+
+      // Keep ops in order of offset, with deletes before inserts
+      this.changes.sort(function (c1, c2) {
+        const result = c1.op.p - c2.op.p
+        if (result !== 0) {
+          return result
+        } else if (c1.op.i != null && c2.op.d != null) {
+          return 1
+        } else {
+          return -1
+        }
+      })
+
+      return this._markAsDirty(change, 'change', 'added')
+    }
+
+    _removeChange(change) {
+      this.changes = this.changes.filter((c) => c.id !== change.id)
+      return this._markAsDirty(change, 'change', 'removed')
+    }
+
+    _applyOpModifications(content, op_modifications) {
+      // Put in descending position order, with deleting first if at the same offset
+      // (Inserting first would modify the content that the delete will delete)
+      op_modifications.sort(function (a, b) {
+        const result = b.p - a.p
+        if (result !== 0) {
+          return result
+        } else if (a.i != null && b.d != null) {
+          return 1
+        } else {
+          return -1
+        }
+      })
+
+      for (const modification of Array.from(op_modifications)) {
+        if (modification.i != null) {
+          content =
+            content.slice(0, modification.p) +
+            modification.i +
+            content.slice(modification.p)
+        } else if (modification.d != null) {
+          if (
+            content.slice(
+              modification.p,
+              modification.p + modification.d.length
+            ) !== modification.d
+          ) {
+            throw new Error(
+              `deleted content does not match. content: ${JSON.stringify(
+                content
+              )}; modification: ${JSON.stringify(modification)}`
+            )
+          }
+          content =
+            content.slice(0, modification.p) +
+            content.slice(modification.p + modification.d.length)
+        }
+      }
+      return content
+    }
+
+    _scanAndMergeAdjacentUpdates() {
+      // This should only need calling when deleting an update between two
+      // other updates. There's no other way to get two adjacent updates from the
+      // same user, since they would be merged on insert.
+      let previous_change = null
+      const remove_changes = []
+      const moved_changes = []
+      for (const change of Array.from(this.changes)) {
+        if (
+          (previous_change != null ? previous_change.op.i : undefined) !=
+            null &&
+          change.op.i != null
+        ) {
+          const previous_change_end =
+            previous_change.op.p + previous_change.op.i.length
+          const previous_change_user_id = previous_change.metadata.user_id
+          const change_start = change.op.p
+          const change_user_id = change.metadata.user_id
+          if (
+            previous_change_end === change_start &&
+            previous_change_user_id === change_user_id
+          ) {
+            remove_changes.push(change)
+            previous_change.op.i += change.op.i
+            moved_changes.push(previous_change)
+          }
+        } else if (
+          (previous_change != null ? previous_change.op.d : undefined) !=
+            null &&
+          change.op.d != null &&
+          previous_change.op.p === change.op.p
+        ) {
+          // Merge adjacent deletes
+          previous_change.op.d += change.op.d
+          remove_changes.push(change)
+          moved_changes.push(previous_change)
+        } else {
+          // Only update to the current change if we haven't removed it.
+          previous_change = change
+        }
+      }
+      return { moved_changes, remove_changes }
+    }
+
+    resetDirtyState() {
+      return (this._dirtyState = {
+        comment: {
+          moved: {},
+          removed: {},
+          added: {}
+        },
+        change: {
+          moved: {},
+          removed: {},
+          added: {}
+        }
+      })
+    }
+
+    getDirtyState() {
+      return this._dirtyState
+    }
+
+    _markAsDirty(object, type, action) {
+      return (this._dirtyState[type][action][object.id] = object)
+    }
+
+    _clone(object) {
+      const clone = {}
+      for (const k in object) {
+        const v = object[k]
+        clone[k] = v
+      }
+      return clone
+    }
+  })
+}
 
 if (typeof define !== 'undefined' && define !== null) {
-	define([], load);
+  define([], load)
 } else {
-	module.exports = load();
+  module.exports = load()
 }

+ 64 - 47
services/document-updater/app/js/RateLimitManager.js

@@ -9,55 +9,72 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let RateLimiter;
-const Settings = require('settings-sharelatex');
-const logger = require('logger-sharelatex');
-const Metrics = require('./Metrics');
+let RateLimiter
+const Settings = require('settings-sharelatex')
+const logger = require('logger-sharelatex')
+const Metrics = require('./Metrics')
 
-module.exports = (RateLimiter = class RateLimiter {
+module.exports = RateLimiter = class RateLimiter {
+  constructor(number) {
+    if (number == null) {
+      number = 10
+    }
+    this.ActiveWorkerCount = 0
+    this.CurrentWorkerLimit = number
+    this.BaseWorkerCount = number
+  }
 
-	constructor(number) {
-		if (number == null) { number = 10; }
-		this.ActiveWorkerCount = 0;
-		this.CurrentWorkerLimit = number;
-		this.BaseWorkerCount = number;
-	}
+  _adjustLimitUp() {
+    this.CurrentWorkerLimit += 0.1 // allow target worker limit to increase gradually
+    return Metrics.gauge('currentLimit', Math.ceil(this.CurrentWorkerLimit))
+  }
 
-	_adjustLimitUp() {
-		this.CurrentWorkerLimit += 0.1; // allow target worker limit to increase gradually
-		return Metrics.gauge("currentLimit", Math.ceil(this.CurrentWorkerLimit));
-	}
+  _adjustLimitDown() {
+    this.CurrentWorkerLimit = Math.max(
+      this.BaseWorkerCount,
+      this.CurrentWorkerLimit * 0.9
+    )
+    logger.log(
+      { currentLimit: Math.ceil(this.CurrentWorkerLimit) },
+      'reducing rate limit'
+    )
+    return Metrics.gauge('currentLimit', Math.ceil(this.CurrentWorkerLimit))
+  }
 
-	_adjustLimitDown() {
-		this.CurrentWorkerLimit = Math.max(this.BaseWorkerCount, (this.CurrentWorkerLimit * 0.9));
-		logger.log({currentLimit: Math.ceil(this.CurrentWorkerLimit)}, "reducing rate limit");
-		return Metrics.gauge("currentLimit", Math.ceil(this.CurrentWorkerLimit));
-	}
+  _trackAndRun(task, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    this.ActiveWorkerCount++
+    Metrics.gauge('processingUpdates', this.ActiveWorkerCount)
+    return task((err) => {
+      this.ActiveWorkerCount--
+      Metrics.gauge('processingUpdates', this.ActiveWorkerCount)
+      return callback(err)
+    })
+  }
 
-	_trackAndRun(task, callback) {
-		if (callback == null) { callback = function() {}; }
-		this.ActiveWorkerCount++;
-		Metrics.gauge("processingUpdates", this.ActiveWorkerCount);
-		return task(err => {
-			this.ActiveWorkerCount--;
-			Metrics.gauge("processingUpdates", this.ActiveWorkerCount);
-			return callback(err);
-		});
-	}
-
-	run(task, callback) {
-		if (this.ActiveWorkerCount < this.CurrentWorkerLimit) {
-			this._trackAndRun(task);  // below the limit, just put the task in the background
-			callback();         // return immediately
-			if (this.CurrentWorkerLimit > this.BaseWorkerCount) {
-				return this._adjustLimitDown();
-			}
-		} else {
-			logger.log({active: this.ActiveWorkerCount, currentLimit: Math.ceil(this.CurrentWorkerLimit)}, "hit rate limit");
-			return this._trackAndRun(task, err => {
-				if ((err == null)) { this._adjustLimitUp(); } // don't increment rate limit if there was an error
-				return callback(err);
-			}); // only return after task completes
-		}
-	}
-});
+  run(task, callback) {
+    if (this.ActiveWorkerCount < this.CurrentWorkerLimit) {
+      this._trackAndRun(task) // below the limit, just put the task in the background
+      callback() // return immediately
+      if (this.CurrentWorkerLimit > this.BaseWorkerCount) {
+        return this._adjustLimitDown()
+      }
+    } else {
+      logger.log(
+        {
+          active: this.ActiveWorkerCount,
+          currentLimit: Math.ceil(this.CurrentWorkerLimit)
+        },
+        'hit rate limit'
+      )
+      return this._trackAndRun(task, (err) => {
+        if (err == null) {
+          this._adjustLimitUp()
+        } // don't increment rate limit if there was an error
+        return callback(err)
+      }) // only return after task completes
+    }
+  }
+}

+ 67 - 59
services/document-updater/app/js/RealTimeRedisManager.js

@@ -11,69 +11,77 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let RealTimeRedisManager;
-const Settings = require('settings-sharelatex');
-const rclient = require("redis-sharelatex").createClient(Settings.redis.documentupdater);
-const pubsubClient = require("redis-sharelatex").createClient(Settings.redis.pubsub);
-const Keys = Settings.redis.documentupdater.key_schema;
-const logger = require('logger-sharelatex');
-const os = require("os");
-const crypto = require("crypto");
-const metrics = require('./Metrics');
+let RealTimeRedisManager
+const Settings = require('settings-sharelatex')
+const rclient = require('redis-sharelatex').createClient(
+  Settings.redis.documentupdater
+)
+const pubsubClient = require('redis-sharelatex').createClient(
+  Settings.redis.pubsub
+)
+const Keys = Settings.redis.documentupdater.key_schema
+const logger = require('logger-sharelatex')
+const os = require('os')
+const crypto = require('crypto')
+const metrics = require('./Metrics')
 
-const HOST = os.hostname();
-const RND = crypto.randomBytes(4).toString('hex'); // generate a random key for this process
-let COUNT = 0;
+const HOST = os.hostname()
+const RND = crypto.randomBytes(4).toString('hex') // generate a random key for this process
+let COUNT = 0
 
-const MAX_OPS_PER_ITERATION = 8; // process a limited number of ops for safety
+const MAX_OPS_PER_ITERATION = 8 // process a limited number of ops for safety
 
-module.exports = (RealTimeRedisManager = {
-	getPendingUpdatesForDoc(doc_id, callback){
-		const multi = rclient.multi();
-		multi.lrange(Keys.pendingUpdates({doc_id}), 0, (MAX_OPS_PER_ITERATION-1));
-		multi.ltrim(Keys.pendingUpdates({doc_id}), MAX_OPS_PER_ITERATION, -1);
-		return multi.exec(function(error, replys) {
-			let jsonUpdate;
-			if (error != null) { return callback(error); }
-			const jsonUpdates = replys[0];
-			for (jsonUpdate of Array.from(jsonUpdates)) {
-				// record metric for each update removed from queue
-				metrics.summary("redis.pendingUpdates", jsonUpdate.length, {status: "pop"});
-			}
-			const updates = [];
-			for (jsonUpdate of Array.from(jsonUpdates)) {
-				var update;
-				try {
-					update = JSON.parse(jsonUpdate);
-				} catch (e) {
-					return callback(e);
-				}
-				updates.push(update);
-			}
-			return callback(error, updates);
-		});
-	},
+module.exports = RealTimeRedisManager = {
+  getPendingUpdatesForDoc(doc_id, callback) {
+    const multi = rclient.multi()
+    multi.lrange(Keys.pendingUpdates({ doc_id }), 0, MAX_OPS_PER_ITERATION - 1)
+    multi.ltrim(Keys.pendingUpdates({ doc_id }), MAX_OPS_PER_ITERATION, -1)
+    return multi.exec(function (error, replys) {
+      let jsonUpdate
+      if (error != null) {
+        return callback(error)
+      }
+      const jsonUpdates = replys[0]
+      for (jsonUpdate of Array.from(jsonUpdates)) {
+        // record metric for each update removed from queue
+        metrics.summary('redis.pendingUpdates', jsonUpdate.length, {
+          status: 'pop'
+        })
+      }
+      const updates = []
+      for (jsonUpdate of Array.from(jsonUpdates)) {
+        var update
+        try {
+          update = JSON.parse(jsonUpdate)
+        } catch (e) {
+          return callback(e)
+        }
+        updates.push(update)
+      }
+      return callback(error, updates)
+    })
+  },
 
-	getUpdatesLength(doc_id, callback){
-		return rclient.llen(Keys.pendingUpdates({doc_id}), callback);
-	},
+  getUpdatesLength(doc_id, callback) {
+    return rclient.llen(Keys.pendingUpdates({ doc_id }), callback)
+  },
 
-	sendData(data) {
-		// create a unique message id using a counter
-		const message_id = `doc:${HOST}:${RND}-${COUNT++}`;
-		if (data != null) {
-			data._id = message_id;
-		}
+  sendData(data) {
+    // create a unique message id using a counter
+    const message_id = `doc:${HOST}:${RND}-${COUNT++}`
+    if (data != null) {
+      data._id = message_id
+    }
 
-		const blob = JSON.stringify(data);
-		metrics.summary("redis.publish.applied-ops", blob.length);
+    const blob = JSON.stringify(data)
+    metrics.summary('redis.publish.applied-ops', blob.length)
 
-		// publish on separate channels for individual projects and docs when
-		// configured (needs realtime to be configured for this too).
-		if (Settings.publishOnIndividualChannels) {
-			return pubsubClient.publish(`applied-ops:${data.doc_id}`, blob);
-		} else {
-			return pubsubClient.publish("applied-ops", blob);
-		}
-	}
-});
+    // publish on separate channels for individual projects and docs when
+    // configured (needs realtime to be configured for this too).
+    if (Settings.publishOnIndividualChannels) {
+      return pubsubClient.publish(`applied-ops:${data.doc_id}`, blob)
+    } else {
+      return pubsubClient.publish('applied-ops', blob)
+    }
+  }
+}

+ 738 - 466
services/document-updater/app/js/RedisManager.js

@@ -13,478 +13,750 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let RedisManager;
-const Settings = require('settings-sharelatex');
-const rclient = require("redis-sharelatex").createClient(Settings.redis.documentupdater);
-const logger = require('logger-sharelatex');
-const metrics = require('./Metrics');
-const Errors = require("./Errors");
-const crypto = require("crypto");
-const async = require("async");
-const ProjectHistoryRedisManager = require("./ProjectHistoryRedisManager");
+let RedisManager
+const Settings = require('settings-sharelatex')
+const rclient = require('redis-sharelatex').createClient(
+  Settings.redis.documentupdater
+)
+const logger = require('logger-sharelatex')
+const metrics = require('./Metrics')
+const Errors = require('./Errors')
+const crypto = require('crypto')
+const async = require('async')
+const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
 
 // Sometimes Redis calls take an unexpectedly long time.  We have to be
 // quick with Redis calls because we're holding a lock that expires
 // after 30 seconds. We can't let any errors in the rest of the stack
 // hold us up, and need to bail out quickly if there is a problem.
-const MAX_REDIS_REQUEST_LENGTH = 5000; // 5 seconds
+const MAX_REDIS_REQUEST_LENGTH = 5000 // 5 seconds
 
 // Make times easy to read
-const minutes = 60; // seconds for Redis expire
-
-const logHashErrors = Settings.documentupdater != null ? Settings.documentupdater.logHashErrors : undefined;
-const logHashReadErrors = logHashErrors != null ? logHashErrors.read : undefined;
-
-const MEGABYTES = 1024 * 1024;
-const MAX_RANGES_SIZE = 3 * MEGABYTES;
-
-const keys = Settings.redis.documentupdater.key_schema;
-const historyKeys = Settings.redis.history.key_schema; // note: this is track changes, not project-history
-
-module.exports = (RedisManager = {
-	rclient,
-
-	putDocInMemory(project_id, doc_id, docLines, version, ranges, pathname, projectHistoryId, _callback){
-		const timer = new metrics.Timer("redis.put-doc");
-		const callback = function(error) {
-			timer.done();
-			return _callback(error);
-		};
-		docLines = JSON.stringify(docLines);
-		if (docLines.indexOf("\u0000") !== -1) {
-			const error = new Error("null bytes found in doc lines");
-			// this check was added to catch memory corruption in JSON.stringify.
-			// It sometimes returned null bytes at the end of the string.
-			logger.error({err: error, doc_id, docLines}, error.message);
-			return callback(error);
-		}
-		const docHash = RedisManager._computeHash(docLines);
-		// record bytes sent to redis
-		metrics.summary("redis.docLines", docLines.length, {status: "set"});
-		logger.log({project_id, doc_id, version, docHash, pathname, projectHistoryId}, "putting doc in redis");
-		return RedisManager._serializeRanges(ranges, function(error, ranges) {
-			if (error != null) {
-				logger.error({err: error, doc_id, project_id}, error.message);
-				return callback(error);
-			}
-			const multi = rclient.multi();
-			multi.set(keys.docLines({doc_id}), docLines);
-			multi.set(keys.projectKey({doc_id}), project_id);
-			multi.set(keys.docVersion({doc_id}), version);
-			multi.set(keys.docHash({doc_id}), docHash);
-			if (ranges != null) {
-				multi.set(keys.ranges({doc_id}), ranges);
-			} else {
-				multi.del(keys.ranges({doc_id}));
-			}
-			multi.set(keys.pathname({doc_id}), pathname);
-			multi.set(keys.projectHistoryId({doc_id}), projectHistoryId);
-			return multi.exec(function(error, result) {
-				if (error != null) { return callback(error); }
-				// update docsInProject set
-				return rclient.sadd(keys.docsInProject({project_id}), doc_id, callback);
-			});
-		});
-	},
-
-	removeDocFromMemory(project_id, doc_id, _callback){
-		logger.log({project_id, doc_id}, "removing doc from redis");
-		const callback = function(err) {
-			if (err != null) {
-				logger.err({project_id, doc_id, err}, "error removing doc from redis");
-				return _callback(err);
-			} else {
-				logger.log({project_id, doc_id}, "removed doc from redis");
-				return _callback();
-			}
-		};
-
-		let multi = rclient.multi();
-		multi.strlen(keys.docLines({doc_id}));
-		multi.del(keys.docLines({doc_id}));
-		multi.del(keys.projectKey({doc_id}));
-		multi.del(keys.docVersion({doc_id}));
-		multi.del(keys.docHash({doc_id}));
-		multi.del(keys.ranges({doc_id}));
-		multi.del(keys.pathname({doc_id}));
-		multi.del(keys.projectHistoryId({doc_id}));
-		multi.del(keys.projectHistoryType({doc_id}));
-		multi.del(keys.unflushedTime({doc_id}));
-		multi.del(keys.lastUpdatedAt({doc_id}));
-		multi.del(keys.lastUpdatedBy({doc_id}));
-		return multi.exec(function(error, response) {
-			if (error != null) { return callback(error); }
-			const length = response != null ? response[0] : undefined;
-			if (length > 0) {
-				// record bytes freed in redis
-				metrics.summary("redis.docLines", length, {status: "del"});
-			}
-			multi = rclient.multi();
-			multi.srem(keys.docsInProject({project_id}), doc_id);
-			multi.del(keys.projectState({project_id}));
-			return multi.exec(callback);
-		});
-	},
-
-	checkOrSetProjectState(project_id, newState, callback) {
-		if (callback == null) { callback = function(error, stateChanged) {}; }
-		const multi = rclient.multi();
-		multi.getset(keys.projectState({project_id}), newState);
-		multi.expire(keys.projectState({project_id}), 30 * minutes);
-		return multi.exec(function(error, response) {
-			if (error != null) { return callback(error); }
-			logger.log({project_id, newState, oldState: response[0]}, "checking project state");
-			return callback(null, response[0] !== newState);
-		});
-	},
-
-	clearProjectState(project_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return rclient.del(keys.projectState({project_id}), callback);
-	},
-
-	getDoc(project_id, doc_id, callback){
-		if (callback == null) { callback = function(error, lines, version, ranges, pathname, projectHistoryId, unflushedTime) {}; }
-		const timer = new metrics.Timer("redis.get-doc");
-		const multi = rclient.multi();
-		multi.get(keys.docLines({doc_id}));
-		multi.get(keys.docVersion({doc_id}));
-		multi.get(keys.docHash({doc_id}));
-		multi.get(keys.projectKey({doc_id}));
-		multi.get(keys.ranges({doc_id}));
-		multi.get(keys.pathname({doc_id}));
-		multi.get(keys.projectHistoryId({doc_id}));
-		multi.get(keys.unflushedTime({doc_id}));
-		multi.get(keys.lastUpdatedAt({doc_id}));
-		multi.get(keys.lastUpdatedBy({doc_id}));
-		return multi.exec(function(error, ...rest){
-			let [docLines, version, storedHash, doc_project_id, ranges, pathname, projectHistoryId, unflushedTime, lastUpdatedAt, lastUpdatedBy] = Array.from(rest[0]);
-			const timeSpan = timer.done();
-			if (error != null) { return callback(error); }
-			// check if request took too long and bail out.  only do this for
-			// get, because it is the first call in each update, so if this
-			// passes we'll assume others have a reasonable chance to succeed.
-			if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
-				error = new Error("redis getDoc exceeded timeout");
-				return callback(error);
-			}
-			// record bytes loaded from redis
-			if (docLines != null) {
-				metrics.summary("redis.docLines", docLines.length, {status: "get"});
-			}
-			// check sha1 hash value if present
-			if ((docLines != null) && (storedHash != null)) {
-				const computedHash = RedisManager._computeHash(docLines);
-				if (logHashReadErrors && (computedHash !== storedHash)) {
-					logger.error({project_id, doc_id, doc_project_id, computedHash, storedHash, docLines}, "hash mismatch on retrieved document");
-				}
-			}
-
-			try {
-				docLines = JSON.parse(docLines);
-				ranges = RedisManager._deserializeRanges(ranges);
-			} catch (e) {
-				return callback(e);
-			}
-
-			version = parseInt(version || 0, 10);
-			// check doc is in requested project
-			if ((doc_project_id != null) && (doc_project_id !== project_id)) {
-				logger.error({project_id, doc_id, doc_project_id}, "doc not in project");
-				return callback(new Errors.NotFoundError("document not found"));
-			}
-
-			if (projectHistoryId != null) {
-				projectHistoryId = parseInt(projectHistoryId);
-			}
-
-			// doc is not in redis, bail out
-			if ((docLines == null)) {
-				return callback(null, docLines, version, ranges, pathname, projectHistoryId, unflushedTime, lastUpdatedAt, lastUpdatedBy);
-			}
-
-			// doc should be in project set, check if missing (workaround for missing docs from putDoc)
-			return rclient.sadd(keys.docsInProject({project_id}), doc_id, function(error, result) {
-				if (error != null) { return callback(error); }
-				if (result !== 0) { // doc should already be in set
-					logger.error({project_id, doc_id, doc_project_id}, "doc missing from docsInProject set");
-				}
-				return callback(null, docLines, version, ranges, pathname, projectHistoryId, unflushedTime, lastUpdatedAt, lastUpdatedBy);
-			});
-		});
-	},
-
-	getDocVersion(doc_id, callback) {
-		if (callback == null) { callback = function(error, version, projectHistoryType) {}; }
-		return rclient.mget(keys.docVersion({doc_id}), keys.projectHistoryType({doc_id}), function(error, result) {
-			if (error != null) { return callback(error); }
-			let [version, projectHistoryType] = Array.from(result || []);
-			version = parseInt(version, 10);
-			return callback(null, version, projectHistoryType);
-		});
-	},
-
-	getDocLines(doc_id, callback) {
-		if (callback == null) { callback = function(error, version) {}; }
-		return rclient.get(keys.docLines({doc_id}), function(error, docLines) {
-			if (error != null) { return callback(error); }
-			return callback(null, docLines);
-		});
-	},
-
-	getPreviousDocOps(doc_id, start, end, callback) {
-		if (callback == null) { callback = function(error, jsonOps) {}; }
-		const timer = new metrics.Timer("redis.get-prev-docops");
-		return rclient.llen(keys.docOps({doc_id}), function(error, length) {
-			if (error != null) { return callback(error); }
-			return rclient.get(keys.docVersion({doc_id}), function(error, version) {
-				if (error != null) { return callback(error); }
-				version = parseInt(version, 10);
-				const first_version_in_redis = version - length;
-
-				if ((start < first_version_in_redis) || (end > version)) {
-					error = new Errors.OpRangeNotAvailableError("doc ops range is not loaded in redis");
-					logger.warn({err: error, doc_id, length, version, start, end}, "doc ops range is not loaded in redis");
-					return callback(error);
-				}
-
-				start = start - first_version_in_redis;
-				if (end > -1) {
-					end = end - first_version_in_redis;
-				}
-
-				if (isNaN(start) || isNaN(end)) {
-					error = new Error("inconsistent version or lengths");
-					logger.error({err: error, doc_id, length, version, start, end}, "inconsistent version or length");
-					return callback(error);
-				}
-
-				return rclient.lrange(keys.docOps({doc_id}), start, end, function(error, jsonOps) {
-					let ops;
-					if (error != null) { return callback(error); }
-					try {
-						ops = jsonOps.map(jsonOp => JSON.parse(jsonOp));
-					} catch (e) {
-						return callback(e);
-					}
-					const timeSpan = timer.done();
-					if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
-						error = new Error("redis getPreviousDocOps exceeded timeout");
-						return callback(error);
-					}
-					return callback(null, ops);
-				});
-			});
-		});
-	},
-
-	getHistoryType(doc_id, callback) {
-		if (callback == null) { callback = function(error, projectHistoryType) {}; }
-		return rclient.get(keys.projectHistoryType({doc_id}), function(error, projectHistoryType) {
-			if (error != null) { return callback(error); }
-			return callback(null, projectHistoryType);
-		});
-	},
-
-	setHistoryType(doc_id, projectHistoryType, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return rclient.set(keys.projectHistoryType({doc_id}), projectHistoryType, callback);
-	},
-
-	DOC_OPS_TTL: 60 * minutes,
-	DOC_OPS_MAX_LENGTH: 100,
-	updateDocument(project_id, doc_id, docLines, newVersion, appliedOps, ranges, updateMeta, callback){
-		if (appliedOps == null) { appliedOps = []; }
-		if (callback == null) { callback = function(error) {}; }
-		return RedisManager.getDocVersion(doc_id, function(error, currentVersion, projectHistoryType) {
-			if (error != null) { return callback(error); }
-			if ((currentVersion + appliedOps.length) !== newVersion) {
-				error = new Error(`Version mismatch. '${doc_id}' is corrupted.`);
-				logger.error({err: error, doc_id, currentVersion, newVersion, opsLength: appliedOps.length}, "version mismatch");
-				return callback(error);
-			}
-
-			const jsonOps = appliedOps.map(op => JSON.stringify(op));
-			for (const op of Array.from(jsonOps)) {
-				if (op.indexOf("\u0000") !== -1) {
-					error = new Error("null bytes found in jsonOps");
-					// this check was added to catch memory corruption in JSON.stringify
-					logger.error({err: error, doc_id, jsonOps}, error.message);
-					return callback(error);
-				}
-			}
-
-			const newDocLines = JSON.stringify(docLines);
-			if (newDocLines.indexOf("\u0000") !== -1) {
-				error = new Error("null bytes found in doc lines");
-				// this check was added to catch memory corruption in JSON.stringify
-				logger.error({err: error, doc_id, newDocLines}, error.message);
-				return callback(error);
-			}
-			const newHash = RedisManager._computeHash(newDocLines);
-
-			const opVersions = appliedOps.map(op => op != null ? op.v : undefined);
-			logger.log({doc_id, version: newVersion, hash: newHash, op_versions: opVersions}, "updating doc in redis");
-			// record bytes sent to redis in update
-			metrics.summary("redis.docLines", newDocLines.length, {status: "update"});
-			return RedisManager._serializeRanges(ranges, function(error, ranges) {
-				if (error != null) {
-					logger.error({err: error, doc_id}, error.message);
-					return callback(error);
-				}
-				if ((ranges != null) && (ranges.indexOf("\u0000") !== -1)) {
-					error = new Error("null bytes found in ranges");
-					// this check was added to catch memory corruption in JSON.stringify
-					logger.error({err: error, doc_id, ranges}, error.message);
-					return callback(error);
-				}
-				const multi = rclient.multi();
-				multi.set(keys.docLines({doc_id}), newDocLines);  // index 0
-				multi.set(keys.docVersion({doc_id}), newVersion);             // index 1
-				multi.set(keys.docHash({doc_id}), newHash);                   // index 2
-				multi.ltrim(keys.docOps({doc_id}), -RedisManager.DOC_OPS_MAX_LENGTH, -1); // index 3
-				if (ranges != null) {
-					multi.set(keys.ranges({doc_id}), ranges);  // index 4
-				} else {
-					multi.del(keys.ranges({doc_id}));          // also index 4
-				}
-				// push the ops last so we can get the lengths at fixed index position 7
-				if (jsonOps.length > 0) {
-					multi.rpush(keys.docOps({doc_id}), ...Array.from(jsonOps));                         // index 5
-					// expire must come after rpush since before it will be a no-op if the list is empty
-					multi.expire(keys.docOps({doc_id}), RedisManager.DOC_OPS_TTL);           // index 6
-					if (projectHistoryType === "project-history") {
-						metrics.inc('history-queue', 1, {status: 'skip-track-changes'});
-						logger.log({doc_id}, "skipping push of uncompressed ops for project using project-history");
-					} else {
-						// project is using old track-changes history service
-						metrics.inc('history-queue', 1, {status: 'track-changes'});
-						multi.rpush(historyKeys.uncompressedHistoryOps({doc_id}), ...Array.from(jsonOps));  // index 7
-					}
-					// Set the unflushed timestamp to the current time if the doc
-					// hasn't been modified before (the content in mongo has been
-					// valid up to this point). Otherwise leave it alone ("NX" flag).
-					multi.set(keys.unflushedTime({doc_id}), Date.now(), "NX");
-					multi.set(keys.lastUpdatedAt({doc_id}), Date.now()); // index 8
-					if ((updateMeta != null ? updateMeta.user_id : undefined)) {
-						multi.set(keys.lastUpdatedBy({doc_id}), updateMeta.user_id); // index 9
-					} else {
-						multi.del(keys.lastUpdatedBy({doc_id})); // index 9
-					}
-				}
-				return multi.exec(function(error, result) {
-						let docUpdateCount;
-						if (error != null) { return callback(error); }
-
-						if (projectHistoryType === 'project-history') {
-							docUpdateCount = undefined;  // only using project history, don't bother with track-changes
-						} else {
-							// project is using old track-changes history service
-							docUpdateCount = result[7]; // length of uncompressedHistoryOps queue (index 7)
-						}
-
-						if ((jsonOps.length > 0) && __guard__(Settings.apis != null ? Settings.apis.project_history : undefined, x => x.enabled)) {
-							metrics.inc('history-queue', 1, {status: 'project-history'});
-							return ProjectHistoryRedisManager.queueOps(project_id, ...Array.from(jsonOps), (error, projectUpdateCount) => callback(null, docUpdateCount, projectUpdateCount));
-						} else {
-							return callback(null, docUpdateCount);
-						}
-				});
-			});
-		});
-	},
-
-	renameDoc(project_id, doc_id, user_id, update, projectHistoryId, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return RedisManager.getDoc(project_id, doc_id, function(error, lines, version) {
-			if (error != null) { return callback(error); }
-
-			if ((lines != null) && (version != null)) {
-				return rclient.set(keys.pathname({doc_id}), update.newPathname, function(error) {
-					if (error != null) { return callback(error); }
-					return ProjectHistoryRedisManager.queueRenameEntity(project_id, projectHistoryId, 'doc', doc_id, user_id, update, callback);
-				});
-			} else {
-				return ProjectHistoryRedisManager.queueRenameEntity(project_id, projectHistoryId, 'doc', doc_id, user_id, update, callback);
-			}
-		});
-	},
-
-	clearUnflushedTime(doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return rclient.del(keys.unflushedTime({doc_id}), callback);
-	},
-
-	getDocIdsInProject(project_id, callback) {
-		if (callback == null) { callback = function(error, doc_ids) {}; }
-		return rclient.smembers(keys.docsInProject({project_id}), callback);
-	},
-
-	getDocTimestamps(doc_ids, callback) {
-		// get lastupdatedat timestamps for an array of doc_ids
-		if (callback == null) { callback = function(error, result) {}; }
-		return async.mapSeries(doc_ids, (doc_id, cb) => rclient.get(keys.lastUpdatedAt({doc_id}), cb)
-		, callback);
-	},
-
-	queueFlushAndDeleteProject(project_id, callback) {
-		// store the project id in a sorted set ordered by time with a random offset to smooth out spikes
-		const SMOOTHING_OFFSET = Settings.smoothingOffset > 0 ? Math.round(Settings.smoothingOffset * Math.random()) : 0;
-		return rclient.zadd(keys.flushAndDeleteQueue(), Date.now() + SMOOTHING_OFFSET, project_id, callback);
-	},
-
-	getNextProjectToFlushAndDelete(cutoffTime, callback) {
-		// find the oldest queued flush that is before the cutoff time
-		if (callback == null) { callback = function(error, key, timestamp){}; }
-		return rclient.zrangebyscore(keys.flushAndDeleteQueue(), 0, cutoffTime, "WITHSCORES", "LIMIT", 0, 1, function(err, reply) {
-			if (err != null) { return callback(err); }
-			if (!(reply != null ? reply.length : undefined)) { return callback(); } // return if no projects ready to be processed
-			// pop the oldest entry (get and remove in a multi)
-			const multi = rclient.multi();
-			// Poor man's version of ZPOPMIN, which is only available in Redis 5.
-			multi.zrange(keys.flushAndDeleteQueue(), 0, 0, "WITHSCORES");
-			multi.zremrangebyrank(keys.flushAndDeleteQueue(), 0, 0);
-			multi.zcard(keys.flushAndDeleteQueue()); // the total length of the queue (for metrics)
-			return multi.exec(function(err, reply) {
-				if (err != null) { return callback(err); }
-				if (!(reply != null ? reply.length : undefined)) { return callback(); }
-				const [key, timestamp] = Array.from(reply[0]);
-				const queueLength = reply[2];
-				return callback(null, key, timestamp, queueLength);
-			});
-		});
-	},
-
-	_serializeRanges(ranges, callback) {
-		if (callback == null) { callback = function(error, serializedRanges) {}; }
-		let jsonRanges = JSON.stringify(ranges);
-		if ((jsonRanges != null) && (jsonRanges.length > MAX_RANGES_SIZE)) {
-			return callback(new Error("ranges are too large"));
-		}
-		if (jsonRanges === '{}') {
-			// Most doc will have empty ranges so don't fill redis with lots of '{}' keys
-			jsonRanges = null;
-		}
-		return callback(null, jsonRanges);
-	},
-
-	_deserializeRanges(ranges) {
-		if ((ranges == null) || (ranges === "")) {
-			return {};
-		} else {
-			return JSON.parse(ranges);
-		}
-	},
-
-	_computeHash(docLines) {
-		// use sha1 checksum of doclines to detect data corruption.
-		//
-		// note: must specify 'utf8' encoding explicitly, as the default is
-		// binary in node < v5
-		return crypto.createHash('sha1').update(docLines, 'utf8').digest('hex');
-	}
-});
+const minutes = 60 // seconds for Redis expire
+
+const logHashErrors =
+  Settings.documentupdater != null
+    ? Settings.documentupdater.logHashErrors
+    : undefined
+const logHashReadErrors = logHashErrors != null ? logHashErrors.read : undefined
+
+const MEGABYTES = 1024 * 1024
+const MAX_RANGES_SIZE = 3 * MEGABYTES
+
+const keys = Settings.redis.documentupdater.key_schema
+const historyKeys = Settings.redis.history.key_schema // note: this is track changes, not project-history
+
+module.exports = RedisManager = {
+  rclient,
+
+  putDocInMemory(
+    project_id,
+    doc_id,
+    docLines,
+    version,
+    ranges,
+    pathname,
+    projectHistoryId,
+    _callback
+  ) {
+    const timer = new metrics.Timer('redis.put-doc')
+    const callback = function (error) {
+      timer.done()
+      return _callback(error)
+    }
+    docLines = JSON.stringify(docLines)
+    if (docLines.indexOf('\u0000') !== -1) {
+      const error = new Error('null bytes found in doc lines')
+      // this check was added to catch memory corruption in JSON.stringify.
+      // It sometimes returned null bytes at the end of the string.
+      logger.error({ err: error, doc_id, docLines }, error.message)
+      return callback(error)
+    }
+    const docHash = RedisManager._computeHash(docLines)
+    // record bytes sent to redis
+    metrics.summary('redis.docLines', docLines.length, { status: 'set' })
+    logger.log(
+      { project_id, doc_id, version, docHash, pathname, projectHistoryId },
+      'putting doc in redis'
+    )
+    return RedisManager._serializeRanges(ranges, function (error, ranges) {
+      if (error != null) {
+        logger.error({ err: error, doc_id, project_id }, error.message)
+        return callback(error)
+      }
+      const multi = rclient.multi()
+      multi.set(keys.docLines({ doc_id }), docLines)
+      multi.set(keys.projectKey({ doc_id }), project_id)
+      multi.set(keys.docVersion({ doc_id }), version)
+      multi.set(keys.docHash({ doc_id }), docHash)
+      if (ranges != null) {
+        multi.set(keys.ranges({ doc_id }), ranges)
+      } else {
+        multi.del(keys.ranges({ doc_id }))
+      }
+      multi.set(keys.pathname({ doc_id }), pathname)
+      multi.set(keys.projectHistoryId({ doc_id }), projectHistoryId)
+      return multi.exec(function (error, result) {
+        if (error != null) {
+          return callback(error)
+        }
+        // update docsInProject set
+        return rclient.sadd(
+          keys.docsInProject({ project_id }),
+          doc_id,
+          callback
+        )
+      })
+    })
+  },
+
+  removeDocFromMemory(project_id, doc_id, _callback) {
+    logger.log({ project_id, doc_id }, 'removing doc from redis')
+    const callback = function (err) {
+      if (err != null) {
+        logger.err({ project_id, doc_id, err }, 'error removing doc from redis')
+        return _callback(err)
+      } else {
+        logger.log({ project_id, doc_id }, 'removed doc from redis')
+        return _callback()
+      }
+    }
+
+    let multi = rclient.multi()
+    multi.strlen(keys.docLines({ doc_id }))
+    multi.del(keys.docLines({ doc_id }))
+    multi.del(keys.projectKey({ doc_id }))
+    multi.del(keys.docVersion({ doc_id }))
+    multi.del(keys.docHash({ doc_id }))
+    multi.del(keys.ranges({ doc_id }))
+    multi.del(keys.pathname({ doc_id }))
+    multi.del(keys.projectHistoryId({ doc_id }))
+    multi.del(keys.projectHistoryType({ doc_id }))
+    multi.del(keys.unflushedTime({ doc_id }))
+    multi.del(keys.lastUpdatedAt({ doc_id }))
+    multi.del(keys.lastUpdatedBy({ doc_id }))
+    return multi.exec(function (error, response) {
+      if (error != null) {
+        return callback(error)
+      }
+      const length = response != null ? response[0] : undefined
+      if (length > 0) {
+        // record bytes freed in redis
+        metrics.summary('redis.docLines', length, { status: 'del' })
+      }
+      multi = rclient.multi()
+      multi.srem(keys.docsInProject({ project_id }), doc_id)
+      multi.del(keys.projectState({ project_id }))
+      return multi.exec(callback)
+    })
+  },
+
+  checkOrSetProjectState(project_id, newState, callback) {
+    if (callback == null) {
+      callback = function (error, stateChanged) {}
+    }
+    const multi = rclient.multi()
+    multi.getset(keys.projectState({ project_id }), newState)
+    multi.expire(keys.projectState({ project_id }), 30 * minutes)
+    return multi.exec(function (error, response) {
+      if (error != null) {
+        return callback(error)
+      }
+      logger.log(
+        { project_id, newState, oldState: response[0] },
+        'checking project state'
+      )
+      return callback(null, response[0] !== newState)
+    })
+  },
+
+  clearProjectState(project_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return rclient.del(keys.projectState({ project_id }), callback)
+  },
+
+  getDoc(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (
+        error,
+        lines,
+        version,
+        ranges,
+        pathname,
+        projectHistoryId,
+        unflushedTime
+      ) {}
+    }
+    const timer = new metrics.Timer('redis.get-doc')
+    const multi = rclient.multi()
+    multi.get(keys.docLines({ doc_id }))
+    multi.get(keys.docVersion({ doc_id }))
+    multi.get(keys.docHash({ doc_id }))
+    multi.get(keys.projectKey({ doc_id }))
+    multi.get(keys.ranges({ doc_id }))
+    multi.get(keys.pathname({ doc_id }))
+    multi.get(keys.projectHistoryId({ doc_id }))
+    multi.get(keys.unflushedTime({ doc_id }))
+    multi.get(keys.lastUpdatedAt({ doc_id }))
+    multi.get(keys.lastUpdatedBy({ doc_id }))
+    return multi.exec(function (error, ...rest) {
+      let [
+        docLines,
+        version,
+        storedHash,
+        doc_project_id,
+        ranges,
+        pathname,
+        projectHistoryId,
+        unflushedTime,
+        lastUpdatedAt,
+        lastUpdatedBy
+      ] = Array.from(rest[0])
+      const timeSpan = timer.done()
+      if (error != null) {
+        return callback(error)
+      }
+      // check if request took too long and bail out.  only do this for
+      // get, because it is the first call in each update, so if this
+      // passes we'll assume others have a reasonable chance to succeed.
+      if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
+        error = new Error('redis getDoc exceeded timeout')
+        return callback(error)
+      }
+      // record bytes loaded from redis
+      if (docLines != null) {
+        metrics.summary('redis.docLines', docLines.length, { status: 'get' })
+      }
+      // check sha1 hash value if present
+      if (docLines != null && storedHash != null) {
+        const computedHash = RedisManager._computeHash(docLines)
+        if (logHashReadErrors && computedHash !== storedHash) {
+          logger.error(
+            {
+              project_id,
+              doc_id,
+              doc_project_id,
+              computedHash,
+              storedHash,
+              docLines
+            },
+            'hash mismatch on retrieved document'
+          )
+        }
+      }
+
+      try {
+        docLines = JSON.parse(docLines)
+        ranges = RedisManager._deserializeRanges(ranges)
+      } catch (e) {
+        return callback(e)
+      }
+
+      version = parseInt(version || 0, 10)
+      // check doc is in requested project
+      if (doc_project_id != null && doc_project_id !== project_id) {
+        logger.error(
+          { project_id, doc_id, doc_project_id },
+          'doc not in project'
+        )
+        return callback(new Errors.NotFoundError('document not found'))
+      }
+
+      if (projectHistoryId != null) {
+        projectHistoryId = parseInt(projectHistoryId)
+      }
+
+      // doc is not in redis, bail out
+      if (docLines == null) {
+        return callback(
+          null,
+          docLines,
+          version,
+          ranges,
+          pathname,
+          projectHistoryId,
+          unflushedTime,
+          lastUpdatedAt,
+          lastUpdatedBy
+        )
+      }
+
+      // doc should be in project set, check if missing (workaround for missing docs from putDoc)
+      return rclient.sadd(keys.docsInProject({ project_id }), doc_id, function (
+        error,
+        result
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        if (result !== 0) {
+          // doc should already be in set
+          logger.error(
+            { project_id, doc_id, doc_project_id },
+            'doc missing from docsInProject set'
+          )
+        }
+        return callback(
+          null,
+          docLines,
+          version,
+          ranges,
+          pathname,
+          projectHistoryId,
+          unflushedTime,
+          lastUpdatedAt,
+          lastUpdatedBy
+        )
+      })
+    })
+  },
+
+  getDocVersion(doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, version, projectHistoryType) {}
+    }
+    return rclient.mget(
+      keys.docVersion({ doc_id }),
+      keys.projectHistoryType({ doc_id }),
+      function (error, result) {
+        if (error != null) {
+          return callback(error)
+        }
+        let [version, projectHistoryType] = Array.from(result || [])
+        version = parseInt(version, 10)
+        return callback(null, version, projectHistoryType)
+      }
+    )
+  },
+
+  getDocLines(doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, version) {}
+    }
+    return rclient.get(keys.docLines({ doc_id }), function (error, docLines) {
+      if (error != null) {
+        return callback(error)
+      }
+      return callback(null, docLines)
+    })
+  },
+
+  getPreviousDocOps(doc_id, start, end, callback) {
+    if (callback == null) {
+      callback = function (error, jsonOps) {}
+    }
+    const timer = new metrics.Timer('redis.get-prev-docops')
+    return rclient.llen(keys.docOps({ doc_id }), function (error, length) {
+      if (error != null) {
+        return callback(error)
+      }
+      return rclient.get(keys.docVersion({ doc_id }), function (
+        error,
+        version
+      ) {
+        if (error != null) {
+          return callback(error)
+        }
+        version = parseInt(version, 10)
+        const first_version_in_redis = version - length
+
+        if (start < first_version_in_redis || end > version) {
+          error = new Errors.OpRangeNotAvailableError(
+            'doc ops range is not loaded in redis'
+          )
+          logger.warn(
+            { err: error, doc_id, length, version, start, end },
+            'doc ops range is not loaded in redis'
+          )
+          return callback(error)
+        }
+
+        start = start - first_version_in_redis
+        if (end > -1) {
+          end = end - first_version_in_redis
+        }
+
+        if (isNaN(start) || isNaN(end)) {
+          error = new Error('inconsistent version or lengths')
+          logger.error(
+            { err: error, doc_id, length, version, start, end },
+            'inconsistent version or length'
+          )
+          return callback(error)
+        }
+
+        return rclient.lrange(keys.docOps({ doc_id }), start, end, function (
+          error,
+          jsonOps
+        ) {
+          let ops
+          if (error != null) {
+            return callback(error)
+          }
+          try {
+            ops = jsonOps.map((jsonOp) => JSON.parse(jsonOp))
+          } catch (e) {
+            return callback(e)
+          }
+          const timeSpan = timer.done()
+          if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
+            error = new Error('redis getPreviousDocOps exceeded timeout')
+            return callback(error)
+          }
+          return callback(null, ops)
+        })
+      })
+    })
+  },
+
+  getHistoryType(doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, projectHistoryType) {}
+    }
+    return rclient.get(keys.projectHistoryType({ doc_id }), function (
+      error,
+      projectHistoryType
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      return callback(null, projectHistoryType)
+    })
+  },
+
+  setHistoryType(doc_id, projectHistoryType, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return rclient.set(
+      keys.projectHistoryType({ doc_id }),
+      projectHistoryType,
+      callback
+    )
+  },
+
+  DOC_OPS_TTL: 60 * minutes,
+  DOC_OPS_MAX_LENGTH: 100,
+  updateDocument(
+    project_id,
+    doc_id,
+    docLines,
+    newVersion,
+    appliedOps,
+    ranges,
+    updateMeta,
+    callback
+  ) {
+    if (appliedOps == null) {
+      appliedOps = []
+    }
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return RedisManager.getDocVersion(doc_id, function (
+      error,
+      currentVersion,
+      projectHistoryType
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+      if (currentVersion + appliedOps.length !== newVersion) {
+        error = new Error(`Version mismatch. '${doc_id}' is corrupted.`)
+        logger.error(
+          {
+            err: error,
+            doc_id,
+            currentVersion,
+            newVersion,
+            opsLength: appliedOps.length
+          },
+          'version mismatch'
+        )
+        return callback(error)
+      }
+
+      const jsonOps = appliedOps.map((op) => JSON.stringify(op))
+      for (const op of Array.from(jsonOps)) {
+        if (op.indexOf('\u0000') !== -1) {
+          error = new Error('null bytes found in jsonOps')
+          // this check was added to catch memory corruption in JSON.stringify
+          logger.error({ err: error, doc_id, jsonOps }, error.message)
+          return callback(error)
+        }
+      }
+
+      const newDocLines = JSON.stringify(docLines)
+      if (newDocLines.indexOf('\u0000') !== -1) {
+        error = new Error('null bytes found in doc lines')
+        // this check was added to catch memory corruption in JSON.stringify
+        logger.error({ err: error, doc_id, newDocLines }, error.message)
+        return callback(error)
+      }
+      const newHash = RedisManager._computeHash(newDocLines)
+
+      const opVersions = appliedOps.map((op) => (op != null ? op.v : undefined))
+      logger.log(
+        { doc_id, version: newVersion, hash: newHash, op_versions: opVersions },
+        'updating doc in redis'
+      )
+      // record bytes sent to redis in update
+      metrics.summary('redis.docLines', newDocLines.length, {
+        status: 'update'
+      })
+      return RedisManager._serializeRanges(ranges, function (error, ranges) {
+        if (error != null) {
+          logger.error({ err: error, doc_id }, error.message)
+          return callback(error)
+        }
+        if (ranges != null && ranges.indexOf('\u0000') !== -1) {
+          error = new Error('null bytes found in ranges')
+          // this check was added to catch memory corruption in JSON.stringify
+          logger.error({ err: error, doc_id, ranges }, error.message)
+          return callback(error)
+        }
+        const multi = rclient.multi()
+        multi.set(keys.docLines({ doc_id }), newDocLines) // index 0
+        multi.set(keys.docVersion({ doc_id }), newVersion) // index 1
+        multi.set(keys.docHash({ doc_id }), newHash) // index 2
+        multi.ltrim(
+          keys.docOps({ doc_id }),
+          -RedisManager.DOC_OPS_MAX_LENGTH,
+          -1
+        ) // index 3
+        if (ranges != null) {
+          multi.set(keys.ranges({ doc_id }), ranges) // index 4
+        } else {
+          multi.del(keys.ranges({ doc_id })) // also index 4
+        }
+        // push the ops last so we can get the lengths at fixed index position 7
+        if (jsonOps.length > 0) {
+          multi.rpush(keys.docOps({ doc_id }), ...Array.from(jsonOps)) // index 5
+          // expire must come after rpush since before it will be a no-op if the list is empty
+          multi.expire(keys.docOps({ doc_id }), RedisManager.DOC_OPS_TTL) // index 6
+          if (projectHistoryType === 'project-history') {
+            metrics.inc('history-queue', 1, { status: 'skip-track-changes' })
+            logger.log(
+              { doc_id },
+              'skipping push of uncompressed ops for project using project-history'
+            )
+          } else {
+            // project is using old track-changes history service
+            metrics.inc('history-queue', 1, { status: 'track-changes' })
+            multi.rpush(
+              historyKeys.uncompressedHistoryOps({ doc_id }),
+              ...Array.from(jsonOps)
+            ) // index 7
+          }
+          // Set the unflushed timestamp to the current time if the doc
+          // hasn't been modified before (the content in mongo has been
+          // valid up to this point). Otherwise leave it alone ("NX" flag).
+          multi.set(keys.unflushedTime({ doc_id }), Date.now(), 'NX')
+          multi.set(keys.lastUpdatedAt({ doc_id }), Date.now()) // index 8
+          if (updateMeta != null ? updateMeta.user_id : undefined) {
+            multi.set(keys.lastUpdatedBy({ doc_id }), updateMeta.user_id) // index 9
+          } else {
+            multi.del(keys.lastUpdatedBy({ doc_id })) // index 9
+          }
+        }
+        return multi.exec(function (error, result) {
+          let docUpdateCount
+          if (error != null) {
+            return callback(error)
+          }
+
+          if (projectHistoryType === 'project-history') {
+            docUpdateCount = undefined // only using project history, don't bother with track-changes
+          } else {
+            // project is using old track-changes history service
+            docUpdateCount = result[7] // length of uncompressedHistoryOps queue (index 7)
+          }
+
+          if (
+            jsonOps.length > 0 &&
+            __guard__(
+              Settings.apis != null ? Settings.apis.project_history : undefined,
+              (x) => x.enabled
+            )
+          ) {
+            metrics.inc('history-queue', 1, { status: 'project-history' })
+            return ProjectHistoryRedisManager.queueOps(
+              project_id,
+              ...Array.from(jsonOps),
+              (error, projectUpdateCount) =>
+                callback(null, docUpdateCount, projectUpdateCount)
+            )
+          } else {
+            return callback(null, docUpdateCount)
+          }
+        })
+      })
+    })
+  },
+
+  renameDoc(project_id, doc_id, user_id, update, projectHistoryId, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return RedisManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version
+    ) {
+      if (error != null) {
+        return callback(error)
+      }
+
+      if (lines != null && version != null) {
+        return rclient.set(
+          keys.pathname({ doc_id }),
+          update.newPathname,
+          function (error) {
+            if (error != null) {
+              return callback(error)
+            }
+            return ProjectHistoryRedisManager.queueRenameEntity(
+              project_id,
+              projectHistoryId,
+              'doc',
+              doc_id,
+              user_id,
+              update,
+              callback
+            )
+          }
+        )
+      } else {
+        return ProjectHistoryRedisManager.queueRenameEntity(
+          project_id,
+          projectHistoryId,
+          'doc',
+          doc_id,
+          user_id,
+          update,
+          callback
+        )
+      }
+    })
+  },
+
+  clearUnflushedTime(doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return rclient.del(keys.unflushedTime({ doc_id }), callback)
+  },
+
+  getDocIdsInProject(project_id, callback) {
+    if (callback == null) {
+      callback = function (error, doc_ids) {}
+    }
+    return rclient.smembers(keys.docsInProject({ project_id }), callback)
+  },
+
+  getDocTimestamps(doc_ids, callback) {
+    // get lastupdatedat timestamps for an array of doc_ids
+    if (callback == null) {
+      callback = function (error, result) {}
+    }
+    return async.mapSeries(
+      doc_ids,
+      (doc_id, cb) => rclient.get(keys.lastUpdatedAt({ doc_id }), cb),
+      callback
+    )
+  },
+
+  queueFlushAndDeleteProject(project_id, callback) {
+    // store the project id in a sorted set ordered by time with a random offset to smooth out spikes
+    const SMOOTHING_OFFSET =
+      Settings.smoothingOffset > 0
+        ? Math.round(Settings.smoothingOffset * Math.random())
+        : 0
+    return rclient.zadd(
+      keys.flushAndDeleteQueue(),
+      Date.now() + SMOOTHING_OFFSET,
+      project_id,
+      callback
+    )
+  },
+
+  getNextProjectToFlushAndDelete(cutoffTime, callback) {
+    // find the oldest queued flush that is before the cutoff time
+    if (callback == null) {
+      callback = function (error, key, timestamp) {}
+    }
+    return rclient.zrangebyscore(
+      keys.flushAndDeleteQueue(),
+      0,
+      cutoffTime,
+      'WITHSCORES',
+      'LIMIT',
+      0,
+      1,
+      function (err, reply) {
+        if (err != null) {
+          return callback(err)
+        }
+        if (!(reply != null ? reply.length : undefined)) {
+          return callback()
+        } // return if no projects ready to be processed
+        // pop the oldest entry (get and remove in a multi)
+        const multi = rclient.multi()
+        // Poor man's version of ZPOPMIN, which is only available in Redis 5.
+        multi.zrange(keys.flushAndDeleteQueue(), 0, 0, 'WITHSCORES')
+        multi.zremrangebyrank(keys.flushAndDeleteQueue(), 0, 0)
+        multi.zcard(keys.flushAndDeleteQueue()) // the total length of the queue (for metrics)
+        return multi.exec(function (err, reply) {
+          if (err != null) {
+            return callback(err)
+          }
+          if (!(reply != null ? reply.length : undefined)) {
+            return callback()
+          }
+          const [key, timestamp] = Array.from(reply[0])
+          const queueLength = reply[2]
+          return callback(null, key, timestamp, queueLength)
+        })
+      }
+    )
+  },
+
+  _serializeRanges(ranges, callback) {
+    if (callback == null) {
+      callback = function (error, serializedRanges) {}
+    }
+    let jsonRanges = JSON.stringify(ranges)
+    if (jsonRanges != null && jsonRanges.length > MAX_RANGES_SIZE) {
+      return callback(new Error('ranges are too large'))
+    }
+    if (jsonRanges === '{}') {
+      // Most doc will have empty ranges so don't fill redis with lots of '{}' keys
+      jsonRanges = null
+    }
+    return callback(null, jsonRanges)
+  },
+
+  _deserializeRanges(ranges) {
+    if (ranges == null || ranges === '') {
+      return {}
+    } else {
+      return JSON.parse(ranges)
+    }
+  },
+
+  _computeHash(docLines) {
+    // use sha1 checksum of doclines to detect data corruption.
+    //
+    // note: must specify 'utf8' encoding explicitly, as the default is
+    // binary in node < v5
+    return crypto.createHash('sha1').update(docLines, 'utf8').digest('hex')
+  }
+}
 
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 67 - 52
services/document-updater/app/js/ShareJsDB.js

@@ -11,60 +11,75 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let ShareJsDB;
-const Keys = require('./UpdateKeys');
-const RedisManager = require("./RedisManager");
-const Errors = require("./Errors");
+let ShareJsDB
+const Keys = require('./UpdateKeys')
+const RedisManager = require('./RedisManager')
+const Errors = require('./Errors')
 
-module.exports = (ShareJsDB = class ShareJsDB {
-	constructor(project_id, doc_id, lines, version) {
-		this.project_id = project_id;
-		this.doc_id = doc_id;
-		this.lines = lines;
-		this.version = version;
-		this.appliedOps = {};
-		// ShareJS calls this detacted from the instance, so we need
-		// bind it to keep our context that can access @appliedOps
-		this.writeOp = this._writeOp.bind(this);
-	}
-	
-	getOps(doc_key, start, end, callback) {
-		if (start === end) {
-			return callback(null, []);
-		}
+module.exports = ShareJsDB = class ShareJsDB {
+  constructor(project_id, doc_id, lines, version) {
+    this.project_id = project_id
+    this.doc_id = doc_id
+    this.lines = lines
+    this.version = version
+    this.appliedOps = {}
+    // ShareJS calls this detacted from the instance, so we need
+    // bind it to keep our context that can access @appliedOps
+    this.writeOp = this._writeOp.bind(this)
+  }
 
-		// In redis, lrange values are inclusive.
-		if (end != null) {
-			end--;
-		} else {
-			end = -1;
-		}
+  getOps(doc_key, start, end, callback) {
+    if (start === end) {
+      return callback(null, [])
+    }
 
-		const [project_id, doc_id] = Array.from(Keys.splitProjectIdAndDocId(doc_key));
-		return RedisManager.getPreviousDocOps(doc_id, start, end, callback);
-	}
-	
-	_writeOp(doc_key, opData, callback) {
-		if (this.appliedOps[doc_key] == null) { this.appliedOps[doc_key] = []; }
-		this.appliedOps[doc_key].push(opData);
-		return callback();
-	}
+    // In redis, lrange values are inclusive.
+    if (end != null) {
+      end--
+    } else {
+      end = -1
+    }
 
-	getSnapshot(doc_key, callback) {
-		if (doc_key !== Keys.combineProjectIdAndDocId(this.project_id, this.doc_id)) {
-			return callback(new Errors.NotFoundError(`unexpected doc_key ${doc_key}, expected ${Keys.combineProjectIdAndDocId(this.project_id, this.doc_id)}`));
-		} else {
-			return callback(null, {
-				snapshot: this.lines.join("\n"),
-				v: parseInt(this.version, 10),
-				type: "text"
-			});
-		}
-	}
+    const [project_id, doc_id] = Array.from(
+      Keys.splitProjectIdAndDocId(doc_key)
+    )
+    return RedisManager.getPreviousDocOps(doc_id, start, end, callback)
+  }
 
-	// To be able to remove a doc from the ShareJS memory
-	// we need to called Model::delete, which calls this 
-	// method on the database. However, we will handle removing
-	// it from Redis ourselves
-	delete(docName, dbMeta, callback) { return callback(); }
-});
+  _writeOp(doc_key, opData, callback) {
+    if (this.appliedOps[doc_key] == null) {
+      this.appliedOps[doc_key] = []
+    }
+    this.appliedOps[doc_key].push(opData)
+    return callback()
+  }
+
+  getSnapshot(doc_key, callback) {
+    if (
+      doc_key !== Keys.combineProjectIdAndDocId(this.project_id, this.doc_id)
+    ) {
+      return callback(
+        new Errors.NotFoundError(
+          `unexpected doc_key ${doc_key}, expected ${Keys.combineProjectIdAndDocId(
+            this.project_id,
+            this.doc_id
+          )}`
+        )
+      )
+    } else {
+      return callback(null, {
+        snapshot: this.lines.join('\n'),
+        v: parseInt(this.version, 10),
+        type: 'text'
+      })
+    }
+  }
+
+  // To be able to remove a doc from the ShareJS memory
+  // we need to called Model::delete, which calls this
+  // method on the database. However, we will handle removing
+  // it from Redis ourselves
+  delete(docName, dbMeta, callback) {
+    return callback()
+  }
+}

+ 110 - 88
services/document-updater/app/js/ShareJsUpdateManager.js

@@ -12,98 +12,120 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let ShareJsUpdateManager;
-const ShareJsModel = require("./sharejs/server/model");
-const ShareJsDB = require("./ShareJsDB");
-const logger = require("logger-sharelatex");
-const Settings = require('settings-sharelatex');
-const Keys = require("./UpdateKeys");
-const {EventEmitter} = require("events");
-const util = require("util");
-const RealTimeRedisManager = require("./RealTimeRedisManager");
-const crypto = require("crypto");
-const metrics = require('./Metrics');
-const Errors = require("./Errors");
+let ShareJsUpdateManager
+const ShareJsModel = require('./sharejs/server/model')
+const ShareJsDB = require('./ShareJsDB')
+const logger = require('logger-sharelatex')
+const Settings = require('settings-sharelatex')
+const Keys = require('./UpdateKeys')
+const { EventEmitter } = require('events')
+const util = require('util')
+const RealTimeRedisManager = require('./RealTimeRedisManager')
+const crypto = require('crypto')
+const metrics = require('./Metrics')
+const Errors = require('./Errors')
 
-ShareJsModel.prototype = {};
-util.inherits(ShareJsModel, EventEmitter);
+ShareJsModel.prototype = {}
+util.inherits(ShareJsModel, EventEmitter)
 
-const MAX_AGE_OF_OP = 80;
+const MAX_AGE_OF_OP = 80
 
-module.exports = (ShareJsUpdateManager = {
-	getNewShareJsModel(project_id, doc_id, lines, version) {
-		const db = new ShareJsDB(project_id, doc_id, lines, version);
-		const model = new ShareJsModel(db, {maxDocLength: Settings.max_doc_length, maximumAge: MAX_AGE_OF_OP});
-		model.db = db;
-		return model;
-	},
+module.exports = ShareJsUpdateManager = {
+  getNewShareJsModel(project_id, doc_id, lines, version) {
+    const db = new ShareJsDB(project_id, doc_id, lines, version)
+    const model = new ShareJsModel(db, {
+      maxDocLength: Settings.max_doc_length,
+      maximumAge: MAX_AGE_OF_OP
+    })
+    model.db = db
+    return model
+  },
 
-	applyUpdate(project_id, doc_id, update, lines, version, callback) {
-		if (callback == null) { callback = function(error, updatedDocLines) {}; }
-		logger.log({project_id, doc_id, update}, "applying sharejs updates");
-		const jobs = [];
-		// record the update version before it is modified
-		const incomingUpdateVersion = update.v;
-		// We could use a global model for all docs, but we're hitting issues with the
-		// internal state of ShareJS not being accessible for clearing caches, and
-		// getting stuck due to queued callbacks (line 260 of sharejs/server/model.coffee)
-		// This adds a small but hopefully acceptable overhead (~12ms per 1000 updates on
-		// my 2009 MBP).
-		const model = this.getNewShareJsModel(project_id, doc_id, lines, version);
-		this._listenForOps(model);
-		const doc_key = Keys.combineProjectIdAndDocId(project_id, doc_id);
-		return model.applyOp(doc_key, update, function(error) {
-			if (error != null) {
-				if (error === "Op already submitted") {
-					metrics.inc("sharejs.already-submitted");
-					logger.warn({project_id, doc_id, update}, "op has already been submitted");
-					update.dup = true;
-					ShareJsUpdateManager._sendOp(project_id, doc_id, update);
-				} else if (/^Delete component/.test(error)) {
-					metrics.inc("sharejs.delete-mismatch");
-					logger.warn({project_id, doc_id, update, shareJsErr: error}, "sharejs delete does not match");
-					error = new Errors.DeleteMismatchError("Delete component does not match");
-					return callback(error);
-				} else {
-					metrics.inc("sharejs.other-error");
-					return callback(error);
-				}
-			}
-			logger.log({project_id, doc_id, error}, "applied update");
-			return model.getSnapshot(doc_key, (error, data) => {
-				if (error != null) { return callback(error); }
-				// only check hash when present and no other updates have been applied 
-				if ((update.hash != null) && (incomingUpdateVersion === version)) {
-					const ourHash = ShareJsUpdateManager._computeHash(data.snapshot);
-					if (ourHash !== update.hash) {
-						metrics.inc("sharejs.hash-fail");
-						return callback(new Error("Invalid hash"));
-					} else {
-						metrics.inc("sharejs.hash-pass", 0.001);
-					}
-				}
-				const docLines = data.snapshot.split(/\r\n|\n|\r/);
-				return callback(null, docLines, data.v, model.db.appliedOps[doc_key] || []);
-			});
-		});
-	},
+  applyUpdate(project_id, doc_id, update, lines, version, callback) {
+    if (callback == null) {
+      callback = function (error, updatedDocLines) {}
+    }
+    logger.log({ project_id, doc_id, update }, 'applying sharejs updates')
+    const jobs = []
+    // record the update version before it is modified
+    const incomingUpdateVersion = update.v
+    // We could use a global model for all docs, but we're hitting issues with the
+    // internal state of ShareJS not being accessible for clearing caches, and
+    // getting stuck due to queued callbacks (line 260 of sharejs/server/model.coffee)
+    // This adds a small but hopefully acceptable overhead (~12ms per 1000 updates on
+    // my 2009 MBP).
+    const model = this.getNewShareJsModel(project_id, doc_id, lines, version)
+    this._listenForOps(model)
+    const doc_key = Keys.combineProjectIdAndDocId(project_id, doc_id)
+    return model.applyOp(doc_key, update, function (error) {
+      if (error != null) {
+        if (error === 'Op already submitted') {
+          metrics.inc('sharejs.already-submitted')
+          logger.warn(
+            { project_id, doc_id, update },
+            'op has already been submitted'
+          )
+          update.dup = true
+          ShareJsUpdateManager._sendOp(project_id, doc_id, update)
+        } else if (/^Delete component/.test(error)) {
+          metrics.inc('sharejs.delete-mismatch')
+          logger.warn(
+            { project_id, doc_id, update, shareJsErr: error },
+            'sharejs delete does not match'
+          )
+          error = new Errors.DeleteMismatchError(
+            'Delete component does not match'
+          )
+          return callback(error)
+        } else {
+          metrics.inc('sharejs.other-error')
+          return callback(error)
+        }
+      }
+      logger.log({ project_id, doc_id, error }, 'applied update')
+      return model.getSnapshot(doc_key, (error, data) => {
+        if (error != null) {
+          return callback(error)
+        }
+        // only check hash when present and no other updates have been applied
+        if (update.hash != null && incomingUpdateVersion === version) {
+          const ourHash = ShareJsUpdateManager._computeHash(data.snapshot)
+          if (ourHash !== update.hash) {
+            metrics.inc('sharejs.hash-fail')
+            return callback(new Error('Invalid hash'))
+          } else {
+            metrics.inc('sharejs.hash-pass', 0.001)
+          }
+        }
+        const docLines = data.snapshot.split(/\r\n|\n|\r/)
+        return callback(
+          null,
+          docLines,
+          data.v,
+          model.db.appliedOps[doc_key] || []
+        )
+      })
+    })
+  },
 
-	_listenForOps(model) {
-		return model.on("applyOp", function(doc_key, opData) {
-			const [project_id, doc_id] = Array.from(Keys.splitProjectIdAndDocId(doc_key));
-			return ShareJsUpdateManager._sendOp(project_id, doc_id, opData);
-		});
-	},
-	
-	_sendOp(project_id, doc_id, op) {
-		return RealTimeRedisManager.sendData({project_id, doc_id, op});
-	},
+  _listenForOps(model) {
+    return model.on('applyOp', function (doc_key, opData) {
+      const [project_id, doc_id] = Array.from(
+        Keys.splitProjectIdAndDocId(doc_key)
+      )
+      return ShareJsUpdateManager._sendOp(project_id, doc_id, opData)
+    })
+  },
 
-	_computeHash(content) {
-		return crypto.createHash('sha1')
-			.update("blob " + content.length + "\x00")
-			.update(content, 'utf8')
-			.digest('hex');
-	}
-});
+  _sendOp(project_id, doc_id, op) {
+    return RealTimeRedisManager.sendData({ project_id, doc_id, op })
+  },
 
+  _computeHash(content) {
+    return crypto
+      .createHash('sha1')
+      .update('blob ' + content.length + '\x00')
+      .update(content, 'utf8')
+      .digest('hex')
+  }
+}

+ 50 - 31
services/document-updater/app/js/SnapshotManager.js

@@ -11,58 +11,77 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let SnapshotManager;
-const {db, ObjectId} = require("./mongojs");
+let SnapshotManager
+const { db, ObjectId } = require('./mongojs')
 
-module.exports = (SnapshotManager = {
-  recordSnapshot(project_id, doc_id, version, pathname, lines, ranges, callback) {
+module.exports = SnapshotManager = {
+  recordSnapshot(
+    project_id,
+    doc_id,
+    version,
+    pathname,
+    lines,
+    ranges,
+    callback
+  ) {
     try {
-      project_id = ObjectId(project_id);
-      doc_id = ObjectId(doc_id);
+      project_id = ObjectId(project_id)
+      doc_id = ObjectId(doc_id)
     } catch (error) {
-      return callback(error);
+      return callback(error)
     }
-    return db.docSnapshots.insert({
-      project_id, doc_id, version, lines, pathname,
-      ranges: SnapshotManager.jsonRangesToMongo(ranges),
-      ts: new Date()
-    }, callback);
+    return db.docSnapshots.insert(
+      {
+        project_id,
+        doc_id,
+        version,
+        lines,
+        pathname,
+        ranges: SnapshotManager.jsonRangesToMongo(ranges),
+        ts: new Date()
+      },
+      callback
+    )
   },
-    // Suggested indexes:
-    //   db.docSnapshots.createIndex({project_id:1, doc_id:1})
-    //   db.docSnapshots.createIndex({ts:1},{expiresAfterSeconds: 30*24*3600)) # expires after 30 days
+  // Suggested indexes:
+  //   db.docSnapshots.createIndex({project_id:1, doc_id:1})
+  //   db.docSnapshots.createIndex({ts:1},{expiresAfterSeconds: 30*24*3600)) # expires after 30 days
 
   jsonRangesToMongo(ranges) {
-    if ((ranges == null)) { return null; }
-    
-    const updateMetadata = function(metadata) {
+    if (ranges == null) {
+      return null
+    }
+
+    const updateMetadata = function (metadata) {
       if ((metadata != null ? metadata.ts : undefined) != null) {
-        metadata.ts = new Date(metadata.ts);
+        metadata.ts = new Date(metadata.ts)
       }
       if ((metadata != null ? metadata.user_id : undefined) != null) {
-        return metadata.user_id = SnapshotManager._safeObjectId(metadata.user_id);
+        return (metadata.user_id = SnapshotManager._safeObjectId(
+          metadata.user_id
+        ))
       }
-    };
-    
+    }
+
     for (const change of Array.from(ranges.changes || [])) {
-      change.id = SnapshotManager._safeObjectId(change.id);
-      updateMetadata(change.metadata);
+      change.id = SnapshotManager._safeObjectId(change.id)
+      updateMetadata(change.metadata)
     }
     for (const comment of Array.from(ranges.comments || [])) {
-      comment.id = SnapshotManager._safeObjectId(comment.id);
+      comment.id = SnapshotManager._safeObjectId(comment.id)
       if ((comment.op != null ? comment.op.t : undefined) != null) {
-        comment.op.t = SnapshotManager._safeObjectId(comment.op.t);
+        comment.op.t = SnapshotManager._safeObjectId(comment.op.t)
       }
-      updateMetadata(comment.metadata);
+      updateMetadata(comment.metadata)
     }
-    return ranges;
+    return ranges
   },
 
   _safeObjectId(data) {
     try {
-      return ObjectId(data);
+      return ObjectId(data)
     } catch (error) {
-      return data;
+      return data
     }
   }
-});
+}

+ 7 - 3
services/document-updater/app/js/UpdateKeys.js

@@ -4,6 +4,10 @@
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 module.exports = {
-	combineProjectIdAndDocId(project_id, doc_id) { return `${project_id}:${doc_id}`; },
-	splitProjectIdAndDocId(project_and_doc_id) { return project_and_doc_id.split(":"); }
-};
+  combineProjectIdAndDocId(project_id, doc_id) {
+    return `${project_id}:${doc_id}`
+  },
+  splitProjectIdAndDocId(project_and_doc_id) {
+    return project_and_doc_id.split(':')
+  }
+}

+ 386 - 213
services/document-updater/app/js/UpdateManager.js

@@ -14,226 +14,399 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let UpdateManager;
-const LockManager = require("./LockManager");
-const RedisManager = require("./RedisManager");
-const RealTimeRedisManager = require("./RealTimeRedisManager");
-const ShareJsUpdateManager = require("./ShareJsUpdateManager");
-const HistoryManager = require("./HistoryManager");
-const Settings = require('settings-sharelatex');
-const _ = require("lodash");
-const async = require("async");
-const logger = require('logger-sharelatex');
-const Metrics = require("./Metrics");
-const Errors = require("./Errors");
-const DocumentManager = require("./DocumentManager");
-const RangesManager = require("./RangesManager");
-const SnapshotManager = require("./SnapshotManager");
-const Profiler = require("./Profiler");
+let UpdateManager
+const LockManager = require('./LockManager')
+const RedisManager = require('./RedisManager')
+const RealTimeRedisManager = require('./RealTimeRedisManager')
+const ShareJsUpdateManager = require('./ShareJsUpdateManager')
+const HistoryManager = require('./HistoryManager')
+const Settings = require('settings-sharelatex')
+const _ = require('lodash')
+const async = require('async')
+const logger = require('logger-sharelatex')
+const Metrics = require('./Metrics')
+const Errors = require('./Errors')
+const DocumentManager = require('./DocumentManager')
+const RangesManager = require('./RangesManager')
+const SnapshotManager = require('./SnapshotManager')
+const Profiler = require('./Profiler')
 
-module.exports = (UpdateManager = {
-	processOutstandingUpdates(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const timer = new Metrics.Timer("updateManager.processOutstandingUpdates");
-		return UpdateManager.fetchAndApplyUpdates(project_id, doc_id, function(error) {
-			timer.done();
-			if (error != null) { return callback(error); }
-			return callback();
-		});
-	},
+module.exports = UpdateManager = {
+  processOutstandingUpdates(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const timer = new Metrics.Timer('updateManager.processOutstandingUpdates')
+    return UpdateManager.fetchAndApplyUpdates(project_id, doc_id, function (
+      error
+    ) {
+      timer.done()
+      if (error != null) {
+        return callback(error)
+      }
+      return callback()
+    })
+  },
 
-	processOutstandingUpdatesWithLock(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const profile = new Profiler("processOutstandingUpdatesWithLock", {project_id, doc_id});
-		return LockManager.tryLock(doc_id, (error, gotLock, lockValue) => {
-			if (error != null) { return callback(error); }
-			if (!gotLock) { return callback(); }
-			profile.log("tryLock");
-			return UpdateManager.processOutstandingUpdates(project_id, doc_id, function(error) {
-				if (error != null) { return UpdateManager._handleErrorInsideLock(doc_id, lockValue, error, callback); }
-				profile.log("processOutstandingUpdates");
-				return LockManager.releaseLock(doc_id, lockValue, error => {
-					if (error != null) { return callback(error); }
-					profile.log("releaseLock").end();
-					return UpdateManager.continueProcessingUpdatesWithLock(project_id, doc_id, callback);
-				});
-			});
-		});
-	},
+  processOutstandingUpdatesWithLock(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const profile = new Profiler('processOutstandingUpdatesWithLock', {
+      project_id,
+      doc_id
+    })
+    return LockManager.tryLock(doc_id, (error, gotLock, lockValue) => {
+      if (error != null) {
+        return callback(error)
+      }
+      if (!gotLock) {
+        return callback()
+      }
+      profile.log('tryLock')
+      return UpdateManager.processOutstandingUpdates(
+        project_id,
+        doc_id,
+        function (error) {
+          if (error != null) {
+            return UpdateManager._handleErrorInsideLock(
+              doc_id,
+              lockValue,
+              error,
+              callback
+            )
+          }
+          profile.log('processOutstandingUpdates')
+          return LockManager.releaseLock(doc_id, lockValue, (error) => {
+            if (error != null) {
+              return callback(error)
+            }
+            profile.log('releaseLock').end()
+            return UpdateManager.continueProcessingUpdatesWithLock(
+              project_id,
+              doc_id,
+              callback
+            )
+          })
+        }
+      )
+    })
+  },
 
-	continueProcessingUpdatesWithLock(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return RealTimeRedisManager.getUpdatesLength(doc_id, (error, length) => {
-			if (error != null) { return callback(error); }
-			if (length > 0) {
-				return UpdateManager.processOutstandingUpdatesWithLock(project_id, doc_id, callback);
-			} else {
-				return callback();
-			}
-		});
-	},
+  continueProcessingUpdatesWithLock(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return RealTimeRedisManager.getUpdatesLength(doc_id, (error, length) => {
+      if (error != null) {
+        return callback(error)
+      }
+      if (length > 0) {
+        return UpdateManager.processOutstandingUpdatesWithLock(
+          project_id,
+          doc_id,
+          callback
+        )
+      } else {
+        return callback()
+      }
+    })
+  },
 
-	fetchAndApplyUpdates(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const profile = new Profiler("fetchAndApplyUpdates", {project_id, doc_id});
-		return RealTimeRedisManager.getPendingUpdatesForDoc(doc_id, (error, updates) => {
-			if (error != null) { return callback(error); }
-			logger.log({project_id, doc_id, count: updates.length}, "processing updates");
-			if (updates.length === 0) {
-				return callback();
-			}
-			profile.log("getPendingUpdatesForDoc");
-			const doUpdate = (update, cb) => UpdateManager.applyUpdate(project_id, doc_id, update, function(err) {
-                profile.log("applyUpdate");
-                return cb(err);
-            });
-			const finalCallback = function(err) {
-				profile.log("async done").end();
-				return callback(err);
-			};
-			return async.eachSeries(updates, doUpdate, finalCallback);
-		});
-	},
+  fetchAndApplyUpdates(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const profile = new Profiler('fetchAndApplyUpdates', { project_id, doc_id })
+    return RealTimeRedisManager.getPendingUpdatesForDoc(
+      doc_id,
+      (error, updates) => {
+        if (error != null) {
+          return callback(error)
+        }
+        logger.log(
+          { project_id, doc_id, count: updates.length },
+          'processing updates'
+        )
+        if (updates.length === 0) {
+          return callback()
+        }
+        profile.log('getPendingUpdatesForDoc')
+        const doUpdate = (update, cb) =>
+          UpdateManager.applyUpdate(project_id, doc_id, update, function (err) {
+            profile.log('applyUpdate')
+            return cb(err)
+          })
+        const finalCallback = function (err) {
+          profile.log('async done').end()
+          return callback(err)
+        }
+        return async.eachSeries(updates, doUpdate, finalCallback)
+      }
+    )
+  },
 
-	applyUpdate(project_id, doc_id, update, _callback) {
-		if (_callback == null) { _callback = function(error) {}; }
-		const callback = function(error) {
-			if (error != null) {
-				RealTimeRedisManager.sendData({project_id, doc_id, error: error.message || error});
-				profile.log("sendData");
-			}
-			profile.end();
-			return _callback(error);
-		};
+  applyUpdate(project_id, doc_id, update, _callback) {
+    if (_callback == null) {
+      _callback = function (error) {}
+    }
+    const callback = function (error) {
+      if (error != null) {
+        RealTimeRedisManager.sendData({
+          project_id,
+          doc_id,
+          error: error.message || error
+        })
+        profile.log('sendData')
+      }
+      profile.end()
+      return _callback(error)
+    }
 
-		var profile = new Profiler("applyUpdate", {project_id, doc_id});
-		UpdateManager._sanitizeUpdate(update);
-		profile.log("sanitizeUpdate");
-		return DocumentManager.getDoc(project_id, doc_id, function(error, lines, version, ranges, pathname, projectHistoryId) {
-			profile.log("getDoc");
-			if (error != null) { return callback(error); }
-			if ((lines == null) || (version == null)) {
-				return callback(new Errors.NotFoundError(`document not found: ${doc_id}`));
-			}
-			const previousVersion = version;
-			return ShareJsUpdateManager.applyUpdate(project_id, doc_id, update, lines, version, function(error, updatedDocLines, version, appliedOps) {
-				profile.log("sharejs.applyUpdate");
-				if (error != null) { return callback(error); }
-				return RangesManager.applyUpdate(project_id, doc_id, ranges, appliedOps, updatedDocLines, function(error, new_ranges, ranges_were_collapsed) {
-					UpdateManager._addProjectHistoryMetadataToOps(appliedOps, pathname, projectHistoryId, lines);
-					profile.log("RangesManager.applyUpdate");
-					if (error != null) { return callback(error); }
-					return RedisManager.updateDocument(project_id, doc_id, updatedDocLines, version, appliedOps, new_ranges, update.meta, function(error, doc_ops_length, project_ops_length) {
-						profile.log("RedisManager.updateDocument");
-						if (error != null) { return callback(error); }
-						return HistoryManager.recordAndFlushHistoryOps(project_id, doc_id, appliedOps, doc_ops_length, project_ops_length, function(error) {
-							profile.log("recordAndFlushHistoryOps");
-							if (error != null) { return callback(error); }
-							if (ranges_were_collapsed) {
-								logger.log({project_id, doc_id, previousVersion, lines, ranges, update}, "update collapsed some ranges, snapshotting previous content");
-								// Do this last, since it's a mongo call, and so potentially longest running
-								// If it overruns the lock, it's ok, since all of our redis work is done
-								return SnapshotManager.recordSnapshot(project_id, doc_id, previousVersion, pathname, lines, ranges, function(error) {
-									if (error != null) {
-										logger.error({err: error, project_id, doc_id, version, lines, ranges}, "error recording snapshot");
-										return callback(error);
-									} else {
-										return callback();
-									}
-								});
-							} else {
-								return callback();
-							}
-						});
-					});
-				});
-			});
-		});
-	},
+    var profile = new Profiler('applyUpdate', { project_id, doc_id })
+    UpdateManager._sanitizeUpdate(update)
+    profile.log('sanitizeUpdate')
+    return DocumentManager.getDoc(project_id, doc_id, function (
+      error,
+      lines,
+      version,
+      ranges,
+      pathname,
+      projectHistoryId
+    ) {
+      profile.log('getDoc')
+      if (error != null) {
+        return callback(error)
+      }
+      if (lines == null || version == null) {
+        return callback(
+          new Errors.NotFoundError(`document not found: ${doc_id}`)
+        )
+      }
+      const previousVersion = version
+      return ShareJsUpdateManager.applyUpdate(
+        project_id,
+        doc_id,
+        update,
+        lines,
+        version,
+        function (error, updatedDocLines, version, appliedOps) {
+          profile.log('sharejs.applyUpdate')
+          if (error != null) {
+            return callback(error)
+          }
+          return RangesManager.applyUpdate(
+            project_id,
+            doc_id,
+            ranges,
+            appliedOps,
+            updatedDocLines,
+            function (error, new_ranges, ranges_were_collapsed) {
+              UpdateManager._addProjectHistoryMetadataToOps(
+                appliedOps,
+                pathname,
+                projectHistoryId,
+                lines
+              )
+              profile.log('RangesManager.applyUpdate')
+              if (error != null) {
+                return callback(error)
+              }
+              return RedisManager.updateDocument(
+                project_id,
+                doc_id,
+                updatedDocLines,
+                version,
+                appliedOps,
+                new_ranges,
+                update.meta,
+                function (error, doc_ops_length, project_ops_length) {
+                  profile.log('RedisManager.updateDocument')
+                  if (error != null) {
+                    return callback(error)
+                  }
+                  return HistoryManager.recordAndFlushHistoryOps(
+                    project_id,
+                    doc_id,
+                    appliedOps,
+                    doc_ops_length,
+                    project_ops_length,
+                    function (error) {
+                      profile.log('recordAndFlushHistoryOps')
+                      if (error != null) {
+                        return callback(error)
+                      }
+                      if (ranges_were_collapsed) {
+                        logger.log(
+                          {
+                            project_id,
+                            doc_id,
+                            previousVersion,
+                            lines,
+                            ranges,
+                            update
+                          },
+                          'update collapsed some ranges, snapshotting previous content'
+                        )
+                        // Do this last, since it's a mongo call, and so potentially longest running
+                        // If it overruns the lock, it's ok, since all of our redis work is done
+                        return SnapshotManager.recordSnapshot(
+                          project_id,
+                          doc_id,
+                          previousVersion,
+                          pathname,
+                          lines,
+                          ranges,
+                          function (error) {
+                            if (error != null) {
+                              logger.error(
+                                {
+                                  err: error,
+                                  project_id,
+                                  doc_id,
+                                  version,
+                                  lines,
+                                  ranges
+                                },
+                                'error recording snapshot'
+                              )
+                              return callback(error)
+                            } else {
+                              return callback()
+                            }
+                          }
+                        )
+                      } else {
+                        return callback()
+                      }
+                    }
+                  )
+                }
+              )
+            }
+          )
+        }
+      )
+    })
+  },
 
-	lockUpdatesAndDo(method, project_id, doc_id, ...rest) {
-		const adjustedLength = Math.max(rest.length, 1); const args = rest.slice(0, adjustedLength - 1); const callback = rest[adjustedLength - 1];
-		const profile = new Profiler("lockUpdatesAndDo", {project_id, doc_id});
-		return LockManager.getLock(doc_id, function(error, lockValue) {
-			profile.log("getLock");
-			if (error != null) { return callback(error); }
-			return UpdateManager.processOutstandingUpdates(project_id, doc_id, function(error) {
-				if (error != null) { return UpdateManager._handleErrorInsideLock(doc_id, lockValue, error, callback); }
-				profile.log("processOutstandingUpdates");
-				return method(project_id, doc_id, ...Array.from(args), function(error, ...response_args) {
-					if (error != null) { return UpdateManager._handleErrorInsideLock(doc_id, lockValue, error, callback); }
-					profile.log("method");
-					return LockManager.releaseLock(doc_id, lockValue, function(error) {
-						if (error != null) { return callback(error); }
-						profile.log("releaseLock").end();
-						callback(null, ...Array.from(response_args));
-						// We held the lock for a while so updates might have queued up
-						return UpdateManager.continueProcessingUpdatesWithLock(project_id, doc_id);
-					});
-				});
-			});
-		});
-	},
+  lockUpdatesAndDo(method, project_id, doc_id, ...rest) {
+    const adjustedLength = Math.max(rest.length, 1)
+    const args = rest.slice(0, adjustedLength - 1)
+    const callback = rest[adjustedLength - 1]
+    const profile = new Profiler('lockUpdatesAndDo', { project_id, doc_id })
+    return LockManager.getLock(doc_id, function (error, lockValue) {
+      profile.log('getLock')
+      if (error != null) {
+        return callback(error)
+      }
+      return UpdateManager.processOutstandingUpdates(
+        project_id,
+        doc_id,
+        function (error) {
+          if (error != null) {
+            return UpdateManager._handleErrorInsideLock(
+              doc_id,
+              lockValue,
+              error,
+              callback
+            )
+          }
+          profile.log('processOutstandingUpdates')
+          return method(project_id, doc_id, ...Array.from(args), function (
+            error,
+            ...response_args
+          ) {
+            if (error != null) {
+              return UpdateManager._handleErrorInsideLock(
+                doc_id,
+                lockValue,
+                error,
+                callback
+              )
+            }
+            profile.log('method')
+            return LockManager.releaseLock(doc_id, lockValue, function (error) {
+              if (error != null) {
+                return callback(error)
+              }
+              profile.log('releaseLock').end()
+              callback(null, ...Array.from(response_args))
+              // We held the lock for a while so updates might have queued up
+              return UpdateManager.continueProcessingUpdatesWithLock(
+                project_id,
+                doc_id
+              )
+            })
+          })
+        }
+      )
+    })
+  },
 
-	_handleErrorInsideLock(doc_id, lockValue, original_error, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return LockManager.releaseLock(doc_id, lockValue, lock_error => callback(original_error));
-	},
+  _handleErrorInsideLock(doc_id, lockValue, original_error, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return LockManager.releaseLock(doc_id, lockValue, (lock_error) =>
+      callback(original_error)
+    )
+  },
 
-	_sanitizeUpdate(update) {
-		// In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
-		//
-		// From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
-		// "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
-		// for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
-		// and one Low Surrogate. A single surrogate code point will never be assigned a character.""
-		//
-		// The main offender seems to be \uD835 as a stand alone character, which would be the first
-		// 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
-		// Something must be going on client side that is screwing up the encoding and splitting the
-		// two 16-bit characters so that \uD835 is standalone.
-		for (const op of Array.from(update.op || [])) {
-			if (op.i != null) {
-				// Replace high and low surrogate characters with 'replacement character' (\uFFFD)
-				op.i = op.i.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
-			}
-		}
-		return update;
-	},
+  _sanitizeUpdate(update) {
+    // In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
+    //
+    // From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
+    // "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
+    // for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
+    // and one Low Surrogate. A single surrogate code point will never be assigned a character.""
+    //
+    // The main offender seems to be \uD835 as a stand alone character, which would be the first
+    // 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
+    // Something must be going on client side that is screwing up the encoding and splitting the
+    // two 16-bit characters so that \uD835 is standalone.
+    for (const op of Array.from(update.op || [])) {
+      if (op.i != null) {
+        // Replace high and low surrogate characters with 'replacement character' (\uFFFD)
+        op.i = op.i.replace(/[\uD800-\uDFFF]/g, '\uFFFD')
+      }
+    }
+    return update
+  },
 
-	_addProjectHistoryMetadataToOps(updates, pathname, projectHistoryId, lines) {
-		let doc_length = _.reduce(lines,
-			(chars, line) => chars + line.length,
-			0);
-		doc_length += lines.length - 1;  // count newline characters
-		return updates.forEach(function(update) {
-			update.projectHistoryId = projectHistoryId;
-			if (!update.meta) { update.meta = {}; }
-			update.meta.pathname = pathname;
-			update.meta.doc_length = doc_length;
-			// Each update may contain multiple ops, i.e.
-			// [{
-			// 	ops: [{i: "foo", p: 4}, {d: "bar", p:8}]
-			// }, {
-			// 	ops: [{d: "baz", p: 40}, {i: "qux", p:8}]
-			// }]
-			// We want to include the doc_length at the start of each update,
-			// before it's ops are applied. However, we need to track any
-			// changes to it for the next update.
-			return (() => {
-				const result = [];
-				for (const op of Array.from(update.op)) {
-					if (op.i != null) {
-						doc_length += op.i.length;
-					}
-					if (op.d != null) {
-						result.push(doc_length -= op.d.length);
-					} else {
-						result.push(undefined);
-					}
-				}
-				return result;
-			})();
-		});
-	}
-});
+  _addProjectHistoryMetadataToOps(updates, pathname, projectHistoryId, lines) {
+    let doc_length = _.reduce(lines, (chars, line) => chars + line.length, 0)
+    doc_length += lines.length - 1 // count newline characters
+    return updates.forEach(function (update) {
+      update.projectHistoryId = projectHistoryId
+      if (!update.meta) {
+        update.meta = {}
+      }
+      update.meta.pathname = pathname
+      update.meta.doc_length = doc_length
+      // Each update may contain multiple ops, i.e.
+      // [{
+      // 	ops: [{i: "foo", p: 4}, {d: "bar", p:8}]
+      // }, {
+      // 	ops: [{d: "baz", p: 40}, {i: "qux", p:8}]
+      // }]
+      // We want to include the doc_length at the start of each update,
+      // before it's ops are applied. However, we need to track any
+      // changes to it for the next update.
+      return (() => {
+        const result = []
+        for (const op of Array.from(update.op)) {
+          if (op.i != null) {
+            doc_length += op.i.length
+          }
+          if (op.d != null) {
+            result.push((doc_length -= op.d.length))
+          } else {
+            result.push(undefined)
+          }
+        }
+        return result
+      })()
+    })
+  }
+}

+ 17 - 13
services/document-updater/app/js/mongojs.js

@@ -6,18 +6,22 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const Settings = require("settings-sharelatex");
-const mongojs = require("mongojs");
-const db = mongojs(Settings.mongo.url, ["docSnapshots"]);
+const Settings = require('settings-sharelatex')
+const mongojs = require('mongojs')
+const db = mongojs(Settings.mongo.url, ['docSnapshots'])
 
 module.exports = {
-	db,
-	ObjectId: mongojs.ObjectId,
-	healthCheck(callback) {
-		return db.runCommand({ping: 1}, function(err, res) {
-			if (err != null) { return callback(err); }
-			if (!res.ok) { return callback(new Error("failed mongo ping")); }
-			return callback();
-		});
-	}
-};
+  db,
+  ObjectId: mongojs.ObjectId,
+  healthCheck(callback) {
+    return db.runCommand({ ping: 1 }, function (err, res) {
+      if (err != null) {
+        return callback(err)
+      }
+      if (!res.ok) {
+        return callback(new Error('failed mongo ping'))
+      }
+      return callback()
+    })
+  }
+}

+ 22 - 17
services/document-updater/app/js/sharejs/count.js

@@ -8,25 +8,30 @@
  */
 // This is a simple type used for testing other OT code. Each op is [expectedSnapshot, increment]
 
-exports.name = 'count';
-exports.create = () => 1;
+exports.name = 'count'
+exports.create = () => 1
 
-exports.apply = function(snapshot, op) {
-  const [v, inc] = Array.from(op);
-  if (snapshot !== v) { throw new Error(`Op ${v} != snapshot ${snapshot}`); }
-  return snapshot + inc;
-};
+exports.apply = function (snapshot, op) {
+  const [v, inc] = Array.from(op)
+  if (snapshot !== v) {
+    throw new Error(`Op ${v} != snapshot ${snapshot}`)
+  }
+  return snapshot + inc
+}
 
 // transform op1 by op2. Return transformed version of op1.
-exports.transform = function(op1, op2) {
-  if (op1[0] !== op2[0]) { throw new Error(`Op1 ${op1[0]} != op2 ${op2[0]}`); }
-  return [op1[0] + op2[1], op1[1]];
-};
+exports.transform = function (op1, op2) {
+  if (op1[0] !== op2[0]) {
+    throw new Error(`Op1 ${op1[0]} != op2 ${op2[0]}`)
+  }
+  return [op1[0] + op2[1], op1[1]]
+}
 
-exports.compose = function(op1, op2) {
-  if ((op1[0] + op1[1]) !== op2[0]) { throw new Error(`Op1 ${op1} + 1 != op2 ${op2}`); }
-  return [op1[0], op1[1] + op2[1]];
-};
-
-exports.generateRandomOp = doc => [[doc, 1], doc + 1];
+exports.compose = function (op1, op2) {
+  if (op1[0] + op1[1] !== op2[0]) {
+    throw new Error(`Op1 ${op1} + 1 != op2 ${op2}`)
+  }
+  return [op1[0], op1[1] + op2[1]]
+}
 
+exports.generateRandomOp = (doc) => [[doc, 1], doc + 1]

+ 65 - 46
services/document-updater/app/js/sharejs/helpers.js

@@ -17,77 +17,96 @@
 
 // Add transform and transformX functions for an OT type which has transformComponent defined.
 // transformComponent(destination array, component, other component, side)
-let bootstrapTransform;
-exports._bt = (bootstrapTransform = function(type, transformComponent, checkValidOp, append) {
-  let transformX;
-  const transformComponentX = function(left, right, destLeft, destRight) {
-    transformComponent(destLeft, left, right, 'left');
-    return transformComponent(destRight, right, left, 'right');
-  };
+let bootstrapTransform
+exports._bt = bootstrapTransform = function (
+  type,
+  transformComponent,
+  checkValidOp,
+  append
+) {
+  let transformX
+  const transformComponentX = function (left, right, destLeft, destRight) {
+    transformComponent(destLeft, left, right, 'left')
+    return transformComponent(destRight, right, left, 'right')
+  }
 
   // Transforms rightOp by leftOp. Returns ['rightOp', clientOp']
-  type.transformX = (type.transformX = (transformX = function(leftOp, rightOp) {
-    checkValidOp(leftOp);
-    checkValidOp(rightOp);
+  type.transformX = type.transformX = transformX = function (leftOp, rightOp) {
+    checkValidOp(leftOp)
+    checkValidOp(rightOp)
 
-    const newRightOp = [];
+    const newRightOp = []
 
     for (let rightComponent of Array.from(rightOp)) {
       // Generate newLeftOp by composing leftOp by rightComponent
-      const newLeftOp = [];
+      const newLeftOp = []
 
-      let k = 0;
+      let k = 0
       while (k < leftOp.length) {
-        var l;
-        const nextC = [];
-        transformComponentX(leftOp[k], rightComponent, newLeftOp, nextC);
-        k++;
+        var l
+        const nextC = []
+        transformComponentX(leftOp[k], rightComponent, newLeftOp, nextC)
+        k++
 
         if (nextC.length === 1) {
-          rightComponent = nextC[0];
+          rightComponent = nextC[0]
         } else if (nextC.length === 0) {
-          for (l of Array.from(leftOp.slice(k))) { append(newLeftOp, l); }
-          rightComponent = null;
-          break;
+          for (l of Array.from(leftOp.slice(k))) {
+            append(newLeftOp, l)
+          }
+          rightComponent = null
+          break
         } else {
           // Recurse.
-          const [l_, r_] = Array.from(transformX(leftOp.slice(k), nextC));
-          for (l of Array.from(l_)) { append(newLeftOp, l); }
-          for (const r of Array.from(r_)) { append(newRightOp, r); }
-          rightComponent = null;
-          break;
+          const [l_, r_] = Array.from(transformX(leftOp.slice(k), nextC))
+          for (l of Array.from(l_)) {
+            append(newLeftOp, l)
+          }
+          for (const r of Array.from(r_)) {
+            append(newRightOp, r)
+          }
+          rightComponent = null
+          break
         }
       }
-    
-      if (rightComponent != null) { append(newRightOp, rightComponent); }
-      leftOp = newLeftOp;
+
+      if (rightComponent != null) {
+        append(newRightOp, rightComponent)
+      }
+      leftOp = newLeftOp
     }
-    
-    return [leftOp, newRightOp];
-  }));
+
+    return [leftOp, newRightOp]
+  }
 
   // Transforms op with specified type ('left' or 'right') by otherOp.
-  return type.transform = (type.transform = function(op, otherOp, type) {
-    let _;
-    if ((type !== 'left') && (type !== 'right')) { throw new Error("type must be 'left' or 'right'"); }
+  return (type.transform = type.transform = function (op, otherOp, type) {
+    let _
+    if (type !== 'left' && type !== 'right') {
+      throw new Error("type must be 'left' or 'right'")
+    }
 
-    if (otherOp.length === 0) { return op; }
+    if (otherOp.length === 0) {
+      return op
+    }
 
     // TODO: Benchmark with and without this line. I _think_ it'll make a big difference...?
-    if ((op.length === 1) && (otherOp.length === 1)) { return transformComponent([], op[0], otherOp[0], type); }
+    if (op.length === 1 && otherOp.length === 1) {
+      return transformComponent([], op[0], otherOp[0], type)
+    }
 
     if (type === 'left') {
-      let left;
-      [left, _] = Array.from(transformX(op, otherOp));
-      return left;
+      let left
+      ;[left, _] = Array.from(transformX(op, otherOp))
+      return left
     } else {
-      let right;
-      [_, right] = Array.from(transformX(otherOp, op));
-      return right;
+      let right
+      ;[_, right] = Array.from(transformX(otherOp, op))
+      return right
     }
-  });
-});
+  })
+}
 
 if (typeof WEB === 'undefined') {
-  exports.bootstrapTransform = bootstrapTransform;
+  exports.bootstrapTransform = bootstrapTransform
 }

+ 13 - 11
services/document-updater/app/js/sharejs/index.js

@@ -6,18 +6,20 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 
-const register = function(file) {
-  const type = require(file);
-  exports[type.name] = type;
-  try { return require(`${file}-api`); } catch (error) {}
-};
+const register = function (file) {
+  const type = require(file)
+  exports[type.name] = type
+  try {
+    return require(`${file}-api`)
+  } catch (error) {}
+}
 
 // Import all the built-in types.
-register('./simple');
-register('./count');
+register('./simple')
+register('./count')
 
-register('./text');
-register('./text-composable');
-register('./text-tp2');
+register('./text')
+register('./text-composable')
+register('./text-tp2')
 
-register('./json');
+register('./json')

+ 257 - 180
services/document-updater/app/js/sharejs/json-api.js

@@ -14,267 +14,344 @@
  */
 // API for JSON OT
 
-let json;
-if (typeof WEB === 'undefined') { json = require('./json'); }
+let json
+if (typeof WEB === 'undefined') {
+  json = require('./json')
+}
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  const {
-    extendDoc
-  } = exports;
-  exports.extendDoc = function(name, fn) {
-    SubDoc.prototype[name] = fn;
-    return extendDoc(name, fn);
-  };
+  const { extendDoc } = exports
+  exports.extendDoc = function (name, fn) {
+    SubDoc.prototype[name] = fn
+    return extendDoc(name, fn)
+  }
 }
 
-const depath = function(path) {
-  if ((path.length === 1) && (path[0].constructor === Array)) {
-    return path[0];
-  } else { return path; }
-};
+const depath = function (path) {
+  if (path.length === 1 && path[0].constructor === Array) {
+    return path[0]
+  } else {
+    return path
+  }
+}
 
 class SubDoc {
   constructor(doc, path) {
-    this.doc = doc;
-    this.path = path;
+    this.doc = doc
+    this.path = path
+  }
+
+  at(...path) {
+    return this.doc.at(this.path.concat(depath(path)))
+  }
+
+  get() {
+    return this.doc.getAt(this.path)
   }
 
-  at(...path) { return this.doc.at(this.path.concat(depath(path))); }
-  get() { return this.doc.getAt(this.path); }
   // for objects and lists
-  set(value, cb) { return this.doc.setAt(this.path, value, cb); }
+  set(value, cb) {
+    return this.doc.setAt(this.path, value, cb)
+  }
+
   // for strings and lists.
-  insert(pos, value, cb) { return this.doc.insertAt(this.path, pos, value, cb); }
+  insert(pos, value, cb) {
+    return this.doc.insertAt(this.path, pos, value, cb)
+  }
+
   // for strings
-  del(pos, length, cb) { return this.doc.deleteTextAt(this.path, length, pos, cb); }
+  del(pos, length, cb) {
+    return this.doc.deleteTextAt(this.path, length, pos, cb)
+  }
+
   // for objects and lists
-  remove(cb) { return this.doc.removeAt(this.path, cb); }
-  push(value, cb) { return this.insert(this.get().length, value, cb); }
-  move(from, to, cb) { return this.doc.moveAt(this.path, from, to, cb); }
-  add(amount, cb) { return this.doc.addAt(this.path, amount, cb); }
-  on(event, cb) { return this.doc.addListener(this.path, event, cb); }
-  removeListener(l) { return this.doc.removeListener(l); }
+  remove(cb) {
+    return this.doc.removeAt(this.path, cb)
+  }
+
+  push(value, cb) {
+    return this.insert(this.get().length, value, cb)
+  }
+
+  move(from, to, cb) {
+    return this.doc.moveAt(this.path, from, to, cb)
+  }
+
+  add(amount, cb) {
+    return this.doc.addAt(this.path, amount, cb)
+  }
+
+  on(event, cb) {
+    return this.doc.addListener(this.path, event, cb)
+  }
+
+  removeListener(l) {
+    return this.doc.removeListener(l)
+  }
 
   // text API compatibility
-  getLength() { return this.get().length; }
-  getText() { return this.get(); }
+  getLength() {
+    return this.get().length
+  }
+
+  getText() {
+    return this.get()
+  }
 }
 
-const traverse = function(snapshot, path) {
-  const container = {data:snapshot};
-  let key = 'data';
-  let elem = container;
+const traverse = function (snapshot, path) {
+  const container = { data: snapshot }
+  let key = 'data'
+  let elem = container
   for (const p of Array.from(path)) {
-    elem = elem[key];
-    key = p;
-    if (typeof elem === 'undefined') { throw new Error('bad path'); }
+    elem = elem[key]
+    key = p
+    if (typeof elem === 'undefined') {
+      throw new Error('bad path')
+    }
   }
-  return {elem, key};
-};
+  return { elem, key }
+}
 
-const pathEquals = function(p1, p2) {
-  if (p1.length !== p2.length) { return false; }
+const pathEquals = function (p1, p2) {
+  if (p1.length !== p2.length) {
+    return false
+  }
   for (let i = 0; i < p1.length; i++) {
-    const e = p1[i];
-    if (e !== p2[i]) { return false; }
+    const e = p1[i]
+    if (e !== p2[i]) {
+      return false
+    }
   }
-  return true;
-};
+  return true
+}
 
 json.api = {
-  provides: {json:true},
+  provides: { json: true },
 
-  at(...path) { return new SubDoc(this, depath(path)); },
+  at(...path) {
+    return new SubDoc(this, depath(path))
+  },
 
-  get() { return this.snapshot; },
-  set(value, cb) { return this.setAt([], value, cb); },
+  get() {
+    return this.snapshot
+  },
+  set(value, cb) {
+    return this.setAt([], value, cb)
+  },
 
   getAt(path) {
-    const {elem, key} = traverse(this.snapshot, path);
-    return elem[key];
+    const { elem, key } = traverse(this.snapshot, path)
+    return elem[key]
   },
 
   setAt(path, value, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    const op = {p:path};
+    const { elem, key } = traverse(this.snapshot, path)
+    const op = { p: path }
     if (elem.constructor === Array) {
-      op.li = value;
-      if (typeof elem[key] !== 'undefined') { op.ld = elem[key]; }
+      op.li = value
+      if (typeof elem[key] !== 'undefined') {
+        op.ld = elem[key]
+      }
     } else if (typeof elem === 'object') {
-      op.oi = value;
-      if (typeof elem[key] !== 'undefined') { op.od = elem[key]; }
-    } else { throw new Error('bad path'); }
-    return this.submitOp([op], cb);
+      op.oi = value
+      if (typeof elem[key] !== 'undefined') {
+        op.od = elem[key]
+      }
+    } else {
+      throw new Error('bad path')
+    }
+    return this.submitOp([op], cb)
   },
 
   removeAt(path, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    if (typeof elem[key] === 'undefined') { throw new Error('no element at that path'); }
-    const op = {p:path};
+    const { elem, key } = traverse(this.snapshot, path)
+    if (typeof elem[key] === 'undefined') {
+      throw new Error('no element at that path')
+    }
+    const op = { p: path }
     if (elem.constructor === Array) {
-      op.ld = elem[key];
+      op.ld = elem[key]
     } else if (typeof elem === 'object') {
-      op.od = elem[key];
-    } else { throw new Error('bad path'); }
-    return this.submitOp([op], cb);
+      op.od = elem[key]
+    } else {
+      throw new Error('bad path')
+    }
+    return this.submitOp([op], cb)
   },
 
   insertAt(path, pos, value, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    const op = {p:path.concat(pos)};
+    const { elem, key } = traverse(this.snapshot, path)
+    const op = { p: path.concat(pos) }
     if (elem[key].constructor === Array) {
-      op.li = value;
+      op.li = value
     } else if (typeof elem[key] === 'string') {
-      op.si = value;
+      op.si = value
     }
-    return this.submitOp([op], cb);
+    return this.submitOp([op], cb)
   },
 
   moveAt(path, from, to, cb) {
-    const op = [{p:path.concat(from), lm:to}];
-    return this.submitOp(op, cb);
+    const op = [{ p: path.concat(from), lm: to }]
+    return this.submitOp(op, cb)
   },
 
   addAt(path, amount, cb) {
-    const op = [{p:path, na:amount}];
-    return this.submitOp(op, cb);
+    const op = [{ p: path, na: amount }]
+    return this.submitOp(op, cb)
   },
 
   deleteTextAt(path, length, pos, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    const op = [{p:path.concat(pos), sd:elem[key].slice(pos, (pos + length))}];
-    return this.submitOp(op, cb);
+    const { elem, key } = traverse(this.snapshot, path)
+    const op = [{ p: path.concat(pos), sd: elem[key].slice(pos, pos + length) }]
+    return this.submitOp(op, cb)
   },
 
   addListener(path, event, cb) {
-    const l = {path, event, cb};
-    this._listeners.push(l);
-    return l;
+    const l = { path, event, cb }
+    this._listeners.push(l)
+    return l
   },
   removeListener(l) {
-    const i = this._listeners.indexOf(l);
-    if (i < 0) { return false; }
-    this._listeners.splice(i, 1);
-    return true;
+    const i = this._listeners.indexOf(l)
+    if (i < 0) {
+      return false
+    }
+    this._listeners.splice(i, 1)
+    return true
   },
   _register() {
-    this._listeners = [];
-    this.on('change', function(op) {
+    this._listeners = []
+    this.on('change', function (op) {
       return (() => {
-        const result = [];
+        const result = []
         for (const c of Array.from(op)) {
-          var i;
-          if ((c.na !== undefined) || (c.si !== undefined) || (c.sd !== undefined)) {
+          var i
+          if (c.na !== undefined || c.si !== undefined || c.sd !== undefined) {
             // no change to structure
-            continue;
+            continue
           }
-          var to_remove = [];
+          var to_remove = []
           for (i = 0; i < this._listeners.length; i++) {
             // Transform a dummy op by the incoming op to work out what
             // should happen to the listener.
-            const l = this._listeners[i];
-            const dummy = {p:l.path, na:0};
-            const xformed = this.type.transformComponent([], dummy, c, 'left');
+            const l = this._listeners[i]
+            const dummy = { p: l.path, na: 0 }
+            const xformed = this.type.transformComponent([], dummy, c, 'left')
             if (xformed.length === 0) {
               // The op was transformed to noop, so we should delete the listener.
-              to_remove.push(i);
+              to_remove.push(i)
             } else if (xformed.length === 1) {
               // The op remained, so grab its new path into the listener.
-              l.path = xformed[0].p;
+              l.path = xformed[0].p
             } else {
-              throw new Error("Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components.");
+              throw new Error(
+                "Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components."
+              )
             }
           }
-          to_remove.sort((a, b) => b - a);
-          result.push((() => {
-            const result1 = [];
-            for (i of Array.from(to_remove)) {
-              result1.push(this._listeners.splice(i, 1));
-            }
-            return result1;
-          })());
+          to_remove.sort((a, b) => b - a)
+          result.push(
+            (() => {
+              const result1 = []
+              for (i of Array.from(to_remove)) {
+                result1.push(this._listeners.splice(i, 1))
+              }
+              return result1
+            })()
+          )
         }
-        return result;
-      })();
-    });
-    return this.on('remoteop', function(op) {
+        return result
+      })()
+    })
+    return this.on('remoteop', function (op) {
       return (() => {
-        const result = [];
+        const result = []
         for (var c of Array.from(op)) {
-          var match_path = c.na === undefined ? c.p.slice(0, c.p.length-1) : c.p;
-          result.push((() => {
-            const result1 = [];
-            for (const {path, event, cb} of Array.from(this._listeners)) {
-              var common;
-              if (pathEquals(path, match_path)) {
-                switch (event) {
-                  case 'insert':
-                    if ((c.li !== undefined) && (c.ld === undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.li));
-                    } else if ((c.oi !== undefined) && (c.od === undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.oi));
-                    } else if (c.si !== undefined) {
-                      result1.push(cb(c.p[c.p.length-1], c.si));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'delete':
-                    if ((c.li === undefined) && (c.ld !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.ld));
-                    } else if ((c.oi === undefined) && (c.od !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.od));
-                    } else if (c.sd !== undefined) {
-                      result1.push(cb(c.p[c.p.length-1], c.sd));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'replace':
-                    if ((c.li !== undefined) && (c.ld !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.ld, c.li));
-                    } else if ((c.oi !== undefined) && (c.od !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.od, c.oi));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'move':
-                    if (c.lm !== undefined) {
-                      result1.push(cb(c.p[c.p.length-1], c.lm));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'add':
-                    if (c.na !== undefined) {
-                      result1.push(cb(c.na));
-                    } else {
-                      result1.push(undefined);
+          var match_path =
+            c.na === undefined ? c.p.slice(0, c.p.length - 1) : c.p
+          result.push(
+            (() => {
+              const result1 = []
+              for (const { path, event, cb } of Array.from(this._listeners)) {
+                var common
+                if (pathEquals(path, match_path)) {
+                  switch (event) {
+                    case 'insert':
+                      if (c.li !== undefined && c.ld === undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.li))
+                      } else if (c.oi !== undefined && c.od === undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.oi))
+                      } else if (c.si !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.si))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'delete':
+                      if (c.li === undefined && c.ld !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.ld))
+                      } else if (c.oi === undefined && c.od !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.od))
+                      } else if (c.sd !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.sd))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'replace':
+                      if (c.li !== undefined && c.ld !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.ld, c.li))
+                      } else if (c.oi !== undefined && c.od !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.od, c.oi))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'move':
+                      if (c.lm !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.lm))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'add':
+                      if (c.na !== undefined) {
+                        result1.push(cb(c.na))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    default:
+                      result1.push(undefined)
+                  }
+                } else if (
+                  (common = this.type.commonPath(match_path, path)) != null
+                ) {
+                  if (event === 'child op') {
+                    if (
+                      match_path.length === path.length &&
+                      path.length === common
+                    ) {
+                      throw new Error(
+                        "paths match length and have commonality, but aren't equal?"
+                      )
                     }
-                    break;
-                  default:
-                    result1.push(undefined);
-                }
-              } else if ((common = this.type.commonPath(match_path, path)) != null) {
-                if (event === 'child op') {
-                  if (match_path.length === path.length && path.length === common) {
-                    throw new Error("paths match length and have commonality, but aren't equal?");
+                    const child_path = c.p.slice(common + 1)
+                    result1.push(cb(child_path, c))
+                  } else {
+                    result1.push(undefined)
                   }
-                  const child_path = c.p.slice(common+1);
-                  result1.push(cb(child_path, c));
                 } else {
-                  result1.push(undefined);
+                  result1.push(undefined)
                 }
-              } else {
-                result1.push(undefined);
               }
-            }
-            return result1;
-          })());
+              return result1
+            })()
+          )
         }
-        return result;
-      })();
-    });
+        return result
+      })()
+    })
   }
-};
+}

+ 347 - 258
services/document-updater/app/js/sharejs/json.js

@@ -16,382 +16,459 @@
 //
 // Spec is here: https://github.com/josephg/ShareJS/wiki/JSON-Operations
 
-let text;
+let text
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  ({
-    text
-  } = exports.types);
+  ;({ text } = exports.types)
 } else {
-  text = require('./text');
+  text = require('./text')
 }
 
-const json = {};
+const json = {}
 
-json.name = 'json';
+json.name = 'json'
 
-json.create = () => null;
+json.create = () => null
 
-json.invertComponent = function(c) {
-  const c_ = {p: c.p};
-  if (c.si !== undefined) { c_.sd = c.si; }
-  if (c.sd !== undefined) { c_.si = c.sd; }
-  if (c.oi !== undefined) { c_.od = c.oi; }
-  if (c.od !== undefined) { c_.oi = c.od; }
-  if (c.li !== undefined) { c_.ld = c.li; }
-  if (c.ld !== undefined) { c_.li = c.ld; }
-  if (c.na !== undefined) { c_.na = -c.na; }
+json.invertComponent = function (c) {
+  const c_ = { p: c.p }
+  if (c.si !== undefined) {
+    c_.sd = c.si
+  }
+  if (c.sd !== undefined) {
+    c_.si = c.sd
+  }
+  if (c.oi !== undefined) {
+    c_.od = c.oi
+  }
+  if (c.od !== undefined) {
+    c_.oi = c.od
+  }
+  if (c.li !== undefined) {
+    c_.ld = c.li
+  }
+  if (c.ld !== undefined) {
+    c_.li = c.ld
+  }
+  if (c.na !== undefined) {
+    c_.na = -c.na
+  }
   if (c.lm !== undefined) {
-    c_.lm = c.p[c.p.length-1];
-    c_.p = c.p.slice(0, c.p.length - 1).concat([c.lm]);
+    c_.lm = c.p[c.p.length - 1]
+    c_.p = c.p.slice(0, c.p.length - 1).concat([c.lm])
   }
-  return c_;
-};
+  return c_
+}
 
-json.invert = op => Array.from(op.slice().reverse()).map((c) => json.invertComponent(c));
+json.invert = (op) =>
+  Array.from(op.slice().reverse()).map((c) => json.invertComponent(c))
 
-json.checkValidOp = function(op) {};
+json.checkValidOp = function (op) {}
 
-const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
-json.checkList = function(elem) {
-  if (!isArray(elem)) { throw new Error('Referenced element not a list'); }
-};
+const isArray = (o) => Object.prototype.toString.call(o) === '[object Array]'
+json.checkList = function (elem) {
+  if (!isArray(elem)) {
+    throw new Error('Referenced element not a list')
+  }
+}
 
-json.checkObj = function(elem) {
-  if (elem.constructor !== Object) { throw new Error(`Referenced element not an object (it was ${JSON.stringify(elem)})`); }
-};
+json.checkObj = function (elem) {
+  if (elem.constructor !== Object) {
+    throw new Error(
+      `Referenced element not an object (it was ${JSON.stringify(elem)})`
+    )
+  }
+}
 
-json.apply = function(snapshot, op) {
-  json.checkValidOp(op);
-  op = clone(op);
+json.apply = function (snapshot, op) {
+  json.checkValidOp(op)
+  op = clone(op)
 
-  const container = {data: clone(snapshot)};
+  const container = { data: clone(snapshot) }
 
   try {
     for (let i = 0; i < op.length; i++) {
-      const c = op[i];
-      let parent = null;
-      let parentkey = null;
-      let elem = container;
-      let key = 'data';
+      const c = op[i]
+      let parent = null
+      let parentkey = null
+      let elem = container
+      let key = 'data'
 
       for (const p of Array.from(c.p)) {
-        parent = elem;
-        parentkey = key;
-        elem = elem[key];
-        key = p;
+        parent = elem
+        parentkey = key
+        elem = elem[key]
+        key = p
 
-        if (parent == null) { throw new Error('Path invalid'); }
+        if (parent == null) {
+          throw new Error('Path invalid')
+        }
       }
 
       if (c.na !== undefined) {
         // Number add
-        if (typeof elem[key] !== 'number') { throw new Error('Referenced element not a number'); }
-        elem[key] += c.na;
-
+        if (typeof elem[key] !== 'number') {
+          throw new Error('Referenced element not a number')
+        }
+        elem[key] += c.na
       } else if (c.si !== undefined) {
         // String insert
-        if (typeof elem !== 'string') { throw new Error(`Referenced element not a string (it was ${JSON.stringify(elem)})`); }
-        parent[parentkey] = elem.slice(0, key) + c.si + elem.slice(key);
+        if (typeof elem !== 'string') {
+          throw new Error(
+            `Referenced element not a string (it was ${JSON.stringify(elem)})`
+          )
+        }
+        parent[parentkey] = elem.slice(0, key) + c.si + elem.slice(key)
       } else if (c.sd !== undefined) {
         // String delete
-        if (typeof elem !== 'string') { throw new Error('Referenced element not a string'); }
-        if (elem.slice(key, key + c.sd.length) !== c.sd) { throw new Error('Deleted string does not match'); }
-        parent[parentkey] = elem.slice(0, key) + elem.slice(key + c.sd.length);
-
-      } else if ((c.li !== undefined) && (c.ld !== undefined)) {
+        if (typeof elem !== 'string') {
+          throw new Error('Referenced element not a string')
+        }
+        if (elem.slice(key, key + c.sd.length) !== c.sd) {
+          throw new Error('Deleted string does not match')
+        }
+        parent[parentkey] = elem.slice(0, key) + elem.slice(key + c.sd.length)
+      } else if (c.li !== undefined && c.ld !== undefined) {
         // List replace
-        json.checkList(elem);
+        json.checkList(elem)
 
         // Should check the list element matches c.ld
-        elem[key] = c.li;
+        elem[key] = c.li
       } else if (c.li !== undefined) {
         // List insert
-        json.checkList(elem);
+        json.checkList(elem)
 
-        elem.splice(key, 0, c.li);
+        elem.splice(key, 0, c.li)
       } else if (c.ld !== undefined) {
         // List delete
-        json.checkList(elem);
+        json.checkList(elem)
 
         // Should check the list element matches c.ld here too.
-        elem.splice(key, 1);
+        elem.splice(key, 1)
       } else if (c.lm !== undefined) {
         // List move
-        json.checkList(elem);
+        json.checkList(elem)
         if (c.lm !== key) {
-          const e = elem[key];
+          const e = elem[key]
           // Remove it...
-          elem.splice(key, 1);
+          elem.splice(key, 1)
           // And insert it back.
-          elem.splice(c.lm, 0, e);
+          elem.splice(c.lm, 0, e)
         }
-
       } else if (c.oi !== undefined) {
         // Object insert / replace
-        json.checkObj(elem);
-        
+        json.checkObj(elem)
+
         // Should check that elem[key] == c.od
-        elem[key] = c.oi;
+        elem[key] = c.oi
       } else if (c.od !== undefined) {
         // Object delete
-        json.checkObj(elem);
+        json.checkObj(elem)
 
         // Should check that elem[key] == c.od
-        delete elem[key];
+        delete elem[key]
       } else {
-        throw new Error('invalid / missing instruction in op');
+        throw new Error('invalid / missing instruction in op')
       }
     }
   } catch (error) {
     // TODO: Roll back all already applied changes. Write tests before implementing this code.
-    throw error;
+    throw error
   }
 
-  return container.data;
-};
+  return container.data
+}
 
 // Checks if two paths, p1 and p2 match.
-json.pathMatches = function(p1, p2, ignoreLast) {
-  if (p1.length !== p2.length) { return false; }
+json.pathMatches = function (p1, p2, ignoreLast) {
+  if (p1.length !== p2.length) {
+    return false
+  }
 
   for (let i = 0; i < p1.length; i++) {
-    const p = p1[i];
-    if ((p !== p2[i]) && (!ignoreLast || (i !== (p1.length - 1)))) { return false; }
+    const p = p1[i]
+    if (p !== p2[i] && (!ignoreLast || i !== p1.length - 1)) {
+      return false
+    }
   }
-      
-  return true;
-};
-
-json.append = function(dest, c) {
-  let last;
-  c = clone(c);
-  if ((dest.length !== 0) && json.pathMatches(c.p, (last = dest[dest.length - 1]).p)) {
-    if ((last.na !== undefined) && (c.na !== undefined)) {
-      return dest[dest.length - 1] = { p: last.p, na: last.na + c.na };
-    } else if ((last.li !== undefined) && (c.li === undefined) && (c.ld === last.li)) {
+
+  return true
+}
+
+json.append = function (dest, c) {
+  let last
+  c = clone(c)
+  if (
+    dest.length !== 0 &&
+    json.pathMatches(c.p, (last = dest[dest.length - 1]).p)
+  ) {
+    if (last.na !== undefined && c.na !== undefined) {
+      return (dest[dest.length - 1] = { p: last.p, na: last.na + c.na })
+    } else if (
+      last.li !== undefined &&
+      c.li === undefined &&
+      c.ld === last.li
+    ) {
       // insert immediately followed by delete becomes a noop.
       if (last.ld !== undefined) {
         // leave the delete part of the replace
-        return delete last.li;
+        return delete last.li
       } else {
-        return dest.pop();
+        return dest.pop()
       }
-    } else if ((last.od !== undefined) && (last.oi === undefined) &&
-        (c.oi !== undefined) && (c.od === undefined)) {
-      return last.oi = c.oi;
-    } else if ((c.lm !== undefined) && (c.p[c.p.length-1] === c.lm)) {
-      return null; // don't do anything
+    } else if (
+      last.od !== undefined &&
+      last.oi === undefined &&
+      c.oi !== undefined &&
+      c.od === undefined
+    ) {
+      return (last.oi = c.oi)
+    } else if (c.lm !== undefined && c.p[c.p.length - 1] === c.lm) {
+      return null // don't do anything
     } else {
-      return dest.push(c);
+      return dest.push(c)
     }
   } else {
-    return dest.push(c);
+    return dest.push(c)
   }
-};
+}
 
-json.compose = function(op1, op2) {
-  json.checkValidOp(op1);
-  json.checkValidOp(op2);
+json.compose = function (op1, op2) {
+  json.checkValidOp(op1)
+  json.checkValidOp(op2)
 
-  const newOp = clone(op1);
-  for (const c of Array.from(op2)) { json.append(newOp, c); }
+  const newOp = clone(op1)
+  for (const c of Array.from(op2)) {
+    json.append(newOp, c)
+  }
+
+  return newOp
+}
 
-  return newOp;
-};
+json.normalize = function (op) {
+  const newOp = []
 
-json.normalize = function(op) {
-  const newOp = [];
-  
-  if (!isArray(op)) { op = [op]; }
+  if (!isArray(op)) {
+    op = [op]
+  }
 
   for (const c of Array.from(op)) {
-    if (c.p == null) { c.p = []; }
-    json.append(newOp, c);
+    if (c.p == null) {
+      c.p = []
+    }
+    json.append(newOp, c)
   }
-  
-  return newOp;
-};
+
+  return newOp
+}
 
 // hax, copied from test/types/json. Apparently this is still the fastest way to deep clone an object, assuming
 // we have browser support for JSON.
 // http://jsperf.com/cloning-an-object/12
-var clone = o => JSON.parse(JSON.stringify(o));
-
-json.commonPath = function(p1, p2) {
-  p1 = p1.slice();
-  p2 = p2.slice();
-  p1.unshift('data');
-  p2.unshift('data');
-  p1 = p1.slice(0, p1.length-1);
-  p2 = p2.slice(0, p2.length-1);
-  if (p2.length === 0) { return -1; }
-  let i = 0;
-  while ((p1[i] === p2[i]) && (i < p1.length)) {
-    i++;
+var clone = (o) => JSON.parse(JSON.stringify(o))
+
+json.commonPath = function (p1, p2) {
+  p1 = p1.slice()
+  p2 = p2.slice()
+  p1.unshift('data')
+  p2.unshift('data')
+  p1 = p1.slice(0, p1.length - 1)
+  p2 = p2.slice(0, p2.length - 1)
+  if (p2.length === 0) {
+    return -1
+  }
+  let i = 0
+  while (p1[i] === p2[i] && i < p1.length) {
+    i++
     if (i === p2.length) {
-      return i-1;
+      return i - 1
     }
   }
-};
+}
 
 // transform c so it applies to a document with otherC applied.
-json.transformComponent = function(dest, c, otherC, type) {
-  let oc;
-  c = clone(c);
-  if (c.na !== undefined) { c.p.push(0); }
-  if (otherC.na !== undefined) { otherC.p.push(0); }
+json.transformComponent = function (dest, c, otherC, type) {
+  let oc
+  c = clone(c)
+  if (c.na !== undefined) {
+    c.p.push(0)
+  }
+  if (otherC.na !== undefined) {
+    otherC.p.push(0)
+  }
 
-  const common = json.commonPath(c.p, otherC.p);
-  const common2 = json.commonPath(otherC.p, c.p);
+  const common = json.commonPath(c.p, otherC.p)
+  const common2 = json.commonPath(otherC.p, c.p)
 
-  const cplength = c.p.length;
-  const otherCplength = otherC.p.length;
+  const cplength = c.p.length
+  const otherCplength = otherC.p.length
 
-  if (c.na !== undefined) { c.p.pop(); } // hax
-  if (otherC.na !== undefined) { otherC.p.pop(); }
+  if (c.na !== undefined) {
+    c.p.pop()
+  } // hax
+  if (otherC.na !== undefined) {
+    otherC.p.pop()
+  }
 
   if (otherC.na) {
-    if ((common2 != null) && (otherCplength >= cplength) && (otherC.p[common2] === c.p[common2])) {
+    if (
+      common2 != null &&
+      otherCplength >= cplength &&
+      otherC.p[common2] === c.p[common2]
+    ) {
       if (c.ld !== undefined) {
-        oc = clone(otherC);
-        oc.p = oc.p.slice(cplength);
-        c.ld = json.apply(clone(c.ld), [oc]);
+        oc = clone(otherC)
+        oc.p = oc.p.slice(cplength)
+        c.ld = json.apply(clone(c.ld), [oc])
       } else if (c.od !== undefined) {
-        oc = clone(otherC);
-        oc.p = oc.p.slice(cplength);
-        c.od = json.apply(clone(c.od), [oc]);
+        oc = clone(otherC)
+        oc.p = oc.p.slice(cplength)
+        c.od = json.apply(clone(c.od), [oc])
       }
     }
-    json.append(dest, c);
-    return dest;
+    json.append(dest, c)
+    return dest
   }
 
-  if ((common2 != null) && (otherCplength > cplength) && (c.p[common2] === otherC.p[common2])) {
+  if (
+    common2 != null &&
+    otherCplength > cplength &&
+    c.p[common2] === otherC.p[common2]
+  ) {
     // transform based on c
     if (c.ld !== undefined) {
-      oc = clone(otherC);
-      oc.p = oc.p.slice(cplength);
-      c.ld = json.apply(clone(c.ld), [oc]);
+      oc = clone(otherC)
+      oc.p = oc.p.slice(cplength)
+      c.ld = json.apply(clone(c.ld), [oc])
     } else if (c.od !== undefined) {
-      oc = clone(otherC);
-      oc.p = oc.p.slice(cplength);
-      c.od = json.apply(clone(c.od), [oc]);
+      oc = clone(otherC)
+      oc.p = oc.p.slice(cplength)
+      c.od = json.apply(clone(c.od), [oc])
     }
   }
 
-
   if (common != null) {
-    let from, p, to;
-    const commonOperand = cplength === otherCplength;
+    let from, p, to
+    const commonOperand = cplength === otherCplength
     // transform based on otherC
     if (otherC.na !== undefined) {
       // this case is handled above due to icky path hax
-    } else if ((otherC.si !== undefined) || (otherC.sd !== undefined)) {
+    } else if (otherC.si !== undefined || otherC.sd !== undefined) {
       // String op vs string op - pass through to text type
-      if ((c.si !== undefined) || (c.sd !== undefined)) {
-        if (!commonOperand) { throw new Error("must be a string?"); }
+      if (c.si !== undefined || c.sd !== undefined) {
+        if (!commonOperand) {
+          throw new Error('must be a string?')
+        }
 
         // Convert an op component to a text op component
-        const convert = function(component) {
-          const newC = {p:component.p[component.p.length - 1]};
+        const convert = function (component) {
+          const newC = { p: component.p[component.p.length - 1] }
           if (component.si) {
-            newC.i = component.si;
+            newC.i = component.si
           } else {
-            newC.d = component.sd;
+            newC.d = component.sd
           }
-          return newC;
-        };
-
-        const tc1 = convert(c);
-        const tc2 = convert(otherC);
-          
-        const res = [];
-        text._tc(res, tc1, tc2, type);
+          return newC
+        }
+
+        const tc1 = convert(c)
+        const tc2 = convert(otherC)
+
+        const res = []
+        text._tc(res, tc1, tc2, type)
         for (const tc of Array.from(res)) {
-          const jc = { p: c.p.slice(0, common) };
-          jc.p.push(tc.p);
-          if (tc.i != null) { jc.si = tc.i; }
-          if (tc.d != null) { jc.sd = tc.d; }
-          json.append(dest, jc);
+          const jc = { p: c.p.slice(0, common) }
+          jc.p.push(tc.p)
+          if (tc.i != null) {
+            jc.si = tc.i
+          }
+          if (tc.d != null) {
+            jc.sd = tc.d
+          }
+          json.append(dest, jc)
         }
-        return dest;
+        return dest
       }
-    } else if ((otherC.li !== undefined) && (otherC.ld !== undefined)) {
+    } else if (otherC.li !== undefined && otherC.ld !== undefined) {
       if (otherC.p[common] === c.p[common]) {
         // noop
         if (!commonOperand) {
           // we're below the deleted element, so -> noop
-          return dest;
+          return dest
         } else if (c.ld !== undefined) {
           // we're trying to delete the same element, -> noop
-          if ((c.li !== undefined) && (type === 'left')) {
+          if (c.li !== undefined && type === 'left') {
             // we're both replacing one element with another. only one can
             // survive!
-            c.ld = clone(otherC.li);
+            c.ld = clone(otherC.li)
           } else {
-            return dest;
+            return dest
           }
         }
       }
     } else if (otherC.li !== undefined) {
-      if ((c.li !== undefined) && (c.ld === undefined) && commonOperand && (c.p[common] === otherC.p[common])) {
+      if (
+        c.li !== undefined &&
+        c.ld === undefined &&
+        commonOperand &&
+        c.p[common] === otherC.p[common]
+      ) {
         // in li vs. li, left wins.
         if (type === 'right') {
-          c.p[common]++;
+          c.p[common]++
         }
       } else if (otherC.p[common] <= c.p[common]) {
-        c.p[common]++;
+        c.p[common]++
       }
 
       if (c.lm !== undefined) {
         if (commonOperand) {
           // otherC edits the same list we edit
           if (otherC.p[common] <= c.lm) {
-            c.lm++;
+            c.lm++
           }
         }
       }
-          // changing c.from is handled above.
+      // changing c.from is handled above.
     } else if (otherC.ld !== undefined) {
       if (c.lm !== undefined) {
         if (commonOperand) {
           if (otherC.p[common] === c.p[common]) {
             // they deleted the thing we're trying to move
-            return dest;
+            return dest
           }
           // otherC edits the same list we edit
-          p = otherC.p[common];
-          from = c.p[common];
-          to = c.lm;
-          if ((p < to) || ((p === to) && (from < to))) {
-            c.lm--;
+          p = otherC.p[common]
+          from = c.p[common]
+          to = c.lm
+          if (p < to || (p === to && from < to)) {
+            c.lm--
           }
         }
       }
 
       if (otherC.p[common] < c.p[common]) {
-        c.p[common]--;
+        c.p[common]--
       } else if (otherC.p[common] === c.p[common]) {
         if (otherCplength < cplength) {
           // we're below the deleted element, so -> noop
-          return dest;
+          return dest
         } else if (c.ld !== undefined) {
           if (c.li !== undefined) {
             // we're replacing, they're deleting. we become an insert.
-            delete c.ld;
+            delete c.ld
           } else {
             // we're trying to delete the same element, -> noop
-            return dest;
+            return dest
           }
         }
       }
     } else if (otherC.lm !== undefined) {
-      if ((c.lm !== undefined) && (cplength === otherCplength)) {
+      if (c.lm !== undefined && cplength === otherCplength) {
         // lm vs lm, here we go!
-        from = c.p[common];
-        to = c.lm;
-        const otherFrom = otherC.p[common];
-        const otherTo = otherC.lm;
+        from = c.p[common]
+        to = c.lm
+        const otherFrom = otherC.p[common]
+        const otherTo = otherC.lm
         if (otherFrom !== otherTo) {
           // if otherFrom == otherTo, we don't need to change our op.
 
@@ -399,143 +476,155 @@ json.transformComponent = function(dest, c, otherC, type) {
           if (from === otherFrom) {
             // they moved it! tie break.
             if (type === 'left') {
-              c.p[common] = otherTo;
-              if (from === to) { // ugh
-                c.lm = otherTo;
+              c.p[common] = otherTo
+              if (from === to) {
+                // ugh
+                c.lm = otherTo
               }
             } else {
-              return dest;
+              return dest
             }
           } else {
             // they moved around it
             if (from > otherFrom) {
-              c.p[common]--;
+              c.p[common]--
             }
             if (from > otherTo) {
-              c.p[common]++;
+              c.p[common]++
             } else if (from === otherTo) {
               if (otherFrom > otherTo) {
-                c.p[common]++;
-                if (from === to) { // ugh, again
-                  c.lm++;
+                c.p[common]++
+                if (from === to) {
+                  // ugh, again
+                  c.lm++
                 }
               }
             }
 
             // step 2: where am i going to put it?
             if (to > otherFrom) {
-              c.lm--;
+              c.lm--
             } else if (to === otherFrom) {
               if (to > from) {
-                c.lm--;
+                c.lm--
               }
             }
             if (to > otherTo) {
-              c.lm++;
+              c.lm++
             } else if (to === otherTo) {
               // if we're both moving in the same direction, tie break
-              if (((otherTo > otherFrom) && (to > from)) ||
-                 ((otherTo < otherFrom) && (to < from))) {
+              if (
+                (otherTo > otherFrom && to > from) ||
+                (otherTo < otherFrom && to < from)
+              ) {
                 if (type === 'right') {
-                  c.lm++;
+                  c.lm++
                 }
               } else {
                 if (to > from) {
-                  c.lm++;
+                  c.lm++
                 } else if (to === otherFrom) {
-                  c.lm--;
+                  c.lm--
                 }
               }
             }
           }
         }
-      } else if ((c.li !== undefined) && (c.ld === undefined) && commonOperand) {
+      } else if (c.li !== undefined && c.ld === undefined && commonOperand) {
         // li
-        from = otherC.p[common];
-        to = otherC.lm;
-        p = c.p[common];
+        from = otherC.p[common]
+        to = otherC.lm
+        p = c.p[common]
         if (p > from) {
-          c.p[common]--;
+          c.p[common]--
         }
         if (p > to) {
-          c.p[common]++;
+          c.p[common]++
         }
       } else {
         // ld, ld+li, si, sd, na, oi, od, oi+od, any li on an element beneath
         // the lm
         //
         // i.e. things care about where their item is after the move.
-        from = otherC.p[common];
-        to = otherC.lm;
-        p = c.p[common];
+        from = otherC.p[common]
+        to = otherC.lm
+        p = c.p[common]
         if (p === from) {
-          c.p[common] = to;
+          c.p[common] = to
         } else {
           if (p > from) {
-            c.p[common]--;
+            c.p[common]--
           }
           if (p > to) {
-            c.p[common]++;
+            c.p[common]++
           } else if (p === to) {
             if (from > to) {
-              c.p[common]++;
+              c.p[common]++
             }
           }
         }
       }
-    } else if ((otherC.oi !== undefined) && (otherC.od !== undefined)) {
+    } else if (otherC.oi !== undefined && otherC.od !== undefined) {
       if (c.p[common] === otherC.p[common]) {
-        if ((c.oi !== undefined) && commonOperand) {
+        if (c.oi !== undefined && commonOperand) {
           // we inserted where someone else replaced
           if (type === 'right') {
             // left wins
-            return dest;
+            return dest
           } else {
             // we win, make our op replace what they inserted
-            c.od = otherC.oi;
+            c.od = otherC.oi
           }
         } else {
           // -> noop if the other component is deleting the same object (or any
           // parent)
-          return dest;
+          return dest
         }
       }
     } else if (otherC.oi !== undefined) {
-      if ((c.oi !== undefined) && (c.p[common] === otherC.p[common])) {
+      if (c.oi !== undefined && c.p[common] === otherC.p[common]) {
         // left wins if we try to insert at the same place
         if (type === 'left') {
-          json.append(dest, {p:c.p, od:otherC.oi});
+          json.append(dest, { p: c.p, od: otherC.oi })
         } else {
-          return dest;
+          return dest
         }
       }
     } else if (otherC.od !== undefined) {
       if (c.p[common] === otherC.p[common]) {
-        if (!commonOperand) { return dest; }
+        if (!commonOperand) {
+          return dest
+        }
         if (c.oi !== undefined) {
-          delete c.od;
+          delete c.od
         } else {
-          return dest;
+          return dest
         }
       }
     }
   }
-  
-  json.append(dest, c);
-  return dest;
-};
+
+  json.append(dest, c)
+  return dest
+}
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  if (!exports.types) { exports.types = {}; }
+  if (!exports.types) {
+    exports.types = {}
+  }
 
   // This is kind of awful - come up with a better way to hook this helper code up.
-  exports._bt(json, json.transformComponent, json.checkValidOp, json.append);
+  exports._bt(json, json.transformComponent, json.checkValidOp, json.append)
 
   // [] is used to prevent closure from renaming types.text
-  exports.types.json = json;
+  exports.types.json = json
 } else {
-  module.exports = json;
-
-  require('./helpers').bootstrapTransform(json, json.transformComponent, json.checkValidOp, json.append);
+  module.exports = json
+
+  require('./helpers').bootstrapTransform(
+    json,
+    json.transformComponent,
+    json.checkValidOp,
+    json.append
+  )
 }
-

+ 504 - 325
services/document-updater/app/js/sharejs/model.js

@@ -21,13 +21,13 @@
 //
 // Actual storage is handled by the database wrappers in db/*, wrapped by DocCache
 
-let Model;
-const {EventEmitter} = require('events');
+let Model
+const { EventEmitter } = require('events')
 
-const queue = require('./syncqueue');
-const types = require('../types');
+const queue = require('./syncqueue')
+const types = require('../types')
 
-const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
+const isArray = (o) => Object.prototype.toString.call(o) === '[object Array]'
 
 // This constructor creates a new Model object. There will be one model object
 // per server context.
@@ -41,15 +41,19 @@ const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
 // The model is an event emitter. It emits the following events:
 //
 // create(docName, data): A document has been created with the specified name & data
-module.exports = (Model = function(db, options) {
+module.exports = Model = function (db, options) {
   // db can be null if the user doesn't want persistance.
 
-  let getOps;
-  if (!(this instanceof Model)) { return new Model(db, options); }
+  let getOps
+  if (!(this instanceof Model)) {
+    return new Model(db, options)
+  }
 
-  const model = this;
+  const model = this
 
-  if (options == null) { options = {}; }
+  if (options == null) {
+    options = {}
+  }
 
   // This is a cache of 'live' documents.
   //
@@ -78,164 +82,210 @@ module.exports = (Model = function(db, options) {
   //
   // In any case, the API to model is designed such that if we want to change that later
   // it should be pretty easy to do so without any external-to-the-model code changes.
-  const docs = {};
+  const docs = {}
 
   // This is a map from docName -> [callback]. It is used when a document hasn't been
   // cached and multiple getSnapshot() / getVersion() requests come in. All requests
   // are added to the callback list and called when db.getSnapshot() returns.
   //
   // callback(error, snapshot data)
-  const awaitingGetSnapshot = {};
+  const awaitingGetSnapshot = {}
 
   // The time that documents which no clients have open will stay in the cache.
   // Should be > 0.
-  if (options.reapTime == null) { options.reapTime = 3000; }
+  if (options.reapTime == null) {
+    options.reapTime = 3000
+  }
 
   // The number of operations the cache holds before reusing the space
-  if (options.numCachedOps == null) { options.numCachedOps = 10; }
+  if (options.numCachedOps == null) {
+    options.numCachedOps = 10
+  }
 
   // This option forces documents to be reaped, even when there's no database backend.
   // This is useful when you don't care about persistance and don't want to gradually
   // fill memory.
   //
   // You might want to set reapTime to a day or something.
-  if (options.forceReaping == null) { options.forceReaping = false; }
+  if (options.forceReaping == null) {
+    options.forceReaping = false
+  }
 
   // Until I come up with a better strategy, we'll save a copy of the document snapshot
   // to the database every ~20 submitted ops.
-  if (options.opsBeforeCommit == null) { options.opsBeforeCommit = 20; }
+  if (options.opsBeforeCommit == null) {
+    options.opsBeforeCommit = 20
+  }
 
   // It takes some processing time to transform client ops. The server will punt ops back to the
   // client to transform if they're too old.
-  if (options.maximumAge == null) { options.maximumAge = 40; }
+  if (options.maximumAge == null) {
+    options.maximumAge = 40
+  }
 
   // **** Cache API methods
 
   // Its important that all ops are applied in order. This helper method creates the op submission queue
   // for a single document. This contains the logic for transforming & applying ops.
-  const makeOpQueue = (docName, doc) => queue(function(opData, callback) {
-    if (!(opData.v >= 0)) { return callback('Version missing'); }
-    if (opData.v > doc.v) { return callback('Op at future version'); }
-
-    // Punt the transforming work back to the client if the op is too old.
-    if ((opData.v + options.maximumAge) < doc.v) { return callback('Op too old'); }
-
-    if (!opData.meta) { opData.meta = {}; }
-    opData.meta.ts = Date.now();
-
-    // We'll need to transform the op to the current version of the document. This
-    // calls the callback immediately if opVersion == doc.v.
-    return getOps(docName, opData.v, doc.v, function(error, ops) {
-      let snapshot;
-      if (error) { return callback(error); }
-
-      if ((doc.v - opData.v) !== ops.length) {
-        // This should never happen. It indicates that we didn't get all the ops we
-        // asked for. Its important that the submitted op is correctly transformed.
-        console.error(`Could not get old ops in model for document ${docName}`);
-        console.error(`Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`);
-        return callback('Internal error');
+  const makeOpQueue = (docName, doc) =>
+    queue(function (opData, callback) {
+      if (!(opData.v >= 0)) {
+        return callback('Version missing')
       }
-
-      if (ops.length > 0) {
-        try {
-          // If there's enough ops, it might be worth spinning this out into a webworker thread.
-          for (const oldOp of Array.from(ops)) {
-            // Dup detection works by sending the id(s) the op has been submitted with previously.
-            // If the id matches, we reject it. The client can also detect the op has been submitted
-            // already if it sees its own previous id in the ops it sees when it does catchup.
-            if (oldOp.meta.source && opData.dupIfSource && Array.from(opData.dupIfSource).includes(oldOp.meta.source)) {
-              return callback('Op already submitted');
-            }
-
-            opData.op = doc.type.transform(opData.op, oldOp.op, 'left');
-            opData.v++;
-          }
-        } catch (error1) {
-          error = error1;
-          console.error(error.stack);
-          return callback(error.message);
-        }
+      if (opData.v > doc.v) {
+        return callback('Op at future version')
       }
 
-      try {
-        snapshot = doc.type.apply(doc.snapshot, opData.op);
-      } catch (error2) {
-        error = error2;
-        console.error(error.stack);
-        return callback(error.message);
+      // Punt the transforming work back to the client if the op is too old.
+      if (opData.v + options.maximumAge < doc.v) {
+        return callback('Op too old')
       }
 
-      // The op data should be at the current version, and the new document data should be at
-      // the next version.
-      //
-      // This should never happen in practice, but its a nice little check to make sure everything
-      // is hunky-dory.
-      if (opData.v !== doc.v) {
-        // This should never happen.
-        console.error("Version mismatch detected in model. File a ticket - this is a bug.");
-        console.error(`Expecting ${opData.v} == ${doc.v}`);
-        return callback('Internal error');
+      if (!opData.meta) {
+        opData.meta = {}
       }
+      opData.meta.ts = Date.now()
 
-      // newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
-      const writeOp = (db != null ? db.writeOp : undefined) || ((docName, newOpData, callback) => callback());
-
-      return writeOp(docName, opData, function(error) {
+      // We'll need to transform the op to the current version of the document. This
+      // calls the callback immediately if opVersion == doc.v.
+      return getOps(docName, opData.v, doc.v, function (error, ops) {
+        let snapshot
         if (error) {
-          // The user should probably know about this.
-          console.warn(`Error writing ops to database: ${error}`);
-          return callback(error);
+          return callback(error)
         }
 
-        __guardMethod__(options.stats, 'writeOp', o => o.writeOp());
-
-        // This is needed when we emit the 'change' event, below.
-        const oldSnapshot = doc.snapshot;
-
-        // All the heavy lifting is now done. Finally, we'll update the cache with the new data
-        // and (maybe!) save a new document snapshot to the database.
+        if (doc.v - opData.v !== ops.length) {
+          // This should never happen. It indicates that we didn't get all the ops we
+          // asked for. Its important that the submitted op is correctly transformed.
+          console.error(
+            `Could not get old ops in model for document ${docName}`
+          )
+          console.error(
+            `Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`
+          )
+          return callback('Internal error')
+        }
 
-        doc.v = opData.v + 1;
-        doc.snapshot = snapshot;
+        if (ops.length > 0) {
+          try {
+            // If there's enough ops, it might be worth spinning this out into a webworker thread.
+            for (const oldOp of Array.from(ops)) {
+              // Dup detection works by sending the id(s) the op has been submitted with previously.
+              // If the id matches, we reject it. The client can also detect the op has been submitted
+              // already if it sees its own previous id in the ops it sees when it does catchup.
+              if (
+                oldOp.meta.source &&
+                opData.dupIfSource &&
+                Array.from(opData.dupIfSource).includes(oldOp.meta.source)
+              ) {
+                return callback('Op already submitted')
+              }
 
-        doc.ops.push(opData);
-        if (db && (doc.ops.length > options.numCachedOps)) { doc.ops.shift(); }
+              opData.op = doc.type.transform(opData.op, oldOp.op, 'left')
+              opData.v++
+            }
+          } catch (error1) {
+            error = error1
+            console.error(error.stack)
+            return callback(error.message)
+          }
+        }
 
-        model.emit('applyOp', docName, opData, snapshot, oldSnapshot);
-        doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot);
+        try {
+          snapshot = doc.type.apply(doc.snapshot, opData.op)
+        } catch (error2) {
+          error = error2
+          console.error(error.stack)
+          return callback(error.message)
+        }
 
-        // The callback is called with the version of the document at which the op was applied.
-        // This is the op.v after transformation, and its doc.v - 1.
-        callback(null, opData.v);
-    
-        // I need a decent strategy here for deciding whether or not to save the snapshot.
+        // The op data should be at the current version, and the new document data should be at
+        // the next version.
         //
-        // The 'right' strategy looks something like "Store the snapshot whenever the snapshot
-        // is smaller than the accumulated op data". For now, I'll just store it every 20
-        // ops or something. (Configurable with doc.committedVersion)
-        if (!doc.snapshotWriteLock && ((doc.committedVersion + options.opsBeforeCommit) <= doc.v)) {
-          return tryWriteSnapshot(docName, function(error) {
-            if (error) { return console.warn(`Error writing snapshot ${error}. This is nonfatal`); }
-          });
+        // This should never happen in practice, but its a nice little check to make sure everything
+        // is hunky-dory.
+        if (opData.v !== doc.v) {
+          // This should never happen.
+          console.error(
+            'Version mismatch detected in model. File a ticket - this is a bug.'
+          )
+          console.error(`Expecting ${opData.v} == ${doc.v}`)
+          return callback('Internal error')
         }
-      });
-    });
-  });
+
+        // newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
+        const writeOp =
+          (db != null ? db.writeOp : undefined) ||
+          ((docName, newOpData, callback) => callback())
+
+        return writeOp(docName, opData, function (error) {
+          if (error) {
+            // The user should probably know about this.
+            console.warn(`Error writing ops to database: ${error}`)
+            return callback(error)
+          }
+
+          __guardMethod__(options.stats, 'writeOp', (o) => o.writeOp())
+
+          // This is needed when we emit the 'change' event, below.
+          const oldSnapshot = doc.snapshot
+
+          // All the heavy lifting is now done. Finally, we'll update the cache with the new data
+          // and (maybe!) save a new document snapshot to the database.
+
+          doc.v = opData.v + 1
+          doc.snapshot = snapshot
+
+          doc.ops.push(opData)
+          if (db && doc.ops.length > options.numCachedOps) {
+            doc.ops.shift()
+          }
+
+          model.emit('applyOp', docName, opData, snapshot, oldSnapshot)
+          doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot)
+
+          // The callback is called with the version of the document at which the op was applied.
+          // This is the op.v after transformation, and its doc.v - 1.
+          callback(null, opData.v)
+
+          // I need a decent strategy here for deciding whether or not to save the snapshot.
+          //
+          // The 'right' strategy looks something like "Store the snapshot whenever the snapshot
+          // is smaller than the accumulated op data". For now, I'll just store it every 20
+          // ops or something. (Configurable with doc.committedVersion)
+          if (
+            !doc.snapshotWriteLock &&
+            doc.committedVersion + options.opsBeforeCommit <= doc.v
+          ) {
+            return tryWriteSnapshot(docName, function (error) {
+              if (error) {
+                return console.warn(
+                  `Error writing snapshot ${error}. This is nonfatal`
+                )
+              }
+            })
+          }
+        })
+      })
+    })
 
   // Add the data for the given docName to the cache. The named document shouldn't already
   // exist in the doc set.
   //
   // Returns the new doc.
-  const add = function(docName, error, data, committedVersion, ops, dbMeta) {
-    let callback, doc;
-    const callbacks = awaitingGetSnapshot[docName];
-    delete awaitingGetSnapshot[docName];
+  const add = function (docName, error, data, committedVersion, ops, dbMeta) {
+    let callback, doc
+    const callbacks = awaitingGetSnapshot[docName]
+    delete awaitingGetSnapshot[docName]
 
     if (error) {
-      if (callbacks) { for (callback of Array.from(callbacks)) { callback(error); } }
+      if (callbacks) {
+        for (callback of Array.from(callbacks)) {
+          callback(error)
+        }
+      }
     } else {
-      doc = (docs[docName] = {
+      doc = docs[docName] = {
         snapshot: data.snapshot,
         v: data.v,
         type: data.type,
@@ -244,7 +294,7 @@ module.exports = (Model = function(db, options) {
         // Cache of ops
         ops: ops || [],
 
-        eventEmitter: new EventEmitter,
+        eventEmitter: new EventEmitter(),
 
         // Timer before the document will be invalidated from the cache (if the document has no
         // listeners)
@@ -254,97 +304,121 @@ module.exports = (Model = function(db, options) {
         committedVersion: committedVersion != null ? committedVersion : data.v,
         snapshotWriteLock: false,
         dbMeta
-      });
+      }
 
-      doc.opQueue = makeOpQueue(docName, doc);
-      
-      refreshReapingTimeout(docName);
-      model.emit('add', docName, data);
-      if (callbacks) { for (callback of Array.from(callbacks)) { callback(null, doc); } }
+      doc.opQueue = makeOpQueue(docName, doc)
+
+      refreshReapingTimeout(docName)
+      model.emit('add', docName, data)
+      if (callbacks) {
+        for (callback of Array.from(callbacks)) {
+          callback(null, doc)
+        }
+      }
     }
 
-    return doc;
-  };
-  
+    return doc
+  }
+
   // This is a little helper wrapper around db.getOps. It does two things:
   //
   // - If there's no database set, it returns an error to the callback
   // - It adds version numbers to each op returned from the database
   // (These can be inferred from context so the DB doesn't store them, but its useful to have them).
-  const getOpsInternal = function(docName, start, end, callback) {
-    if (!db) { return (typeof callback === 'function' ? callback('Document does not exist') : undefined); }
+  const getOpsInternal = function (docName, start, end, callback) {
+    if (!db) {
+      return typeof callback === 'function'
+        ? callback('Document does not exist')
+        : undefined
+    }
 
-    return db.getOps(docName, start, end, function(error, ops) {
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+    return db.getOps(docName, start, end, function (error, ops) {
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
-      let v = start;
-      for (const op of Array.from(ops)) { op.v = v++; }
+      let v = start
+      for (const op of Array.from(ops)) {
+        op.v = v++
+      }
 
-      return (typeof callback === 'function' ? callback(null, ops) : undefined);
-    });
-  };
+      return typeof callback === 'function' ? callback(null, ops) : undefined
+    })
+  }
 
   // Load the named document into the cache. This function is re-entrant.
   //
   // The callback is called with (error, doc)
-  const load = function(docName, callback) {
+  const load = function (docName, callback) {
     if (docs[docName]) {
       // The document is already loaded. Return immediately.
-      __guardMethod__(options.stats, 'cacheHit', o => o.cacheHit('getSnapshot'));
-      return callback(null, docs[docName]);
+      __guardMethod__(options.stats, 'cacheHit', (o) =>
+        o.cacheHit('getSnapshot')
+      )
+      return callback(null, docs[docName])
     }
 
     // We're a memory store. If we don't have it, nobody does.
-    if (!db) { return callback('Document does not exist'); }
+    if (!db) {
+      return callback('Document does not exist')
+    }
 
-    const callbacks = awaitingGetSnapshot[docName];
+    const callbacks = awaitingGetSnapshot[docName]
 
     // The document is being loaded already. Add ourselves as a callback.
-    if (callbacks) { return callbacks.push(callback); }
+    if (callbacks) {
+      return callbacks.push(callback)
+    }
 
-    __guardMethod__(options.stats, 'cacheMiss', o1 => o1.cacheMiss('getSnapshot'));
+    __guardMethod__(options.stats, 'cacheMiss', (o1) =>
+      o1.cacheMiss('getSnapshot')
+    )
 
     // The document isn't loaded and isn't being loaded. Load it.
-    awaitingGetSnapshot[docName] = [callback];
-    return db.getSnapshot(docName, function(error, data, dbMeta) {
-      if (error) { return add(docName, error); }
+    awaitingGetSnapshot[docName] = [callback]
+    return db.getSnapshot(docName, function (error, data, dbMeta) {
+      if (error) {
+        return add(docName, error)
+      }
 
-      const type = types[data.type];
+      const type = types[data.type]
       if (!type) {
-        console.warn(`Type '${data.type}' missing`);
-        return callback("Type not found");
+        console.warn(`Type '${data.type}' missing`)
+        return callback('Type not found')
       }
-      data.type = type;
+      data.type = type
 
-      const committedVersion = data.v;
+      const committedVersion = data.v
 
       // The server can close without saving the most recent document snapshot.
       // In this case, there are extra ops which need to be applied before
       // returning the snapshot.
-      return getOpsInternal(docName, data.v, null, function(error, ops) {
-        if (error) { return callback(error); }
+      return getOpsInternal(docName, data.v, null, function (error, ops) {
+        if (error) {
+          return callback(error)
+        }
 
         if (ops.length > 0) {
-          console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`);
+          console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`)
 
           try {
             for (const op of Array.from(ops)) {
-              data.snapshot = type.apply(data.snapshot, op.op);
-              data.v++;
+              data.snapshot = type.apply(data.snapshot, op.op)
+              data.v++
             }
           } catch (e) {
             // This should never happen - it indicates that whats in the
             // database is invalid.
-            console.error(`Op data invalid for ${docName}: ${e.stack}`);
-            return callback('Op data invalid');
+            console.error(`Op data invalid for ${docName}: ${e.stack}`)
+            return callback('Op data invalid')
           }
         }
 
-        model.emit('load', docName, data);
-        return add(docName, error, data, committedVersion, ops, dbMeta);
-      });
-    });
-  };
+        model.emit('load', docName, data)
+        return add(docName, error, data, committedVersion, ops, dbMeta)
+      })
+    })
+  }
 
   // This makes sure the cache contains a document. If the doc cache doesn't contain
   // a document, it is loaded from the database and stored.
@@ -352,52 +426,75 @@ module.exports = (Model = function(db, options) {
   // Documents are stored so long as either:
   // - They have been accessed within the past #{PERIOD}
   // - At least one client has the document open
-  var refreshReapingTimeout = function(docName) {
-    const doc = docs[docName];
-    if (!doc) { return; }
+  var refreshReapingTimeout = function (docName) {
+    const doc = docs[docName]
+    if (!doc) {
+      return
+    }
 
     // I want to let the clients list be updated before this is called.
-    return process.nextTick(function() {
+    return process.nextTick(function () {
       // This is an awkward way to find out the number of clients on a document. If this
       // causes performance issues, add a numClients field to the document.
       //
       // The first check is because its possible that between refreshReapingTimeout being called and this
       // event being fired, someone called delete() on the document and hence the doc is something else now.
-      if ((doc === docs[docName]) &&
-          (doc.eventEmitter.listeners('op').length === 0) &&
-          (db || options.forceReaping) &&
-          (doc.opQueue.busy === false)) {
-
-        let reapTimer;
-        clearTimeout(doc.reapTimer);
-        return doc.reapTimer = (reapTimer = setTimeout(() => tryWriteSnapshot(docName, function() {
-          // If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
-          // in the middle of applying an operation, don't reap.
-          if ((docs[docName].reapTimer === reapTimer) && (doc.opQueue.busy === false)) { return delete docs[docName]; }
-        })
-          , options.reapTime));
+      if (
+        doc === docs[docName] &&
+        doc.eventEmitter.listeners('op').length === 0 &&
+        (db || options.forceReaping) &&
+        doc.opQueue.busy === false
+      ) {
+        let reapTimer
+        clearTimeout(doc.reapTimer)
+        return (doc.reapTimer = reapTimer = setTimeout(
+          () =>
+            tryWriteSnapshot(docName, function () {
+              // If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
+              // in the middle of applying an operation, don't reap.
+              if (
+                docs[docName].reapTimer === reapTimer &&
+                doc.opQueue.busy === false
+              ) {
+                return delete docs[docName]
+              }
+            }),
+          options.reapTime
+        ))
       }
-    });
-  };
+    })
+  }
 
-  var tryWriteSnapshot = function(docName, callback) {
-    if (!db) { return (typeof callback === 'function' ? callback() : undefined); }
+  var tryWriteSnapshot = function (docName, callback) {
+    if (!db) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    const doc = docs[docName];
+    const doc = docs[docName]
 
     // The doc is closed
-    if (!doc) { return (typeof callback === 'function' ? callback() : undefined); }
+    if (!doc) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
     // The document is already saved.
-    if (doc.committedVersion === doc.v) { return (typeof callback === 'function' ? callback() : undefined); }
+    if (doc.committedVersion === doc.v) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    if (doc.snapshotWriteLock) { return (typeof callback === 'function' ? callback('Another snapshot write is in progress') : undefined); }
+    if (doc.snapshotWriteLock) {
+      return typeof callback === 'function'
+        ? callback('Another snapshot write is in progress')
+        : undefined
+    }
 
-    doc.snapshotWriteLock = true;
+    doc.snapshotWriteLock = true
 
-    __guardMethod__(options.stats, 'writeSnapshot', o => o.writeSnapshot());
+    __guardMethod__(options.stats, 'writeSnapshot', (o) => o.writeSnapshot())
 
-    const writeSnapshot = (db != null ? db.writeSnapshot : undefined) || ((docName, docData, dbMeta, callback) => callback());
+    const writeSnapshot =
+      (db != null ? db.writeSnapshot : undefined) ||
+      ((docName, docData, dbMeta, callback) => callback())
 
     const data = {
       v: doc.v,
@@ -405,87 +502,107 @@ module.exports = (Model = function(db, options) {
       snapshot: doc.snapshot,
       // The database doesn't know about object types.
       type: doc.type.name
-    };
+    }
 
     // Commit snapshot.
-    return writeSnapshot(docName, data, doc.dbMeta, function(error, dbMeta) {
-      doc.snapshotWriteLock = false;
+    return writeSnapshot(docName, data, doc.dbMeta, function (error, dbMeta) {
+      doc.snapshotWriteLock = false
 
       // We have to use data.v here because the version in the doc could
       // have been updated between the call to writeSnapshot() and now.
-      doc.committedVersion = data.v;
-      doc.dbMeta = dbMeta;
+      doc.committedVersion = data.v
+      doc.dbMeta = dbMeta
 
-      return (typeof callback === 'function' ? callback(error) : undefined);
-    });
-  };
+      return typeof callback === 'function' ? callback(error) : undefined
+    })
+  }
 
   // *** Model interface methods
 
   // Create a new document.
   //
   // data should be {snapshot, type, [meta]}. The version of a new document is 0.
-  this.create = function(docName, type, meta, callback) {
-    if (typeof meta === 'function') { [meta, callback] = Array.from([{}, meta]); }
+  this.create = function (docName, type, meta, callback) {
+    if (typeof meta === 'function') {
+      ;[meta, callback] = Array.from([{}, meta])
+    }
 
-    if (docName.match(/\//)) { return (typeof callback === 'function' ? callback('Invalid document name') : undefined); }
-    if (docs[docName]) { return (typeof callback === 'function' ? callback('Document already exists') : undefined); }
+    if (docName.match(/\//)) {
+      return typeof callback === 'function'
+        ? callback('Invalid document name')
+        : undefined
+    }
+    if (docs[docName]) {
+      return typeof callback === 'function'
+        ? callback('Document already exists')
+        : undefined
+    }
 
-    if (typeof type === 'string') { type = types[type]; }
-    if (!type) { return (typeof callback === 'function' ? callback('Type not found') : undefined); }
+    if (typeof type === 'string') {
+      type = types[type]
+    }
+    if (!type) {
+      return typeof callback === 'function'
+        ? callback('Type not found')
+        : undefined
+    }
 
     const data = {
-      snapshot:type.create(),
-      type:type.name,
-      meta:meta || {},
-      v:0
-    };
+      snapshot: type.create(),
+      type: type.name,
+      meta: meta || {},
+      v: 0
+    }
 
-    const done = function(error, dbMeta) {
+    const done = function (error, dbMeta) {
       // dbMeta can be used to cache extra state needed by the database to access the document, like an ID or something.
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
       // From here on we'll store the object version of the type name.
-      data.type = type;
-      add(docName, null, data, 0, [], dbMeta);
-      model.emit('create', docName, data);
-      return (typeof callback === 'function' ? callback() : undefined);
-    };
+      data.type = type
+      add(docName, null, data, 0, [], dbMeta)
+      model.emit('create', docName, data)
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
     if (db) {
-      return db.create(docName, data, done);
+      return db.create(docName, data, done)
     } else {
-      return done();
+      return done()
     }
-  };
+  }
 
   // Perminantly deletes the specified document.
   // If listeners are attached, they are removed.
-  // 
+  //
   // The callback is called with (error) if there was an error. If error is null / undefined, the
   // document was deleted.
   //
   // WARNING: This isn't well supported throughout the code. (Eg, streaming clients aren't told about the
   // deletion. Subsequent op submissions will fail).
-  this.delete = function(docName, callback) {
-    const doc = docs[docName];
+  this.delete = function (docName, callback) {
+    const doc = docs[docName]
 
     if (doc) {
-      clearTimeout(doc.reapTimer);
-      delete docs[docName];
+      clearTimeout(doc.reapTimer)
+      delete docs[docName]
     }
 
-    const done = function(error) {
-      if (!error) { model.emit('delete', docName); }
-      return (typeof callback === 'function' ? callback(error) : undefined);
-    };
+    const done = function (error) {
+      if (!error) {
+        model.emit('delete', docName)
+      }
+      return typeof callback === 'function' ? callback(error) : undefined
+    }
 
     if (db) {
-      return db.delete(docName, doc != null ? doc.dbMeta : undefined, done);
+      return db.delete(docName, doc != null ? doc.dbMeta : undefined, done)
     } else {
-      return done((!doc ? 'Document does not exist' : undefined));
+      return done(!doc ? 'Document does not exist' : undefined)
     }
-  };
+  }
 
   // This gets all operations from [start...end]. (That is, its not inclusive.)
   //
@@ -502,102 +619,139 @@ module.exports = (Model = function(db, options) {
   //
   // Use getVersion() to determine if a document actually exists, if thats what you're
   // after.
-  this.getOps = (getOps = function(docName, start, end, callback) {
+  this.getOps = getOps = function (docName, start, end, callback) {
     // getOps will only use the op cache if its there. It won't fill the op cache in.
-    if (!(start >= 0)) { throw new Error('start must be 0+'); }
+    if (!(start >= 0)) {
+      throw new Error('start must be 0+')
+    }
 
-    if (typeof end === 'function') { [end, callback] = Array.from([null, end]); }
+    if (typeof end === 'function') {
+      ;[end, callback] = Array.from([null, end])
+    }
 
-    const ops = docs[docName] != null ? docs[docName].ops : undefined;
+    const ops = docs[docName] != null ? docs[docName].ops : undefined
 
     if (ops) {
-      const version = docs[docName].v;
+      const version = docs[docName].v
 
       // Ops contains an array of ops. The last op in the list is the last op applied
-      if (end == null) { end = version; }
-      start = Math.min(start, end);
+      if (end == null) {
+        end = version
+      }
+      start = Math.min(start, end)
 
-      if (start === end) { return callback(null, []); }
+      if (start === end) {
+        return callback(null, [])
+      }
 
       // Base is the version number of the oldest op we have cached
-      const base = version - ops.length;
+      const base = version - ops.length
 
       // If the database is null, we'll trim to the ops we do have and hope thats enough.
-      if ((start >= base) || (db === null)) {
-        refreshReapingTimeout(docName);
+      if (start >= base || db === null) {
+        refreshReapingTimeout(docName)
         if (options.stats != null) {
-          options.stats.cacheHit('getOps');
+          options.stats.cacheHit('getOps')
         }
 
-        return callback(null, ops.slice((start - base), (end - base)));
+        return callback(null, ops.slice(start - base, end - base))
       }
     }
 
     if (options.stats != null) {
-      options.stats.cacheMiss('getOps');
+      options.stats.cacheMiss('getOps')
     }
 
-    return getOpsInternal(docName, start, end, callback);
-  });
+    return getOpsInternal(docName, start, end, callback)
+  }
 
   // Gets the snapshot data for the specified document.
   // getSnapshot(docName, callback)
   // Callback is called with (error, {v: <version>, type: <type>, snapshot: <snapshot>, meta: <meta>})
-  this.getSnapshot = (docName, callback) => load(docName, (error, doc) => callback(error, doc ? {v:doc.v, type:doc.type, snapshot:doc.snapshot, meta:doc.meta} : undefined));
+  this.getSnapshot = (docName, callback) =>
+    load(docName, (error, doc) =>
+      callback(
+        error,
+        doc
+          ? { v: doc.v, type: doc.type, snapshot: doc.snapshot, meta: doc.meta }
+          : undefined
+      )
+    )
 
   // Gets the latest version # of the document.
   // getVersion(docName, callback)
   // callback is called with (error, version).
-  this.getVersion = (docName, callback) => load(docName, (error, doc) => callback(error, doc != null ? doc.v : undefined));
+  this.getVersion = (docName, callback) =>
+    load(docName, (error, doc) =>
+      callback(error, doc != null ? doc.v : undefined)
+    )
 
   // Apply an op to the specified document.
   // The callback is passed (error, applied version #)
   // opData = {op:op, v:v, meta:metadata}
-  // 
+  //
   // Ops are queued before being applied so that the following code applies op C before op B:
   // model.applyOp 'doc', OPA, -> model.applyOp 'doc', OPB
   // model.applyOp 'doc', OPC
-  this.applyOp = (docName, opData, callback) => // All the logic for this is in makeOpQueue, above.
-  load(docName, function(error, doc) {
-    if (error) { return callback(error); }
+  this.applyOp = (
+    docName,
+    opData,
+    callback // All the logic for this is in makeOpQueue, above.
+  ) =>
+    load(docName, function (error, doc) {
+      if (error) {
+        return callback(error)
+      }
 
-    return process.nextTick(() => doc.opQueue(opData, function(error, newVersion) {
-      refreshReapingTimeout(docName);
-      return (typeof callback === 'function' ? callback(error, newVersion) : undefined);
-    }));
-  });
+      return process.nextTick(() =>
+        doc.opQueue(opData, function (error, newVersion) {
+          refreshReapingTimeout(docName)
+          return typeof callback === 'function'
+            ? callback(error, newVersion)
+            : undefined
+        })
+      )
+    })
 
   // TODO: store (some) metadata in DB
   // TODO: op and meta should be combineable in the op that gets sent
-  this.applyMetaOp = function(docName, metaOpData, callback) {
-    const {path, value} = metaOpData.meta;
-   
-    if (!isArray(path)) { return (typeof callback === 'function' ? callback("path should be an array") : undefined); }
+  this.applyMetaOp = function (docName, metaOpData, callback) {
+    const { path, value } = metaOpData.meta
+
+    if (!isArray(path)) {
+      return typeof callback === 'function'
+        ? callback('path should be an array')
+        : undefined
+    }
 
-    return load(docName, function(error, doc) {
+    return load(docName, function (error, doc) {
       if (error != null) {
-        return (typeof callback === 'function' ? callback(error) : undefined);
+        return typeof callback === 'function' ? callback(error) : undefined
       } else {
-        let applied = false;
+        let applied = false
         switch (path[0]) {
           case 'shout':
-            doc.eventEmitter.emit('op', metaOpData);
-            applied = true;
-            break;
+            doc.eventEmitter.emit('op', metaOpData)
+            applied = true
+            break
         }
 
-        if (applied) { model.emit('applyMetaOp', docName, path, value); }
-        return (typeof callback === 'function' ? callback(null, doc.v) : undefined);
+        if (applied) {
+          model.emit('applyMetaOp', docName, path, value)
+        }
+        return typeof callback === 'function'
+          ? callback(null, doc.v)
+          : undefined
       }
-    });
-  };
+    })
+  }
 
   // Listen to all ops from the specified version. If version is in the past, all
   // ops since that version are sent immediately to the listener.
   //
   // The callback is called once the listener is attached, but before any ops have been passed
   // to the listener.
-  // 
+  //
   // This will _not_ edit the document metadata.
   //
   // If there are any listeners, we don't purge the document from the cache. But be aware, this behaviour
@@ -609,98 +763,123 @@ module.exports = (Model = function(db, options) {
   // listener is called with (opData) each time an op is applied.
   //
   // callback(error, openedVersion)
-  this.listen = function(docName, version, listener, callback) {
-    if (typeof version === 'function') { [version, listener, callback] = Array.from([null, version, listener]); }
+  this.listen = function (docName, version, listener, callback) {
+    if (typeof version === 'function') {
+      ;[version, listener, callback] = Array.from([null, version, listener])
+    }
 
-    return load(docName, function(error, doc) {
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+    return load(docName, function (error, doc) {
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
-      clearTimeout(doc.reapTimer);
+      clearTimeout(doc.reapTimer)
 
       if (version != null) {
-        return getOps(docName, version, null, function(error, data) {
-          if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+        return getOps(docName, version, null, function (error, data) {
+          if (error) {
+            return typeof callback === 'function' ? callback(error) : undefined
+          }
 
-          doc.eventEmitter.on('op', listener);
+          doc.eventEmitter.on('op', listener)
           if (typeof callback === 'function') {
-            callback(null, version);
+            callback(null, version)
           }
           return (() => {
-            const result = [];
+            const result = []
             for (const op of Array.from(data)) {
-              var needle;
-              listener(op);
+              var needle
+              listener(op)
 
               // The listener may well remove itself during the catchup phase. If this happens, break early.
               // This is done in a quite inefficient way. (O(n) where n = #listeners on doc)
-              if ((needle = listener, !Array.from(doc.eventEmitter.listeners('op')).includes(needle))) { break; } else {
-                result.push(undefined);
+              if (
+                ((needle = listener),
+                !Array.from(doc.eventEmitter.listeners('op')).includes(needle))
+              ) {
+                break
+              } else {
+                result.push(undefined)
               }
             }
-            return result;
-          })();
-        });
-
-      } else { // Version is null / undefined. Just add the listener.
-        doc.eventEmitter.on('op', listener);
-        return (typeof callback === 'function' ? callback(null, doc.v) : undefined);
+            return result
+          })()
+        })
+      } else {
+        // Version is null / undefined. Just add the listener.
+        doc.eventEmitter.on('op', listener)
+        return typeof callback === 'function'
+          ? callback(null, doc.v)
+          : undefined
       }
-    });
-  };
+    })
+  }
 
   // Remove a listener for a particular document.
   //
   // removeListener(docName, listener)
   //
   // This is synchronous.
-  this.removeListener = function(docName, listener) {
+  this.removeListener = function (docName, listener) {
     // The document should already be loaded.
-    const doc = docs[docName];
-    if (!doc) { throw new Error('removeListener called but document not loaded'); }
+    const doc = docs[docName]
+    if (!doc) {
+      throw new Error('removeListener called but document not loaded')
+    }
 
-    doc.eventEmitter.removeListener('op', listener);
-    return refreshReapingTimeout(docName);
-  };
+    doc.eventEmitter.removeListener('op', listener)
+    return refreshReapingTimeout(docName)
+  }
 
   // Flush saves all snapshot data to the database. I'm not sure whether or not this is actually needed -
   // sharejs will happily replay uncommitted ops when documents are re-opened anyway.
-  this.flush = function(callback) {
-    if (!db) { return (typeof callback === 'function' ? callback() : undefined); }
+  this.flush = function (callback) {
+    if (!db) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    let pendingWrites = 0;
+    let pendingWrites = 0
 
     for (const docName in docs) {
-      const doc = docs[docName];
+      const doc = docs[docName]
       if (doc.committedVersion < doc.v) {
-        pendingWrites++;
+        pendingWrites++
         // I'm hoping writeSnapshot will always happen in another thread.
-        tryWriteSnapshot(docName, () => process.nextTick(function() {
-          pendingWrites--;
-          if (pendingWrites === 0) { return (typeof callback === 'function' ? callback() : undefined); }
-        }));
+        tryWriteSnapshot(docName, () =>
+          process.nextTick(function () {
+            pendingWrites--
+            if (pendingWrites === 0) {
+              return typeof callback === 'function' ? callback() : undefined
+            }
+          })
+        )
       }
     }
 
     // If nothing was queued, terminate immediately.
-    if (pendingWrites === 0) { return (typeof callback === 'function' ? callback() : undefined); }
-  };
+    if (pendingWrites === 0) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
+  }
 
   // Close the database connection. This is needed so nodejs can shut down cleanly.
-  this.closeDb = function() {
-    __guardMethod__(db, 'close', o => o.close());
-    return db = null;
-  };
-
-});
+  this.closeDb = function () {
+    __guardMethod__(db, 'close', (o) => o.close())
+    return (db = null)
+  }
+}
 
 // Model inherits from EventEmitter.
-Model.prototype = new EventEmitter;
-
+Model.prototype = new EventEmitter()
 
 function __guardMethod__(obj, methodName, transform) {
-  if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
-    return transform(obj, methodName);
+  if (
+    typeof obj !== 'undefined' &&
+    obj !== null &&
+    typeof obj[methodName] === 'function'
+  ) {
+    return transform(obj, methodName)
   } else {
-    return undefined;
+    return undefined
   }
-}
+}

+ 510 - 328
services/document-updater/app/js/sharejs/server/model.js

@@ -21,13 +21,13 @@
 //
 // Actual storage is handled by the database wrappers in db/*, wrapped by DocCache
 
-let Model;
-const {EventEmitter} = require('events');
+let Model
+const { EventEmitter } = require('events')
 
-const queue = require('./syncqueue');
-const types = require('../types');
+const queue = require('./syncqueue')
+const types = require('../types')
 
-const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
+const isArray = (o) => Object.prototype.toString.call(o) === '[object Array]'
 
 // This constructor creates a new Model object. There will be one model object
 // per server context.
@@ -41,15 +41,19 @@ const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
 // The model is an event emitter. It emits the following events:
 //
 // create(docName, data): A document has been created with the specified name & data
-module.exports = (Model = function(db, options) {
+module.exports = Model = function (db, options) {
   // db can be null if the user doesn't want persistance.
 
-  let getOps;
-  if (!(this instanceof Model)) { return new Model(db, options); }
+  let getOps
+  if (!(this instanceof Model)) {
+    return new Model(db, options)
+  }
 
-  const model = this;
+  const model = this
 
-  if (options == null) { options = {}; }
+  if (options == null) {
+    options = {}
+  }
 
   // This is a cache of 'live' documents.
   //
@@ -78,168 +82,217 @@ module.exports = (Model = function(db, options) {
   //
   // In any case, the API to model is designed such that if we want to change that later
   // it should be pretty easy to do so without any external-to-the-model code changes.
-  const docs = {};
+  const docs = {}
 
   // This is a map from docName -> [callback]. It is used when a document hasn't been
   // cached and multiple getSnapshot() / getVersion() requests come in. All requests
   // are added to the callback list and called when db.getSnapshot() returns.
   //
   // callback(error, snapshot data)
-  const awaitingGetSnapshot = {};
+  const awaitingGetSnapshot = {}
 
   // The time that documents which no clients have open will stay in the cache.
   // Should be > 0.
-  if (options.reapTime == null) { options.reapTime = 3000; }
+  if (options.reapTime == null) {
+    options.reapTime = 3000
+  }
 
   // The number of operations the cache holds before reusing the space
-  if (options.numCachedOps == null) { options.numCachedOps = 10; }
+  if (options.numCachedOps == null) {
+    options.numCachedOps = 10
+  }
 
   // This option forces documents to be reaped, even when there's no database backend.
   // This is useful when you don't care about persistance and don't want to gradually
   // fill memory.
   //
   // You might want to set reapTime to a day or something.
-  if (options.forceReaping == null) { options.forceReaping = false; }
+  if (options.forceReaping == null) {
+    options.forceReaping = false
+  }
 
   // Until I come up with a better strategy, we'll save a copy of the document snapshot
   // to the database every ~20 submitted ops.
-  if (options.opsBeforeCommit == null) { options.opsBeforeCommit = 20; }
+  if (options.opsBeforeCommit == null) {
+    options.opsBeforeCommit = 20
+  }
 
   // It takes some processing time to transform client ops. The server will punt ops back to the
   // client to transform if they're too old.
-  if (options.maximumAge == null) { options.maximumAge = 40; }
+  if (options.maximumAge == null) {
+    options.maximumAge = 40
+  }
 
   // **** Cache API methods
 
   // Its important that all ops are applied in order. This helper method creates the op submission queue
   // for a single document. This contains the logic for transforming & applying ops.
-  const makeOpQueue = (docName, doc) => queue(function(opData, callback) {
-    if (!(opData.v >= 0)) { return callback('Version missing'); }
-    if (opData.v > doc.v) { return callback('Op at future version'); }
-
-    // Punt the transforming work back to the client if the op is too old.
-    if ((opData.v + options.maximumAge) < doc.v) { return callback('Op too old'); }
-
-    if (!opData.meta) { opData.meta = {}; }
-    opData.meta.ts = Date.now();
-
-    // We'll need to transform the op to the current version of the document. This
-    // calls the callback immediately if opVersion == doc.v.
-    return getOps(docName, opData.v, doc.v, function(error, ops) {
-      let snapshot;
-      if (error) { return callback(error); }
-
-      if ((doc.v - opData.v) !== ops.length) {
-        // This should never happen. It indicates that we didn't get all the ops we
-        // asked for. Its important that the submitted op is correctly transformed.
-        console.error(`Could not get old ops in model for document ${docName}`);
-        console.error(`Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`);
-        return callback('Internal error');
+  const makeOpQueue = (docName, doc) =>
+    queue(function (opData, callback) {
+      if (!(opData.v >= 0)) {
+        return callback('Version missing')
       }
-
-      if (ops.length > 0) {
-        try {
-          // If there's enough ops, it might be worth spinning this out into a webworker thread.
-          for (const oldOp of Array.from(ops)) {
-            // Dup detection works by sending the id(s) the op has been submitted with previously.
-            // If the id matches, we reject it. The client can also detect the op has been submitted
-            // already if it sees its own previous id in the ops it sees when it does catchup.
-            if (oldOp.meta.source && opData.dupIfSource && Array.from(opData.dupIfSource).includes(oldOp.meta.source)) {
-              return callback('Op already submitted');
-            }
-
-            opData.op = doc.type.transform(opData.op, oldOp.op, 'left');
-            opData.v++;
-          }
-        } catch (error1) {
-          error = error1;
-          console.error(error.stack);
-          return callback(error.message);
-        }
+      if (opData.v > doc.v) {
+        return callback('Op at future version')
       }
 
-      try {
-        snapshot = doc.type.apply(doc.snapshot, opData.op);
-      } catch (error2) {
-        error = error2;
-        console.error(error.stack);
-        return callback(error.message);
-      }
-        
-      if ((options.maxDocLength != null) && (doc.snapshot.length > options.maxDocLength)) {
-        return callback("Update takes doc over max doc size");
+      // Punt the transforming work back to the client if the op is too old.
+      if (opData.v + options.maximumAge < doc.v) {
+        return callback('Op too old')
       }
 
-      // The op data should be at the current version, and the new document data should be at
-      // the next version.
-      //
-      // This should never happen in practice, but its a nice little check to make sure everything
-      // is hunky-dory.
-      if (opData.v !== doc.v) {
-        // This should never happen.
-        console.error("Version mismatch detected in model. File a ticket - this is a bug.");
-        console.error(`Expecting ${opData.v} == ${doc.v}`);
-        return callback('Internal error');
+      if (!opData.meta) {
+        opData.meta = {}
       }
+      opData.meta.ts = Date.now()
 
-      // newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
-      const writeOp = (db != null ? db.writeOp : undefined) || ((docName, newOpData, callback) => callback());
-
-      return writeOp(docName, opData, function(error) {
+      // We'll need to transform the op to the current version of the document. This
+      // calls the callback immediately if opVersion == doc.v.
+      return getOps(docName, opData.v, doc.v, function (error, ops) {
+        let snapshot
         if (error) {
-          // The user should probably know about this.
-          console.warn(`Error writing ops to database: ${error}`);
-          return callback(error);
+          return callback(error)
         }
 
-        __guardMethod__(options.stats, 'writeOp', o => o.writeOp());
-
-        // This is needed when we emit the 'change' event, below.
-        const oldSnapshot = doc.snapshot;
+        if (doc.v - opData.v !== ops.length) {
+          // This should never happen. It indicates that we didn't get all the ops we
+          // asked for. Its important that the submitted op is correctly transformed.
+          console.error(
+            `Could not get old ops in model for document ${docName}`
+          )
+          console.error(
+            `Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`
+          )
+          return callback('Internal error')
+        }
 
-        // All the heavy lifting is now done. Finally, we'll update the cache with the new data
-        // and (maybe!) save a new document snapshot to the database.
+        if (ops.length > 0) {
+          try {
+            // If there's enough ops, it might be worth spinning this out into a webworker thread.
+            for (const oldOp of Array.from(ops)) {
+              // Dup detection works by sending the id(s) the op has been submitted with previously.
+              // If the id matches, we reject it. The client can also detect the op has been submitted
+              // already if it sees its own previous id in the ops it sees when it does catchup.
+              if (
+                oldOp.meta.source &&
+                opData.dupIfSource &&
+                Array.from(opData.dupIfSource).includes(oldOp.meta.source)
+              ) {
+                return callback('Op already submitted')
+              }
 
-        doc.v = opData.v + 1;
-        doc.snapshot = snapshot;
+              opData.op = doc.type.transform(opData.op, oldOp.op, 'left')
+              opData.v++
+            }
+          } catch (error1) {
+            error = error1
+            console.error(error.stack)
+            return callback(error.message)
+          }
+        }
 
-        doc.ops.push(opData);
-        if (db && (doc.ops.length > options.numCachedOps)) { doc.ops.shift(); }
+        try {
+          snapshot = doc.type.apply(doc.snapshot, opData.op)
+        } catch (error2) {
+          error = error2
+          console.error(error.stack)
+          return callback(error.message)
+        }
 
-        model.emit('applyOp', docName, opData, snapshot, oldSnapshot);
-        doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot);
+        if (
+          options.maxDocLength != null &&
+          doc.snapshot.length > options.maxDocLength
+        ) {
+          return callback('Update takes doc over max doc size')
+        }
 
-        // The callback is called with the version of the document at which the op was applied.
-        // This is the op.v after transformation, and its doc.v - 1.
-        callback(null, opData.v);
-    
-        // I need a decent strategy here for deciding whether or not to save the snapshot.
+        // The op data should be at the current version, and the new document data should be at
+        // the next version.
         //
-        // The 'right' strategy looks something like "Store the snapshot whenever the snapshot
-        // is smaller than the accumulated op data". For now, I'll just store it every 20
-        // ops or something. (Configurable with doc.committedVersion)
-        if (!doc.snapshotWriteLock && ((doc.committedVersion + options.opsBeforeCommit) <= doc.v)) {
-          return tryWriteSnapshot(docName, function(error) {
-            if (error) { return console.warn(`Error writing snapshot ${error}. This is nonfatal`); }
-          });
+        // This should never happen in practice, but its a nice little check to make sure everything
+        // is hunky-dory.
+        if (opData.v !== doc.v) {
+          // This should never happen.
+          console.error(
+            'Version mismatch detected in model. File a ticket - this is a bug.'
+          )
+          console.error(`Expecting ${opData.v} == ${doc.v}`)
+          return callback('Internal error')
         }
-      });
-    });
-  });
+
+        // newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
+        const writeOp =
+          (db != null ? db.writeOp : undefined) ||
+          ((docName, newOpData, callback) => callback())
+
+        return writeOp(docName, opData, function (error) {
+          if (error) {
+            // The user should probably know about this.
+            console.warn(`Error writing ops to database: ${error}`)
+            return callback(error)
+          }
+
+          __guardMethod__(options.stats, 'writeOp', (o) => o.writeOp())
+
+          // This is needed when we emit the 'change' event, below.
+          const oldSnapshot = doc.snapshot
+
+          // All the heavy lifting is now done. Finally, we'll update the cache with the new data
+          // and (maybe!) save a new document snapshot to the database.
+
+          doc.v = opData.v + 1
+          doc.snapshot = snapshot
+
+          doc.ops.push(opData)
+          if (db && doc.ops.length > options.numCachedOps) {
+            doc.ops.shift()
+          }
+
+          model.emit('applyOp', docName, opData, snapshot, oldSnapshot)
+          doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot)
+
+          // The callback is called with the version of the document at which the op was applied.
+          // This is the op.v after transformation, and its doc.v - 1.
+          callback(null, opData.v)
+
+          // I need a decent strategy here for deciding whether or not to save the snapshot.
+          //
+          // The 'right' strategy looks something like "Store the snapshot whenever the snapshot
+          // is smaller than the accumulated op data". For now, I'll just store it every 20
+          // ops or something. (Configurable with doc.committedVersion)
+          if (
+            !doc.snapshotWriteLock &&
+            doc.committedVersion + options.opsBeforeCommit <= doc.v
+          ) {
+            return tryWriteSnapshot(docName, function (error) {
+              if (error) {
+                return console.warn(
+                  `Error writing snapshot ${error}. This is nonfatal`
+                )
+              }
+            })
+          }
+        })
+      })
+    })
 
   // Add the data for the given docName to the cache. The named document shouldn't already
   // exist in the doc set.
   //
   // Returns the new doc.
-  const add = function(docName, error, data, committedVersion, ops, dbMeta) {
-    let callback, doc;
-    const callbacks = awaitingGetSnapshot[docName];
-    delete awaitingGetSnapshot[docName];
+  const add = function (docName, error, data, committedVersion, ops, dbMeta) {
+    let callback, doc
+    const callbacks = awaitingGetSnapshot[docName]
+    delete awaitingGetSnapshot[docName]
 
     if (error) {
-      if (callbacks) { for (callback of Array.from(callbacks)) { callback(error); } }
+      if (callbacks) {
+        for (callback of Array.from(callbacks)) {
+          callback(error)
+        }
+      }
     } else {
-      doc = (docs[docName] = {
+      doc = docs[docName] = {
         snapshot: data.snapshot,
         v: data.v,
         type: data.type,
@@ -248,7 +301,7 @@ module.exports = (Model = function(db, options) {
         // Cache of ops
         ops: ops || [],
 
-        eventEmitter: new EventEmitter,
+        eventEmitter: new EventEmitter(),
 
         // Timer before the document will be invalidated from the cache (if the document has no
         // listeners)
@@ -258,97 +311,121 @@ module.exports = (Model = function(db, options) {
         committedVersion: committedVersion != null ? committedVersion : data.v,
         snapshotWriteLock: false,
         dbMeta
-      });
+      }
+
+      doc.opQueue = makeOpQueue(docName, doc)
 
-      doc.opQueue = makeOpQueue(docName, doc);
-      
-      refreshReapingTimeout(docName);
-      model.emit('add', docName, data);
-      if (callbacks) { for (callback of Array.from(callbacks)) { callback(null, doc); } }
+      refreshReapingTimeout(docName)
+      model.emit('add', docName, data)
+      if (callbacks) {
+        for (callback of Array.from(callbacks)) {
+          callback(null, doc)
+        }
+      }
     }
 
-    return doc;
-  };
-  
+    return doc
+  }
+
   // This is a little helper wrapper around db.getOps. It does two things:
   //
   // - If there's no database set, it returns an error to the callback
   // - It adds version numbers to each op returned from the database
   // (These can be inferred from context so the DB doesn't store them, but its useful to have them).
-  const getOpsInternal = function(docName, start, end, callback) {
-    if (!db) { return (typeof callback === 'function' ? callback('Document does not exist') : undefined); }
+  const getOpsInternal = function (docName, start, end, callback) {
+    if (!db) {
+      return typeof callback === 'function'
+        ? callback('Document does not exist')
+        : undefined
+    }
 
-    return db.getOps(docName, start, end, function(error, ops) {
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+    return db.getOps(docName, start, end, function (error, ops) {
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
-      let v = start;
-      for (const op of Array.from(ops)) { op.v = v++; }
+      let v = start
+      for (const op of Array.from(ops)) {
+        op.v = v++
+      }
 
-      return (typeof callback === 'function' ? callback(null, ops) : undefined);
-    });
-  };
+      return typeof callback === 'function' ? callback(null, ops) : undefined
+    })
+  }
 
   // Load the named document into the cache. This function is re-entrant.
   //
   // The callback is called with (error, doc)
-  const load = function(docName, callback) {
+  const load = function (docName, callback) {
     if (docs[docName]) {
       // The document is already loaded. Return immediately.
-      __guardMethod__(options.stats, 'cacheHit', o => o.cacheHit('getSnapshot'));
-      return callback(null, docs[docName]);
+      __guardMethod__(options.stats, 'cacheHit', (o) =>
+        o.cacheHit('getSnapshot')
+      )
+      return callback(null, docs[docName])
     }
 
     // We're a memory store. If we don't have it, nobody does.
-    if (!db) { return callback('Document does not exist'); }
+    if (!db) {
+      return callback('Document does not exist')
+    }
 
-    const callbacks = awaitingGetSnapshot[docName];
+    const callbacks = awaitingGetSnapshot[docName]
 
     // The document is being loaded already. Add ourselves as a callback.
-    if (callbacks) { return callbacks.push(callback); }
+    if (callbacks) {
+      return callbacks.push(callback)
+    }
 
-    __guardMethod__(options.stats, 'cacheMiss', o1 => o1.cacheMiss('getSnapshot'));
+    __guardMethod__(options.stats, 'cacheMiss', (o1) =>
+      o1.cacheMiss('getSnapshot')
+    )
 
     // The document isn't loaded and isn't being loaded. Load it.
-    awaitingGetSnapshot[docName] = [callback];
-    return db.getSnapshot(docName, function(error, data, dbMeta) {
-      if (error) { return add(docName, error); }
+    awaitingGetSnapshot[docName] = [callback]
+    return db.getSnapshot(docName, function (error, data, dbMeta) {
+      if (error) {
+        return add(docName, error)
+      }
 
-      const type = types[data.type];
+      const type = types[data.type]
       if (!type) {
-        console.warn(`Type '${data.type}' missing`);
-        return callback("Type not found");
+        console.warn(`Type '${data.type}' missing`)
+        return callback('Type not found')
       }
-      data.type = type;
+      data.type = type
 
-      const committedVersion = data.v;
+      const committedVersion = data.v
 
       // The server can close without saving the most recent document snapshot.
       // In this case, there are extra ops which need to be applied before
       // returning the snapshot.
-      return getOpsInternal(docName, data.v, null, function(error, ops) {
-        if (error) { return callback(error); }
+      return getOpsInternal(docName, data.v, null, function (error, ops) {
+        if (error) {
+          return callback(error)
+        }
 
         if (ops.length > 0) {
-          console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`);
+          console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`)
 
           try {
             for (const op of Array.from(ops)) {
-              data.snapshot = type.apply(data.snapshot, op.op);
-              data.v++;
+              data.snapshot = type.apply(data.snapshot, op.op)
+              data.v++
             }
           } catch (e) {
             // This should never happen - it indicates that whats in the
             // database is invalid.
-            console.error(`Op data invalid for ${docName}: ${e.stack}`);
-            return callback('Op data invalid');
+            console.error(`Op data invalid for ${docName}: ${e.stack}`)
+            return callback('Op data invalid')
           }
         }
 
-        model.emit('load', docName, data);
-        return add(docName, error, data, committedVersion, ops, dbMeta);
-      });
-    });
-  };
+        model.emit('load', docName, data)
+        return add(docName, error, data, committedVersion, ops, dbMeta)
+      })
+    })
+  }
 
   // This makes sure the cache contains a document. If the doc cache doesn't contain
   // a document, it is loaded from the database and stored.
@@ -356,52 +433,75 @@ module.exports = (Model = function(db, options) {
   // Documents are stored so long as either:
   // - They have been accessed within the past #{PERIOD}
   // - At least one client has the document open
-  var refreshReapingTimeout = function(docName) {
-    const doc = docs[docName];
-    if (!doc) { return; }
+  var refreshReapingTimeout = function (docName) {
+    const doc = docs[docName]
+    if (!doc) {
+      return
+    }
 
     // I want to let the clients list be updated before this is called.
-    return process.nextTick(function() {
+    return process.nextTick(function () {
       // This is an awkward way to find out the number of clients on a document. If this
       // causes performance issues, add a numClients field to the document.
       //
       // The first check is because its possible that between refreshReapingTimeout being called and this
       // event being fired, someone called delete() on the document and hence the doc is something else now.
-      if ((doc === docs[docName]) &&
-          (doc.eventEmitter.listeners('op').length === 0) &&
-          (db || options.forceReaping) &&
-          (doc.opQueue.busy === false)) {
-
-        let reapTimer;
-        clearTimeout(doc.reapTimer);
-        return doc.reapTimer = (reapTimer = setTimeout(() => tryWriteSnapshot(docName, function() {
-          // If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
-          // in the middle of applying an operation, don't reap.
-          if ((docs[docName].reapTimer === reapTimer) && (doc.opQueue.busy === false)) { return delete docs[docName]; }
-        })
-          , options.reapTime));
+      if (
+        doc === docs[docName] &&
+        doc.eventEmitter.listeners('op').length === 0 &&
+        (db || options.forceReaping) &&
+        doc.opQueue.busy === false
+      ) {
+        let reapTimer
+        clearTimeout(doc.reapTimer)
+        return (doc.reapTimer = reapTimer = setTimeout(
+          () =>
+            tryWriteSnapshot(docName, function () {
+              // If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
+              // in the middle of applying an operation, don't reap.
+              if (
+                docs[docName].reapTimer === reapTimer &&
+                doc.opQueue.busy === false
+              ) {
+                return delete docs[docName]
+              }
+            }),
+          options.reapTime
+        ))
       }
-    });
-  };
+    })
+  }
 
-  var tryWriteSnapshot = function(docName, callback) {
-    if (!db) { return (typeof callback === 'function' ? callback() : undefined); }
+  var tryWriteSnapshot = function (docName, callback) {
+    if (!db) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    const doc = docs[docName];
+    const doc = docs[docName]
 
     // The doc is closed
-    if (!doc) { return (typeof callback === 'function' ? callback() : undefined); }
+    if (!doc) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
     // The document is already saved.
-    if (doc.committedVersion === doc.v) { return (typeof callback === 'function' ? callback() : undefined); }
+    if (doc.committedVersion === doc.v) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    if (doc.snapshotWriteLock) { return (typeof callback === 'function' ? callback('Another snapshot write is in progress') : undefined); }
+    if (doc.snapshotWriteLock) {
+      return typeof callback === 'function'
+        ? callback('Another snapshot write is in progress')
+        : undefined
+    }
 
-    doc.snapshotWriteLock = true;
+    doc.snapshotWriteLock = true
 
-    __guardMethod__(options.stats, 'writeSnapshot', o => o.writeSnapshot());
+    __guardMethod__(options.stats, 'writeSnapshot', (o) => o.writeSnapshot())
 
-    const writeSnapshot = (db != null ? db.writeSnapshot : undefined) || ((docName, docData, dbMeta, callback) => callback());
+    const writeSnapshot =
+      (db != null ? db.writeSnapshot : undefined) ||
+      ((docName, docData, dbMeta, callback) => callback())
 
     const data = {
       v: doc.v,
@@ -409,87 +509,107 @@ module.exports = (Model = function(db, options) {
       snapshot: doc.snapshot,
       // The database doesn't know about object types.
       type: doc.type.name
-    };
+    }
 
     // Commit snapshot.
-    return writeSnapshot(docName, data, doc.dbMeta, function(error, dbMeta) {
-      doc.snapshotWriteLock = false;
+    return writeSnapshot(docName, data, doc.dbMeta, function (error, dbMeta) {
+      doc.snapshotWriteLock = false
 
       // We have to use data.v here because the version in the doc could
       // have been updated between the call to writeSnapshot() and now.
-      doc.committedVersion = data.v;
-      doc.dbMeta = dbMeta;
+      doc.committedVersion = data.v
+      doc.dbMeta = dbMeta
 
-      return (typeof callback === 'function' ? callback(error) : undefined);
-    });
-  };
+      return typeof callback === 'function' ? callback(error) : undefined
+    })
+  }
 
   // *** Model interface methods
 
   // Create a new document.
   //
   // data should be {snapshot, type, [meta]}. The version of a new document is 0.
-  this.create = function(docName, type, meta, callback) {
-    if (typeof meta === 'function') { [meta, callback] = Array.from([{}, meta]); }
+  this.create = function (docName, type, meta, callback) {
+    if (typeof meta === 'function') {
+      ;[meta, callback] = Array.from([{}, meta])
+    }
 
-    if (docName.match(/\//)) { return (typeof callback === 'function' ? callback('Invalid document name') : undefined); }
-    if (docs[docName]) { return (typeof callback === 'function' ? callback('Document already exists') : undefined); }
+    if (docName.match(/\//)) {
+      return typeof callback === 'function'
+        ? callback('Invalid document name')
+        : undefined
+    }
+    if (docs[docName]) {
+      return typeof callback === 'function'
+        ? callback('Document already exists')
+        : undefined
+    }
 
-    if (typeof type === 'string') { type = types[type]; }
-    if (!type) { return (typeof callback === 'function' ? callback('Type not found') : undefined); }
+    if (typeof type === 'string') {
+      type = types[type]
+    }
+    if (!type) {
+      return typeof callback === 'function'
+        ? callback('Type not found')
+        : undefined
+    }
 
     const data = {
-      snapshot:type.create(),
-      type:type.name,
-      meta:meta || {},
-      v:0
-    };
+      snapshot: type.create(),
+      type: type.name,
+      meta: meta || {},
+      v: 0
+    }
 
-    const done = function(error, dbMeta) {
+    const done = function (error, dbMeta) {
       // dbMeta can be used to cache extra state needed by the database to access the document, like an ID or something.
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
       // From here on we'll store the object version of the type name.
-      data.type = type;
-      add(docName, null, data, 0, [], dbMeta);
-      model.emit('create', docName, data);
-      return (typeof callback === 'function' ? callback() : undefined);
-    };
+      data.type = type
+      add(docName, null, data, 0, [], dbMeta)
+      model.emit('create', docName, data)
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
     if (db) {
-      return db.create(docName, data, done);
+      return db.create(docName, data, done)
     } else {
-      return done();
+      return done()
     }
-  };
+  }
 
   // Perminantly deletes the specified document.
   // If listeners are attached, they are removed.
-  // 
+  //
   // The callback is called with (error) if there was an error. If error is null / undefined, the
   // document was deleted.
   //
   // WARNING: This isn't well supported throughout the code. (Eg, streaming clients aren't told about the
   // deletion. Subsequent op submissions will fail).
-  this.delete = function(docName, callback) {
-    const doc = docs[docName];
+  this.delete = function (docName, callback) {
+    const doc = docs[docName]
 
     if (doc) {
-      clearTimeout(doc.reapTimer);
-      delete docs[docName];
+      clearTimeout(doc.reapTimer)
+      delete docs[docName]
     }
 
-    const done = function(error) {
-      if (!error) { model.emit('delete', docName); }
-      return (typeof callback === 'function' ? callback(error) : undefined);
-    };
+    const done = function (error) {
+      if (!error) {
+        model.emit('delete', docName)
+      }
+      return typeof callback === 'function' ? callback(error) : undefined
+    }
 
     if (db) {
-      return db.delete(docName, doc != null ? doc.dbMeta : undefined, done);
+      return db.delete(docName, doc != null ? doc.dbMeta : undefined, done)
     } else {
-      return done((!doc ? 'Document does not exist' : undefined));
+      return done(!doc ? 'Document does not exist' : undefined)
     }
-  };
+  }
 
   // This gets all operations from [start...end]. (That is, its not inclusive.)
   //
@@ -506,102 +626,139 @@ module.exports = (Model = function(db, options) {
   //
   // Use getVersion() to determine if a document actually exists, if thats what you're
   // after.
-  this.getOps = (getOps = function(docName, start, end, callback) {
+  this.getOps = getOps = function (docName, start, end, callback) {
     // getOps will only use the op cache if its there. It won't fill the op cache in.
-    if (!(start >= 0)) { throw new Error('start must be 0+'); }
+    if (!(start >= 0)) {
+      throw new Error('start must be 0+')
+    }
 
-    if (typeof end === 'function') { [end, callback] = Array.from([null, end]); }
+    if (typeof end === 'function') {
+      ;[end, callback] = Array.from([null, end])
+    }
 
-    const ops = docs[docName] != null ? docs[docName].ops : undefined;
+    const ops = docs[docName] != null ? docs[docName].ops : undefined
 
     if (ops) {
-      const version = docs[docName].v;
+      const version = docs[docName].v
 
       // Ops contains an array of ops. The last op in the list is the last op applied
-      if (end == null) { end = version; }
-      start = Math.min(start, end);
+      if (end == null) {
+        end = version
+      }
+      start = Math.min(start, end)
 
-      if (start === end) { return callback(null, []); }
+      if (start === end) {
+        return callback(null, [])
+      }
 
       // Base is the version number of the oldest op we have cached
-      const base = version - ops.length;
+      const base = version - ops.length
 
       // If the database is null, we'll trim to the ops we do have and hope thats enough.
-      if ((start >= base) || (db === null)) {
-        refreshReapingTimeout(docName);
+      if (start >= base || db === null) {
+        refreshReapingTimeout(docName)
         if (options.stats != null) {
-          options.stats.cacheHit('getOps');
+          options.stats.cacheHit('getOps')
         }
 
-        return callback(null, ops.slice((start - base), (end - base)));
+        return callback(null, ops.slice(start - base, end - base))
       }
     }
 
     if (options.stats != null) {
-      options.stats.cacheMiss('getOps');
+      options.stats.cacheMiss('getOps')
     }
 
-    return getOpsInternal(docName, start, end, callback);
-  });
+    return getOpsInternal(docName, start, end, callback)
+  }
 
   // Gets the snapshot data for the specified document.
   // getSnapshot(docName, callback)
   // Callback is called with (error, {v: <version>, type: <type>, snapshot: <snapshot>, meta: <meta>})
-  this.getSnapshot = (docName, callback) => load(docName, (error, doc) => callback(error, doc ? {v:doc.v, type:doc.type, snapshot:doc.snapshot, meta:doc.meta} : undefined));
+  this.getSnapshot = (docName, callback) =>
+    load(docName, (error, doc) =>
+      callback(
+        error,
+        doc
+          ? { v: doc.v, type: doc.type, snapshot: doc.snapshot, meta: doc.meta }
+          : undefined
+      )
+    )
 
   // Gets the latest version # of the document.
   // getVersion(docName, callback)
   // callback is called with (error, version).
-  this.getVersion = (docName, callback) => load(docName, (error, doc) => callback(error, doc != null ? doc.v : undefined));
+  this.getVersion = (docName, callback) =>
+    load(docName, (error, doc) =>
+      callback(error, doc != null ? doc.v : undefined)
+    )
 
   // Apply an op to the specified document.
   // The callback is passed (error, applied version #)
   // opData = {op:op, v:v, meta:metadata}
-  // 
+  //
   // Ops are queued before being applied so that the following code applies op C before op B:
   // model.applyOp 'doc', OPA, -> model.applyOp 'doc', OPB
   // model.applyOp 'doc', OPC
-  this.applyOp = (docName, opData, callback) => // All the logic for this is in makeOpQueue, above.
-  load(docName, function(error, doc) {
-    if (error) { return callback(error); }
+  this.applyOp = (
+    docName,
+    opData,
+    callback // All the logic for this is in makeOpQueue, above.
+  ) =>
+    load(docName, function (error, doc) {
+      if (error) {
+        return callback(error)
+      }
 
-    return process.nextTick(() => doc.opQueue(opData, function(error, newVersion) {
-      refreshReapingTimeout(docName);
-      return (typeof callback === 'function' ? callback(error, newVersion) : undefined);
-    }));
-  });
+      return process.nextTick(() =>
+        doc.opQueue(opData, function (error, newVersion) {
+          refreshReapingTimeout(docName)
+          return typeof callback === 'function'
+            ? callback(error, newVersion)
+            : undefined
+        })
+      )
+    })
 
   // TODO: store (some) metadata in DB
   // TODO: op and meta should be combineable in the op that gets sent
-  this.applyMetaOp = function(docName, metaOpData, callback) {
-    const {path, value} = metaOpData.meta;
-   
-    if (!isArray(path)) { return (typeof callback === 'function' ? callback("path should be an array") : undefined); }
+  this.applyMetaOp = function (docName, metaOpData, callback) {
+    const { path, value } = metaOpData.meta
+
+    if (!isArray(path)) {
+      return typeof callback === 'function'
+        ? callback('path should be an array')
+        : undefined
+    }
 
-    return load(docName, function(error, doc) {
+    return load(docName, function (error, doc) {
       if (error != null) {
-        return (typeof callback === 'function' ? callback(error) : undefined);
+        return typeof callback === 'function' ? callback(error) : undefined
       } else {
-        let applied = false;
+        let applied = false
         switch (path[0]) {
           case 'shout':
-            doc.eventEmitter.emit('op', metaOpData);
-            applied = true;
-            break;
+            doc.eventEmitter.emit('op', metaOpData)
+            applied = true
+            break
         }
 
-        if (applied) { model.emit('applyMetaOp', docName, path, value); }
-        return (typeof callback === 'function' ? callback(null, doc.v) : undefined);
+        if (applied) {
+          model.emit('applyMetaOp', docName, path, value)
+        }
+        return typeof callback === 'function'
+          ? callback(null, doc.v)
+          : undefined
       }
-    });
-  };
+    })
+  }
 
   // Listen to all ops from the specified version. If version is in the past, all
   // ops since that version are sent immediately to the listener.
   //
   // The callback is called once the listener is attached, but before any ops have been passed
   // to the listener.
-  // 
+  //
   // This will _not_ edit the document metadata.
   //
   // If there are any listeners, we don't purge the document from the cache. But be aware, this behaviour
@@ -613,98 +770,123 @@ module.exports = (Model = function(db, options) {
   // listener is called with (opData) each time an op is applied.
   //
   // callback(error, openedVersion)
-  this.listen = function(docName, version, listener, callback) {
-    if (typeof version === 'function') { [version, listener, callback] = Array.from([null, version, listener]); }
+  this.listen = function (docName, version, listener, callback) {
+    if (typeof version === 'function') {
+      ;[version, listener, callback] = Array.from([null, version, listener])
+    }
 
-    return load(docName, function(error, doc) {
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+    return load(docName, function (error, doc) {
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
-      clearTimeout(doc.reapTimer);
+      clearTimeout(doc.reapTimer)
 
       if (version != null) {
-        return getOps(docName, version, null, function(error, data) {
-          if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+        return getOps(docName, version, null, function (error, data) {
+          if (error) {
+            return typeof callback === 'function' ? callback(error) : undefined
+          }
 
-          doc.eventEmitter.on('op', listener);
+          doc.eventEmitter.on('op', listener)
           if (typeof callback === 'function') {
-            callback(null, version);
+            callback(null, version)
           }
           return (() => {
-            const result = [];
+            const result = []
             for (const op of Array.from(data)) {
-              var needle;
-              listener(op);
+              var needle
+              listener(op)
 
               // The listener may well remove itself during the catchup phase. If this happens, break early.
               // This is done in a quite inefficient way. (O(n) where n = #listeners on doc)
-              if ((needle = listener, !Array.from(doc.eventEmitter.listeners('op')).includes(needle))) { break; } else {
-                result.push(undefined);
+              if (
+                ((needle = listener),
+                !Array.from(doc.eventEmitter.listeners('op')).includes(needle))
+              ) {
+                break
+              } else {
+                result.push(undefined)
               }
             }
-            return result;
-          })();
-        });
-
-      } else { // Version is null / undefined. Just add the listener.
-        doc.eventEmitter.on('op', listener);
-        return (typeof callback === 'function' ? callback(null, doc.v) : undefined);
+            return result
+          })()
+        })
+      } else {
+        // Version is null / undefined. Just add the listener.
+        doc.eventEmitter.on('op', listener)
+        return typeof callback === 'function'
+          ? callback(null, doc.v)
+          : undefined
       }
-    });
-  };
+    })
+  }
 
   // Remove a listener for a particular document.
   //
   // removeListener(docName, listener)
   //
   // This is synchronous.
-  this.removeListener = function(docName, listener) {
+  this.removeListener = function (docName, listener) {
     // The document should already be loaded.
-    const doc = docs[docName];
-    if (!doc) { throw new Error('removeListener called but document not loaded'); }
+    const doc = docs[docName]
+    if (!doc) {
+      throw new Error('removeListener called but document not loaded')
+    }
 
-    doc.eventEmitter.removeListener('op', listener);
-    return refreshReapingTimeout(docName);
-  };
+    doc.eventEmitter.removeListener('op', listener)
+    return refreshReapingTimeout(docName)
+  }
 
   // Flush saves all snapshot data to the database. I'm not sure whether or not this is actually needed -
   // sharejs will happily replay uncommitted ops when documents are re-opened anyway.
-  this.flush = function(callback) {
-    if (!db) { return (typeof callback === 'function' ? callback() : undefined); }
+  this.flush = function (callback) {
+    if (!db) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    let pendingWrites = 0;
+    let pendingWrites = 0
 
     for (const docName in docs) {
-      const doc = docs[docName];
+      const doc = docs[docName]
       if (doc.committedVersion < doc.v) {
-        pendingWrites++;
+        pendingWrites++
         // I'm hoping writeSnapshot will always happen in another thread.
-        tryWriteSnapshot(docName, () => process.nextTick(function() {
-          pendingWrites--;
-          if (pendingWrites === 0) { return (typeof callback === 'function' ? callback() : undefined); }
-        }));
+        tryWriteSnapshot(docName, () =>
+          process.nextTick(function () {
+            pendingWrites--
+            if (pendingWrites === 0) {
+              return typeof callback === 'function' ? callback() : undefined
+            }
+          })
+        )
       }
     }
 
     // If nothing was queued, terminate immediately.
-    if (pendingWrites === 0) { return (typeof callback === 'function' ? callback() : undefined); }
-  };
+    if (pendingWrites === 0) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
+  }
 
   // Close the database connection. This is needed so nodejs can shut down cleanly.
-  this.closeDb = function() {
-    __guardMethod__(db, 'close', o => o.close());
-    return db = null;
-  };
-
-});
+  this.closeDb = function () {
+    __guardMethod__(db, 'close', (o) => o.close())
+    return (db = null)
+  }
+}
 
 // Model inherits from EventEmitter.
-Model.prototype = new EventEmitter;
-
+Model.prototype = new EventEmitter()
 
 function __guardMethod__(obj, methodName, transform) {
-  if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
-    return transform(obj, methodName);
+  if (
+    typeof obj !== 'undefined' &&
+    obj !== null &&
+    typeof obj[methodName] === 'function'
+  ) {
+    return transform(obj, methodName)
   } else {
-    return undefined;
+    return undefined
   }
-}
+}

+ 28 - 22
services/document-updater/app/js/sharejs/server/syncqueue.js

@@ -25,30 +25,36 @@
 //
 //   ^--- async thing will only be running once at any time.
 
-module.exports = function(process) {
-  if (typeof process !== 'function') { throw new Error('process is not a function'); }
-  const queue = [];
-  
-  const enqueue = function(data, callback) {
-    queue.push([data, callback]);
-    return flush();
-  };
+module.exports = function (process) {
+  if (typeof process !== 'function') {
+    throw new Error('process is not a function')
+  }
+  const queue = []
 
-  enqueue.busy = false;
+  const enqueue = function (data, callback) {
+    queue.push([data, callback])
+    return flush()
+  }
 
-  var flush = function() {
-    if (enqueue.busy || (queue.length === 0)) { return; }
+  enqueue.busy = false
 
-    enqueue.busy = true;
-    const [data, callback] = Array.from(queue.shift());
-    return process(data, function(...result) { // TODO: Make this not use varargs - varargs are really slow.
-      enqueue.busy = false;
-      // This is called after busy = false so a user can check if enqueue.busy is set in the callback.
-      if (callback) { callback.apply(null, result); }
-      return flush();
-    });
-  };
+  var flush = function () {
+    if (enqueue.busy || queue.length === 0) {
+      return
+    }
 
-  return enqueue;
-};
+    enqueue.busy = true
+    const [data, callback] = Array.from(queue.shift())
+    return process(data, function (...result) {
+      // TODO: Make this not use varargs - varargs are really slow.
+      enqueue.busy = false
+      // This is called after busy = false so a user can check if enqueue.busy is set in the callback.
+      if (callback) {
+        callback.apply(null, result)
+      }
+      return flush()
+    })
+  }
 
+  return enqueue
+}

+ 15 - 11
services/document-updater/app/js/sharejs/simple.js

@@ -23,28 +23,32 @@ module.exports = {
   name: 'simple',
 
   // Create a new document snapshot
-  create() { return {str:""}; },
+  create() {
+    return { str: '' }
+  },
 
   // Apply the given op to the document snapshot. Returns the new snapshot.
   //
   // The original snapshot should not be modified.
   apply(snapshot, op) {
-    if (!(op.position >= 0 && op.position <= snapshot.str.length)) { throw new Error('Invalid position'); }
+    if (!(op.position >= 0 && op.position <= snapshot.str.length)) {
+      throw new Error('Invalid position')
+    }
 
-    let {
-      str
-    } = snapshot;
-    str = str.slice(0, op.position) + op.text + str.slice(op.position);
-    return {str};
+    let { str } = snapshot
+    str = str.slice(0, op.position) + op.text + str.slice(op.position)
+    return { str }
   },
 
   // transform op1 by op2. Return transformed version of op1.
   // sym describes the symmetry of the op. Its 'left' or 'right' depending on whether the
   // op being transformed comes from the client or the server.
   transform(op1, op2, sym) {
-    let pos = op1.position;
-    if ((op2.position < pos) || ((op2.position === pos) && (sym === 'left'))) { pos += op2.text.length; }
+    let pos = op1.position
+    if (op2.position < pos || (op2.position === pos && sym === 'left')) {
+      pos += op2.text.length
+    }
 
-    return {position:pos, text:op1.text};
+    return { position: pos, text: op1.text }
   }
-};
+}

+ 28 - 22
services/document-updater/app/js/sharejs/syncqueue.js

@@ -25,30 +25,36 @@
 //
 //   ^--- async thing will only be running once at any time.
 
-module.exports = function(process) {
-  if (typeof process !== 'function') { throw new Error('process is not a function'); }
-  const queue = [];
-  
-  const enqueue = function(data, callback) {
-    queue.push([data, callback]);
-    return flush();
-  };
+module.exports = function (process) {
+  if (typeof process !== 'function') {
+    throw new Error('process is not a function')
+  }
+  const queue = []
 
-  enqueue.busy = false;
+  const enqueue = function (data, callback) {
+    queue.push([data, callback])
+    return flush()
+  }
 
-  var flush = function() {
-    if (enqueue.busy || (queue.length === 0)) { return; }
+  enqueue.busy = false
 
-    enqueue.busy = true;
-    const [data, callback] = Array.from(queue.shift());
-    return process(data, function(...result) { // TODO: Make this not use varargs - varargs are really slow.
-      enqueue.busy = false;
-      // This is called after busy = false so a user can check if enqueue.busy is set in the callback.
-      if (callback) { callback.apply(null, result); }
-      return flush();
-    });
-  };
+  var flush = function () {
+    if (enqueue.busy || queue.length === 0) {
+      return
+    }
 
-  return enqueue;
-};
+    enqueue.busy = true
+    const [data, callback] = Array.from(queue.shift())
+    return process(data, function (...result) {
+      // TODO: Make this not use varargs - varargs are really slow.
+      enqueue.busy = false
+      // This is called after busy = false so a user can check if enqueue.busy is set in the callback.
+      if (callback) {
+        callback.apply(null, result)
+      }
+      return flush()
+    })
+  }
 
+  return enqueue
+}

+ 27 - 21
services/document-updater/app/js/sharejs/text-api.js

@@ -8,39 +8,45 @@
  */
 // Text document API for text
 
-let text;
-if (typeof WEB === 'undefined') { text = require('./text'); }
+let text
+if (typeof WEB === 'undefined') {
+  text = require('./text')
+}
 
 text.api = {
-  provides: {text:true},
+  provides: { text: true },
 
   // The number of characters in the string
-  getLength() { return this.snapshot.length; },
+  getLength() {
+    return this.snapshot.length
+  },
 
   // Get the text contents of a document
-  getText() { return this.snapshot; },
+  getText() {
+    return this.snapshot
+  },
 
   insert(pos, text, callback) {
-    const op = [{p:pos, i:text}];
-    
-    this.submitOp(op, callback);
-    return op;
+    const op = [{ p: pos, i: text }]
+
+    this.submitOp(op, callback)
+    return op
   },
-  
+
   del(pos, length, callback) {
-    const op = [{p:pos, d:this.snapshot.slice(pos, (pos + length))}];
+    const op = [{ p: pos, d: this.snapshot.slice(pos, pos + length) }]
 
-    this.submitOp(op, callback);
-    return op;
+    this.submitOp(op, callback)
+    return op
   },
-  
+
   _register() {
-    return this.on('remoteop', function(op) {
+    return this.on('remoteop', function (op) {
       return Array.from(op).map((component) =>
-        component.i !== undefined ?
-          this.emit('insert', component.p, component.i)
-        :
-          this.emit('delete', component.p, component.d));
-    });
+        component.i !== undefined
+          ? this.emit('insert', component.p, component.i)
+          : this.emit('delete', component.p, component.d)
+      )
+    })
   }
-};
+}

+ 37 - 30
services/document-updater/app/js/sharejs/text-composable-api.js

@@ -13,57 +13,64 @@
  */
 // Text document API for text
 
-let type;
+let type
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  type = exports.types['text-composable'];
+  type = exports.types['text-composable']
 } else {
-  type = require('./text-composable');
+  type = require('./text-composable')
 }
 
 type.api = {
-  provides: {'text':true},
+  provides: { text: true },
 
   // The number of characters in the string
-  'getLength'() { return this.snapshot.length; },
+  getLength() {
+    return this.snapshot.length
+  },
 
   // Get the text contents of a document
-  'getText'() { return this.snapshot; },
+  getText() {
+    return this.snapshot
+  },
+
+  insert(pos, text, callback) {
+    const op = type.normalize([pos, { i: text }, this.snapshot.length - pos])
 
-  'insert'(pos, text, callback) {
-    const op = type.normalize([pos, {'i':text}, (this.snapshot.length - pos)]);
-    
-    this.submitOp(op, callback);
-    return op;
+    this.submitOp(op, callback)
+    return op
   },
-  
-  'del'(pos, length, callback) {
-    const op = type.normalize([pos, {'d':this.snapshot.slice(pos, (pos + length))}, (this.snapshot.length - pos - length)]);
 
-    this.submitOp(op, callback);
-    return op;
+  del(pos, length, callback) {
+    const op = type.normalize([
+      pos,
+      { d: this.snapshot.slice(pos, pos + length) },
+      this.snapshot.length - pos - length
+    ])
+
+    this.submitOp(op, callback)
+    return op
   },
 
   _register() {
-    return this.on('remoteop', function(op) {
-      let pos = 0;
+    return this.on('remoteop', function (op) {
+      let pos = 0
       return (() => {
-        const result = [];
+        const result = []
         for (const component of Array.from(op)) {
           if (typeof component === 'number') {
-            result.push(pos += component);
+            result.push((pos += component))
           } else if (component.i !== undefined) {
-            this.emit('insert', pos, component.i);
-            result.push(pos += component.i.length);
+            this.emit('insert', pos, component.i)
+            result.push((pos += component.i.length))
           } else {
             // delete
-            result.push(this.emit('delete', pos, component.d));
+            result.push(this.emit('delete', pos, component.d))
           }
         }
-        return result;
-      })();
-    });
+        return result
+      })()
+    })
   }
-};
-          // We don't increment pos, because the position
-          // specified is after the delete has happened.
-
+}
+// We don't increment pos, because the position
+// specified is after the delete has happened.

+ 255 - 179
services/document-updater/app/js/sharejs/text-composable.js

@@ -27,297 +27,373 @@
 //
 // Snapshots are strings.
 
-let makeAppend;
-const p = function() {}; // require('util').debug
-const i = function() {}; // require('util').inspect
+let makeAppend
+const p = function () {} // require('util').debug
+const i = function () {} // require('util').inspect
 
-const exports = (typeof WEB !== 'undefined' && WEB !== null) ? {} : module.exports;
+const exports = typeof WEB !== 'undefined' && WEB !== null ? {} : module.exports
 
-exports.name = 'text-composable';
+exports.name = 'text-composable'
 
-exports.create = () => '';
+exports.create = () => ''
 
 // -------- Utility methods
 
-const checkOp = function(op) {
-  if (!Array.isArray(op)) { throw new Error('Op must be an array of components'); }
-  let last = null;
+const checkOp = function (op) {
+  if (!Array.isArray(op)) {
+    throw new Error('Op must be an array of components')
+  }
+  let last = null
   return (() => {
-    const result = [];
+    const result = []
     for (const c of Array.from(op)) {
-      if (typeof(c) === 'object') {
-        if (((c.i == null) || !(c.i.length > 0)) && ((c.d == null) || !(c.d.length > 0))) { throw new Error(`Invalid op component: ${i(c)}`); }
+      if (typeof c === 'object') {
+        if (
+          (c.i == null || !(c.i.length > 0)) &&
+          (c.d == null || !(c.d.length > 0))
+        ) {
+          throw new Error(`Invalid op component: ${i(c)}`)
+        }
       } else {
-        if (typeof(c) !== 'number') { throw new Error('Op components must be objects or numbers'); }
-        if (!(c > 0)) { throw new Error('Skip components must be a positive number'); }
-        if (typeof(last) === 'number') { throw new Error('Adjacent skip components should be added'); }
+        if (typeof c !== 'number') {
+          throw new Error('Op components must be objects or numbers')
+        }
+        if (!(c > 0)) {
+          throw new Error('Skip components must be a positive number')
+        }
+        if (typeof last === 'number') {
+          throw new Error('Adjacent skip components should be added')
+        }
       }
 
-      result.push(last = c);
+      result.push((last = c))
     }
-    return result;
-  })();
-};
+    return result
+  })()
+}
 
 // Makes a function for appending components to a given op.
 // Exported for the randomOpGenerator.
-exports._makeAppend = (makeAppend = op => (function(component) {
-  if ((component === 0) || (component.i === '') || (component.d === '')) {
-    
-  } else if (op.length === 0) {
-    return op.push(component);
-  } else if ((typeof(component) === 'number') && (typeof(op[op.length - 1]) === 'number')) {
-    return op[op.length - 1] += component;
-  } else if ((component.i != null) && (op[op.length - 1].i != null)) {
-    return op[op.length - 1].i += component.i;
-  } else if ((component.d != null) && (op[op.length - 1].d != null)) {
-    return op[op.length - 1].d += component.d;
-  } else {
-    return op.push(component);
+exports._makeAppend = makeAppend = (op) =>
+  function (component) {
+    if (component === 0 || component.i === '' || component.d === '') {
+    } else if (op.length === 0) {
+      return op.push(component)
+    } else if (
+      typeof component === 'number' &&
+      typeof op[op.length - 1] === 'number'
+    ) {
+      return (op[op.length - 1] += component)
+    } else if (component.i != null && op[op.length - 1].i != null) {
+      return (op[op.length - 1].i += component.i)
+    } else if (component.d != null && op[op.length - 1].d != null) {
+      return (op[op.length - 1].d += component.d)
+    } else {
+      return op.push(component)
+    }
   }
-}));
-  
+
 //  checkOp op
 
 // Makes 2 functions for taking components from the start of an op, and for peeking
 // at the next op that could be taken.
-const makeTake = function(op) {
+const makeTake = function (op) {
   // The index of the next component to take
-  let idx = 0;
+  let idx = 0
   // The offset into the component
-  let offset = 0;
+  let offset = 0
 
   // Take up to length n from the front of op. If n is null, take the next
   // op component. If indivisableField == 'd', delete components won't be separated.
   // If indivisableField == 'i', insert components won't be separated.
-  const take = function(n, indivisableField) {
-    let c;
-    if (idx === op.length) { return null; }
+  const take = function (n, indivisableField) {
+    let c
+    if (idx === op.length) {
+      return null
+    }
     // assert.notStrictEqual op.length, i, 'The op is too short to traverse the document'
 
-    if (typeof(op[idx]) === 'number') {
-      if ((n == null) || ((op[idx] - offset) <= n)) {
-        c = op[idx] - offset;
-        ++idx; offset = 0;
-        return c;
+    if (typeof op[idx] === 'number') {
+      if (n == null || op[idx] - offset <= n) {
+        c = op[idx] - offset
+        ++idx
+        offset = 0
+        return c
       } else {
-        offset += n;
-        return n;
+        offset += n
+        return n
       }
     } else {
       // Take from the string
-      const field = op[idx].i ? 'i' : 'd';
-      c = {};
-      if ((n == null) || ((op[idx][field].length - offset) <= n) || (field === indivisableField)) {
-        c[field] = op[idx][field].slice(offset);
-        ++idx; offset = 0;
+      const field = op[idx].i ? 'i' : 'd'
+      c = {}
+      if (
+        n == null ||
+        op[idx][field].length - offset <= n ||
+        field === indivisableField
+      ) {
+        c[field] = op[idx][field].slice(offset)
+        ++idx
+        offset = 0
       } else {
-        c[field] = op[idx][field].slice(offset, (offset + n));
-        offset += n;
+        c[field] = op[idx][field].slice(offset, offset + n)
+        offset += n
       }
-      return c;
+      return c
     }
-  };
-  
-  const peekType = () => op[idx];
-  
-  return [take, peekType];
-};
+  }
+
+  const peekType = () => op[idx]
+
+  return [take, peekType]
+}
 
 // Find and return the length of an op component
-const componentLength = function(component) {
-  if (typeof(component) === 'number') {
-    return component;
+const componentLength = function (component) {
+  if (typeof component === 'number') {
+    return component
   } else if (component.i != null) {
-    return component.i.length;
+    return component.i.length
   } else {
-    return component.d.length;
+    return component.d.length
   }
-};
+}
 
 // Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
 // adjacent inserts and deletes.
-exports.normalize = function(op) {
-  const newOp = [];
-  const append = makeAppend(newOp);
-  for (const component of Array.from(op)) { append(component); }
-  return newOp;
-};
+exports.normalize = function (op) {
+  const newOp = []
+  const append = makeAppend(newOp)
+  for (const component of Array.from(op)) {
+    append(component)
+  }
+  return newOp
+}
 
 // Apply the op to the string. Returns the new string.
-exports.apply = function(str, op) {
-  p(`Applying ${i(op)} to '${str}'`);
-  if (typeof(str) !== 'string') { throw new Error('Snapshot should be a string'); }
-  checkOp(op);
+exports.apply = function (str, op) {
+  p(`Applying ${i(op)} to '${str}'`)
+  if (typeof str !== 'string') {
+    throw new Error('Snapshot should be a string')
+  }
+  checkOp(op)
 
-  const pos = 0;
-  const newDoc = [];
+  const pos = 0
+  const newDoc = []
 
   for (const component of Array.from(op)) {
-    if (typeof(component) === 'number') {
-      if (component > str.length) { throw new Error('The op is too long for this document'); }
-      newDoc.push(str.slice(0, component));
-      str = str.slice(component);
+    if (typeof component === 'number') {
+      if (component > str.length) {
+        throw new Error('The op is too long for this document')
+      }
+      newDoc.push(str.slice(0, component))
+      str = str.slice(component)
     } else if (component.i != null) {
-      newDoc.push(component.i);
+      newDoc.push(component.i)
     } else {
-      if (component.d !== str.slice(0, component.d.length)) { throw new Error(`The deleted text '${component.d}' doesn't match the next characters in the document '${str.slice(0, component.d.length)}'`); }
-      str = str.slice(component.d.length);
+      if (component.d !== str.slice(0, component.d.length)) {
+        throw new Error(
+          `The deleted text '${
+            component.d
+          }' doesn't match the next characters in the document '${str.slice(
+            0,
+            component.d.length
+          )}'`
+        )
+      }
+      str = str.slice(component.d.length)
     }
   }
-  
-  if (str !== '') { throw new Error("The applied op doesn't traverse the entire document"); }
 
-  return newDoc.join('');
-};
+  if (str !== '') {
+    throw new Error("The applied op doesn't traverse the entire document")
+  }
+
+  return newDoc.join('')
+}
 
 // transform op1 by op2. Return transformed version of op1.
 // op1 and op2 are unchanged by transform.
-exports.transform = function(op, otherOp, side) {
-  let component;
-  if ((side !== 'left') && (side !== 'right')) { throw new Error(`side (${side} must be 'left' or 'right'`); }
+exports.transform = function (op, otherOp, side) {
+  let component
+  if (side !== 'left' && side !== 'right') {
+    throw new Error(`side (${side} must be 'left' or 'right'`)
+  }
 
-  checkOp(op);
-  checkOp(otherOp);
-  const newOp = [];
+  checkOp(op)
+  checkOp(otherOp)
+  const newOp = []
 
-  const append = makeAppend(newOp);
-  const [take, peek] = Array.from(makeTake(op));
+  const append = makeAppend(newOp)
+  const [take, peek] = Array.from(makeTake(op))
 
   for (component of Array.from(otherOp)) {
-    var chunk, length;
-    if (typeof(component) === 'number') { // Skip
-      length = component;
+    var chunk, length
+    if (typeof component === 'number') {
+      // Skip
+      length = component
       while (length > 0) {
-        chunk = take(length, 'i');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, 'i')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(chunk);
-        if ((typeof(chunk) !== 'object') || (chunk.i == null)) { length -= componentLength(chunk); }
+        append(chunk)
+        if (typeof chunk !== 'object' || chunk.i == null) {
+          length -= componentLength(chunk)
+        }
       }
-    } else if (component.i != null) { // Insert
+    } else if (component.i != null) {
+      // Insert
       if (side === 'left') {
         // The left insert should go first.
-        const o = peek();
-        if (o != null ? o.i : undefined) { append(take()); }
+        const o = peek()
+        if (o != null ? o.i : undefined) {
+          append(take())
+        }
       }
 
       // Otherwise, skip the inserted text.
-      append(component.i.length);
-    } else { // Delete.
+      append(component.i.length)
+    } else {
+      // Delete.
       // assert.ok component.d
-      ({
-        length
-      } = component.d);
+      ;({ length } = component.d)
       while (length > 0) {
-        chunk = take(length, 'i');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, 'i')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        if (typeof(chunk) === 'number') {
-          length -= chunk;
+        if (typeof chunk === 'number') {
+          length -= chunk
         } else if (chunk.i != null) {
-          append(chunk);
+          append(chunk)
         } else {
           // assert.ok chunk.d
           // The delete is unnecessary now.
-          length -= chunk.d.length;
+          length -= chunk.d.length
         }
       }
     }
   }
-  
+
   // Append extras from op1
-  while (component = take()) {
-    if ((component != null ? component.i : undefined) == null) { throw new Error(`Remaining fragments in the op: ${i(component)}`); }
-    append(component);
+  while ((component = take())) {
+    if ((component != null ? component.i : undefined) == null) {
+      throw new Error(`Remaining fragments in the op: ${i(component)}`)
+    }
+    append(component)
   }
 
-  return newOp;
-};
-
+  return newOp
+}
 
 // Compose 2 ops into 1 op.
-exports.compose = function(op1, op2) {
-  let component;
-  p(`COMPOSE ${i(op1)} + ${i(op2)}`);
-  checkOp(op1);
-  checkOp(op2);
+exports.compose = function (op1, op2) {
+  let component
+  p(`COMPOSE ${i(op1)} + ${i(op2)}`)
+  checkOp(op1)
+  checkOp(op2)
 
-  const result = [];
+  const result = []
 
-  const append = makeAppend(result);
-  const [take, _] = Array.from(makeTake(op1));
+  const append = makeAppend(result)
+  const [take, _] = Array.from(makeTake(op1))
 
   for (component of Array.from(op2)) {
-    var chunk, length;
-    if (typeof(component) === 'number') { // Skip
-      length = component;
+    var chunk, length
+    if (typeof component === 'number') {
+      // Skip
+      length = component
       while (length > 0) {
-        chunk = take(length, 'd');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, 'd')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(chunk);
-        if ((typeof(chunk) !== 'object') || (chunk.d == null)) { length -= componentLength(chunk); }
+        append(chunk)
+        if (typeof chunk !== 'object' || chunk.d == null) {
+          length -= componentLength(chunk)
+        }
       }
-
-    } else if (component.i != null) { // Insert
-      append({i:component.i});
-
-    } else { // Delete
-      let offset = 0;
+    } else if (component.i != null) {
+      // Insert
+      append({ i: component.i })
+    } else {
+      // Delete
+      let offset = 0
       while (offset < component.d.length) {
-        chunk = take(component.d.length - offset, 'd');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(component.d.length - offset, 'd')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
         // If its delete, append it. If its skip, drop it and decrease length. If its insert, check the strings match, drop it and decrease length.
-        if (typeof(chunk) === 'number') {
-          append({d:component.d.slice(offset, (offset + chunk))});
-          offset += chunk;
+        if (typeof chunk === 'number') {
+          append({ d: component.d.slice(offset, offset + chunk) })
+          offset += chunk
         } else if (chunk.i != null) {
-          if (component.d.slice(offset, (offset + chunk.i.length)) !== chunk.i) { throw new Error("The deleted text doesn't match the inserted text"); }
-          offset += chunk.i.length;
+          if (component.d.slice(offset, offset + chunk.i.length) !== chunk.i) {
+            throw new Error("The deleted text doesn't match the inserted text")
+          }
+          offset += chunk.i.length
           // The ops cancel each other out.
         } else {
           // Delete
-          append(chunk);
+          append(chunk)
         }
       }
     }
   }
-    
+
   // Append extras from op1
-  while (component = take()) {
-    if ((component != null ? component.d : undefined) == null) { throw new Error(`Trailing stuff in op1 ${i(component)}`); }
-    append(component);
+  while ((component = take())) {
+    if ((component != null ? component.d : undefined) == null) {
+      throw new Error(`Trailing stuff in op1 ${i(component)}`)
+    }
+    append(component)
   }
 
-  return result;
-};
-  
+  return result
+}
 
-const invertComponent = function(c) {
-  if (typeof(c) === 'number') {
-    return c;
+const invertComponent = function (c) {
+  if (typeof c === 'number') {
+    return c
   } else if (c.i != null) {
-    return {d:c.i};
+    return { d: c.i }
   } else {
-    return {i:c.d};
+    return { i: c.d }
   }
-};
+}
 
 // Invert an op
-exports.invert = function(op) {
-  const result = [];
-  const append = makeAppend(result);
+exports.invert = function (op) {
+  const result = []
+  const append = makeAppend(result)
 
-  for (const component of Array.from(op)) { append(invertComponent(component)); }
-  
-  return result;
-};
+  for (const component of Array.from(op)) {
+    append(invertComponent(component))
+  }
 
-if (typeof window !== 'undefined' && window !== null) {
-  if (!window.ot) { window.ot = {}; }
-  if (!window.ot.types) { window.ot.types = {}; }
-  window.ot.types.text = exports;
+  return result
 }
 
+if (typeof window !== 'undefined' && window !== null) {
+  if (!window.ot) {
+    window.ot = {}
+  }
+  if (!window.ot.types) {
+    window.ot.types = {}
+  }
+  window.ot.types.text = exports
+}

+ 71 - 61
services/document-updater/app/js/sharejs/text-tp2-api.js

@@ -13,111 +13,121 @@
  */
 // Text document API for text-tp2
 
-let type;
+let type
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  type = exports.types['text-tp2'];
+  type = exports.types['text-tp2']
 } else {
-  type = require('./text-tp2');
+  type = require('./text-tp2')
 }
 
-const {_takeDoc:takeDoc, _append:append} = type;
+const { _takeDoc: takeDoc, _append: append } = type
 
-const appendSkipChars = (op, doc, pos, maxlength) => (() => {
-  const result = [];
-  while (((maxlength === undefined) || (maxlength > 0)) && (pos.index < doc.data.length)) {
-    const part = takeDoc(doc, pos, maxlength, true);
-    if ((maxlength !== undefined) && (typeof part === 'string')) { maxlength -= part.length; }
-    result.push(append(op, (part.length || part)));
-  }
-  return result;
-})();
+const appendSkipChars = (op, doc, pos, maxlength) =>
+  (() => {
+    const result = []
+    while (
+      (maxlength === undefined || maxlength > 0) &&
+      pos.index < doc.data.length
+    ) {
+      const part = takeDoc(doc, pos, maxlength, true)
+      if (maxlength !== undefined && typeof part === 'string') {
+        maxlength -= part.length
+      }
+      result.push(append(op, part.length || part))
+    }
+    return result
+  })()
 
 type.api = {
-  'provides': {'text':true},
+  provides: { text: true },
 
   // The number of characters in the string
-  'getLength'() { return this.snapshot.charLength; },
+  getLength() {
+    return this.snapshot.charLength
+  },
 
   // Flatten a document into a string
-  'getText'() {
-    const strings = (Array.from(this.snapshot.data).filter((elem) => typeof elem === 'string'));
-    return strings.join('');
+  getText() {
+    const strings = Array.from(this.snapshot.data).filter(
+      (elem) => typeof elem === 'string'
+    )
+    return strings.join('')
   },
 
-  'insert'(pos, text, callback) {
-    if (pos === undefined) { pos = 0; }
+  insert(pos, text, callback) {
+    if (pos === undefined) {
+      pos = 0
+    }
+
+    const op = []
+    const docPos = { index: 0, offset: 0 }
 
-    const op = [];
-    const docPos = {index:0, offset:0};
+    appendSkipChars(op, this.snapshot, docPos, pos)
+    append(op, { i: text })
+    appendSkipChars(op, this.snapshot, docPos)
 
-    appendSkipChars(op, this.snapshot, docPos, pos);
-    append(op, {'i':text});
-    appendSkipChars(op, this.snapshot, docPos);
-    
-    this.submitOp(op, callback);
-    return op;
+    this.submitOp(op, callback)
+    return op
   },
-  
-  'del'(pos, length, callback) {
-    const op = [];
-    const docPos = {index:0, offset:0};
 
-    appendSkipChars(op, this.snapshot, docPos, pos);
-    
+  del(pos, length, callback) {
+    const op = []
+    const docPos = { index: 0, offset: 0 }
+
+    appendSkipChars(op, this.snapshot, docPos, pos)
+
     while (length > 0) {
-      const part = takeDoc(this.snapshot, docPos, length, true);
+      const part = takeDoc(this.snapshot, docPos, length, true)
       if (typeof part === 'string') {
-        append(op, {'d':part.length});
-        length -= part.length;
+        append(op, { d: part.length })
+        length -= part.length
       } else {
-        append(op, part);
+        append(op, part)
       }
     }
-    
-    appendSkipChars(op, this.snapshot, docPos);
 
-    this.submitOp(op, callback);
-    return op;
+    appendSkipChars(op, this.snapshot, docPos)
+
+    this.submitOp(op, callback)
+    return op
   },
 
-  '_register'() {
+  _register() {
     // Interpret recieved ops + generate more detailed events for them
-    return this.on('remoteop', function(op, snapshot) {
-      let textPos = 0;
-      const docPos = {index:0, offset:0};
+    return this.on('remoteop', function (op, snapshot) {
+      let textPos = 0
+      const docPos = { index: 0, offset: 0 }
 
       for (const component of Array.from(op)) {
-        var part, remainder;
+        var part, remainder
         if (typeof component === 'number') {
           // Skip
-          remainder = component;
+          remainder = component
           while (remainder > 0) {
-            part = takeDoc(snapshot, docPos, remainder);
+            part = takeDoc(snapshot, docPos, remainder)
             if (typeof part === 'string') {
-              textPos += part.length;
+              textPos += part.length
             }
-            remainder -= part.length || part;
+            remainder -= part.length || part
           }
         } else if (component.i !== undefined) {
           // Insert
           if (typeof component.i === 'string') {
-            this.emit('insert', textPos, component.i);
-            textPos += component.i.length;
+            this.emit('insert', textPos, component.i)
+            textPos += component.i.length
           }
         } else {
           // Delete
-          remainder = component.d;
+          remainder = component.d
           while (remainder > 0) {
-            part = takeDoc(snapshot, docPos, remainder);
+            part = takeDoc(snapshot, docPos, remainder)
             if (typeof part === 'string') {
-              this.emit('delete', textPos, part);
+              this.emit('delete', textPos, part)
             }
-            remainder -= part.length || part;
+            remainder -= part.length || part
           }
         }
       }
-
-    });
+    })
   }
-};
-
+}

+ 307 - 216
services/document-updater/app/js/sharejs/text-tp2.js

@@ -38,369 +38,460 @@
 // Eg, the document: 'Hello .....world' ('.' denotes tombstoned (deleted) characters)
 // would be represented by a document snapshot of ['Hello ', 5, 'world']
 
-let append, appendDoc, takeDoc;
+let append, appendDoc, takeDoc
 var type = {
   name: 'text-tp2',
   tp2: true,
-  create() { return {charLength:0, totalLength:0, positionCache:[], data:[]}; },
+  create() {
+    return { charLength: 0, totalLength: 0, positionCache: [], data: [] }
+  },
   serialize(doc) {
-    if (!doc.data) { throw new Error('invalid doc snapshot'); }
-    return doc.data;
+    if (!doc.data) {
+      throw new Error('invalid doc snapshot')
+    }
+    return doc.data
   },
   deserialize(data) {
-    const doc = type.create();
-    doc.data = data;
-    
+    const doc = type.create()
+    doc.data = data
+
     for (const component of Array.from(data)) {
       if (typeof component === 'string') {
-        doc.charLength += component.length;
-        doc.totalLength += component.length;
+        doc.charLength += component.length
+        doc.totalLength += component.length
       } else {
-        doc.totalLength += component;
+        doc.totalLength += component
       }
     }
-    
-    return doc;
-  }
-};
 
+    return doc
+  }
+}
 
-const checkOp = function(op) {
-  if (!Array.isArray(op)) { throw new Error('Op must be an array of components'); }
-  let last = null;
+const checkOp = function (op) {
+  if (!Array.isArray(op)) {
+    throw new Error('Op must be an array of components')
+  }
+  let last = null
   return (() => {
-    const result = [];
+    const result = []
     for (const c of Array.from(op)) {
-      if (typeof(c) === 'object') {
+      if (typeof c === 'object') {
         if (c.i !== undefined) {
-          if (((typeof(c.i) !== 'string') || !(c.i.length > 0)) && ((typeof(c.i) !== 'number') || !(c.i > 0))) { throw new Error('Inserts must insert a string or a +ive number'); }
+          if (
+            (typeof c.i !== 'string' || !(c.i.length > 0)) &&
+            (typeof c.i !== 'number' || !(c.i > 0))
+          ) {
+            throw new Error('Inserts must insert a string or a +ive number')
+          }
         } else if (c.d !== undefined) {
-          if ((typeof(c.d) !== 'number') || !(c.d > 0)) { throw new Error('Deletes must be a +ive number'); }
+          if (typeof c.d !== 'number' || !(c.d > 0)) {
+            throw new Error('Deletes must be a +ive number')
+          }
         } else {
-          throw new Error('Operation component must define .i or .d');
+          throw new Error('Operation component must define .i or .d')
         }
       } else {
-        if (typeof(c) !== 'number') { throw new Error('Op components must be objects or numbers'); }
-        if (!(c > 0)) { throw new Error('Skip components must be a positive number'); }
-        if (typeof(last) === 'number') { throw new Error('Adjacent skip components should be combined'); }
+        if (typeof c !== 'number') {
+          throw new Error('Op components must be objects or numbers')
+        }
+        if (!(c > 0)) {
+          throw new Error('Skip components must be a positive number')
+        }
+        if (typeof last === 'number') {
+          throw new Error('Adjacent skip components should be combined')
+        }
       }
 
-      result.push(last = c);
+      result.push((last = c))
     }
-    return result;
-  })();
-};
+    return result
+  })()
+}
 
 // Take the next part from the specified position in a document snapshot.
 // position = {index, offset}. It will be updated.
-type._takeDoc = (takeDoc = function(doc, position, maxlength, tombsIndivisible) {
-  if (position.index >= doc.data.length) { throw new Error('Operation goes past the end of the document'); }
+type._takeDoc = takeDoc = function (
+  doc,
+  position,
+  maxlength,
+  tombsIndivisible
+) {
+  if (position.index >= doc.data.length) {
+    throw new Error('Operation goes past the end of the document')
+  }
 
-  const part = doc.data[position.index];
+  const part = doc.data[position.index]
   // peel off data[0]
-  const result = typeof(part) === 'string' ?
-    maxlength !== undefined ?
-      part.slice(position.offset, (position.offset + maxlength))
-    :
-      part.slice(position.offset)
-  :
-    (maxlength === undefined) || tombsIndivisible ?
-      part - position.offset
-    :
-      Math.min(maxlength, part - position.offset);
-  
-  const resultLen = result.length || result;
-
-  if (((part.length || part) - position.offset) > resultLen) {
-    position.offset += resultLen;
+  const result =
+    typeof part === 'string'
+      ? maxlength !== undefined
+        ? part.slice(position.offset, position.offset + maxlength)
+        : part.slice(position.offset)
+      : maxlength === undefined || tombsIndivisible
+      ? part - position.offset
+      : Math.min(maxlength, part - position.offset)
+
+  const resultLen = result.length || result
+
+  if ((part.length || part) - position.offset > resultLen) {
+    position.offset += resultLen
   } else {
-    position.index++;
-    position.offset = 0;
+    position.index++
+    position.offset = 0
   }
-  
-  return result;
-});
+
+  return result
+}
 
 // Append a part to the end of a document
-type._appendDoc = (appendDoc = function(doc, p) {
-  if ((p === 0) || (p === '')) { return; }
+type._appendDoc = appendDoc = function (doc, p) {
+  if (p === 0 || p === '') {
+    return
+  }
 
   if (typeof p === 'string') {
-    doc.charLength += p.length;
-    doc.totalLength += p.length;
+    doc.charLength += p.length
+    doc.totalLength += p.length
   } else {
-    doc.totalLength += p;
+    doc.totalLength += p
   }
 
-  const {
-    data
-  } = doc;
+  const { data } = doc
   if (data.length === 0) {
-    data.push(p);
-  } else if (typeof(data[data.length - 1]) === typeof(p)) {
-    data[data.length - 1] += p;
+    data.push(p)
+  } else if (typeof data[data.length - 1] === typeof p) {
+    data[data.length - 1] += p
   } else {
-    data.push(p);
+    data.push(p)
   }
-});
+}
 
 // Apply the op to the document. The document is not modified in the process.
-type.apply = function(doc, op) {
-  if ((doc.totalLength === undefined) || (doc.charLength === undefined) || (doc.data.length === undefined)) {
-    throw new Error('Snapshot is invalid');
+type.apply = function (doc, op) {
+  if (
+    doc.totalLength === undefined ||
+    doc.charLength === undefined ||
+    doc.data.length === undefined
+  ) {
+    throw new Error('Snapshot is invalid')
   }
 
-  checkOp(op);
+  checkOp(op)
 
-  const newDoc = type.create();
-  const position = {index:0, offset:0};
+  const newDoc = type.create()
+  const position = { index: 0, offset: 0 }
 
   for (const component of Array.from(op)) {
-    var part, remainder;
-    if (typeof(component) === 'number') {
-      remainder = component;
+    var part, remainder
+    if (typeof component === 'number') {
+      remainder = component
       while (remainder > 0) {
-        part = takeDoc(doc, position, remainder);
-        
-        appendDoc(newDoc, part);
-        remainder -= part.length || part;
-      }
+        part = takeDoc(doc, position, remainder)
 
+        appendDoc(newDoc, part)
+        remainder -= part.length || part
+      }
     } else if (component.i !== undefined) {
-      appendDoc(newDoc, component.i);
+      appendDoc(newDoc, component.i)
     } else if (component.d !== undefined) {
-      remainder = component.d;
+      remainder = component.d
       while (remainder > 0) {
-        part = takeDoc(doc, position, remainder);
-        remainder -= part.length || part;
+        part = takeDoc(doc, position, remainder)
+        remainder -= part.length || part
       }
-      appendDoc(newDoc, component.d);
+      appendDoc(newDoc, component.d)
     }
   }
-  
-  return newDoc;
-};
+
+  return newDoc
+}
 
 // Append an op component to the end of the specified op.
 // Exported for the randomOpGenerator.
-type._append = (append = function(op, component) {
-  if ((component === 0) || (component.i === '') || (component.i === 0) || (component.d === 0)) {
-    
+type._append = append = function (op, component) {
+  if (
+    component === 0 ||
+    component.i === '' ||
+    component.i === 0 ||
+    component.d === 0
+  ) {
   } else if (op.length === 0) {
-    return op.push(component);
+    return op.push(component)
   } else {
-    const last = op[op.length - 1];
-    if ((typeof(component) === 'number') && (typeof(last) === 'number')) {
-      return op[op.length - 1] += component;
-    } else if ((component.i !== undefined) && (last.i != null) && (typeof(last.i) === typeof(component.i))) {
-      return last.i += component.i;
-    } else if ((component.d !== undefined) && (last.d != null)) {
-      return last.d += component.d;
+    const last = op[op.length - 1]
+    if (typeof component === 'number' && typeof last === 'number') {
+      return (op[op.length - 1] += component)
+    } else if (
+      component.i !== undefined &&
+      last.i != null &&
+      typeof last.i === typeof component.i
+    ) {
+      return (last.i += component.i)
+    } else if (component.d !== undefined && last.d != null) {
+      return (last.d += component.d)
     } else {
-      return op.push(component);
+      return op.push(component)
     }
   }
-});
-  
+}
+
 // Makes 2 functions for taking components from the start of an op, and for peeking
 // at the next op that could be taken.
-const makeTake = function(op) {
+const makeTake = function (op) {
   // The index of the next component to take
-  let index = 0;
+  let index = 0
   // The offset into the component
-  let offset = 0;
+  let offset = 0
 
   // Take up to length maxlength from the op. If maxlength is not defined, there is no max.
   // If insertsIndivisible is true, inserts (& insert tombstones) won't be separated.
   //
   // Returns null when op is fully consumed.
-  const take = function(maxlength, insertsIndivisible) {
-    let current;
-    if (index === op.length) { return null; }
-
-    const e = op[index];
-    if ((typeof((current = e)) === 'number') || (typeof((current = e.i)) === 'number') || ((current = e.d) !== undefined)) {
-      let c;
-      if ((maxlength == null) || ((current - offset) <= maxlength) || (insertsIndivisible && (e.i !== undefined))) {
+  const take = function (maxlength, insertsIndivisible) {
+    let current
+    if (index === op.length) {
+      return null
+    }
+
+    const e = op[index]
+    if (
+      typeof (current = e) === 'number' ||
+      typeof (current = e.i) === 'number' ||
+      (current = e.d) !== undefined
+    ) {
+      let c
+      if (
+        maxlength == null ||
+        current - offset <= maxlength ||
+        (insertsIndivisible && e.i !== undefined)
+      ) {
         // Return the rest of the current element.
-        c = current - offset;
-        ++index; offset = 0;
+        c = current - offset
+        ++index
+        offset = 0
+      } else {
+        offset += maxlength
+        c = maxlength
+      }
+      if (e.i !== undefined) {
+        return { i: c }
+      } else if (e.d !== undefined) {
+        return { d: c }
       } else {
-        offset += maxlength;
-        c = maxlength;
+        return c
       }
-      if (e.i !== undefined) { return {i:c}; } else if (e.d !== undefined) { return {d:c}; } else { return c; }
     } else {
       // Take from the inserted string
-      let result;
-      if ((maxlength == null) || ((e.i.length - offset) <= maxlength) || insertsIndivisible) {
-        result = {i:e.i.slice(offset)};
-        ++index; offset = 0;
+      let result
+      if (
+        maxlength == null ||
+        e.i.length - offset <= maxlength ||
+        insertsIndivisible
+      ) {
+        result = { i: e.i.slice(offset) }
+        ++index
+        offset = 0
       } else {
-        result = {i:e.i.slice(offset, offset + maxlength)};
-        offset += maxlength;
+        result = { i: e.i.slice(offset, offset + maxlength) }
+        offset += maxlength
       }
-      return result;
+      return result
     }
-  };
-  
-  const peekType = () => op[index];
-  
-  return [take, peekType];
-};
+  }
+
+  const peekType = () => op[index]
+
+  return [take, peekType]
+}
 
 // Find and return the length of an op component
-const componentLength = function(component) {
-  if (typeof(component) === 'number') {
-    return component;
-  } else if (typeof(component.i) === 'string') {
-    return component.i.length;
+const componentLength = function (component) {
+  if (typeof component === 'number') {
+    return component
+  } else if (typeof component.i === 'string') {
+    return component.i.length
   } else {
     // This should work because c.d and c.i must be +ive.
-    return component.d || component.i;
+    return component.d || component.i
   }
-};
+}
 
 // Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
 // adjacent inserts and deletes.
-type.normalize = function(op) {
-  const newOp = [];
-  for (const component of Array.from(op)) { append(newOp, component); }
-  return newOp;
-};
+type.normalize = function (op) {
+  const newOp = []
+  for (const component of Array.from(op)) {
+    append(newOp, component)
+  }
+  return newOp
+}
 
 // This is a helper method to transform and prune. goForwards is true for transform, false for prune.
-const transformer = function(op, otherOp, goForwards, side) {
-  let component;
-  checkOp(op);
-  checkOp(otherOp);
-  const newOp = [];
+const transformer = function (op, otherOp, goForwards, side) {
+  let component
+  checkOp(op)
+  checkOp(otherOp)
+  const newOp = []
 
-  const [take, peek] = Array.from(makeTake(op));
+  const [take, peek] = Array.from(makeTake(op))
 
   for (component of Array.from(otherOp)) {
-    var chunk;
-    let length = componentLength(component);
+    var chunk
+    let length = componentLength(component)
 
-    if (component.i !== undefined) { // Insert text or tombs
-      if (goForwards) { // transform - insert skips over inserted parts
+    if (component.i !== undefined) {
+      // Insert text or tombs
+      if (goForwards) {
+        // transform - insert skips over inserted parts
         if (side === 'left') {
           // The left insert should go first.
-          while (__guard__(peek(), x => x.i) !== undefined) { append(newOp, take()); }
+          while (__guard__(peek(), (x) => x.i) !== undefined) {
+            append(newOp, take())
+          }
         }
 
         // In any case, skip the inserted text.
-        append(newOp, length);
-
-      } else { // Prune. Remove skips for inserts.
+        append(newOp, length)
+      } else {
+        // Prune. Remove skips for inserts.
         while (length > 0) {
-          chunk = take(length, true);
+          chunk = take(length, true)
 
-          if (chunk === null) { throw new Error('The transformed op is invalid'); }
-          if (chunk.d !== undefined) { throw new Error('The transformed op deletes locally inserted characters - it cannot be purged of the insert.'); }
+          if (chunk === null) {
+            throw new Error('The transformed op is invalid')
+          }
+          if (chunk.d !== undefined) {
+            throw new Error(
+              'The transformed op deletes locally inserted characters - it cannot be purged of the insert.'
+            )
+          }
 
           if (typeof chunk === 'number') {
-            length -= chunk;
+            length -= chunk
           } else {
-            append(newOp, chunk);
+            append(newOp, chunk)
           }
         }
       }
-
-    } else { // Skip or delete
+    } else {
+      // Skip or delete
       while (length > 0) {
-        chunk = take(length, true);
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, true)
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(newOp, chunk);
-        if (!chunk.i) { length -= componentLength(chunk); }
+        append(newOp, chunk)
+        if (!chunk.i) {
+          length -= componentLength(chunk)
+        }
       }
     }
   }
-  
+
   // Append extras from op1
-  while (component = take()) {
-    if (component.i === undefined) { throw new Error(`Remaining fragments in the op: ${component}`); }
-    append(newOp, component);
+  while ((component = take())) {
+    if (component.i === undefined) {
+      throw new Error(`Remaining fragments in the op: ${component}`)
+    }
+    append(newOp, component)
   }
 
-  return newOp;
-};
+  return newOp
+}
 
 // transform op1 by op2. Return transformed version of op1.
 // op1 and op2 are unchanged by transform.
 // side should be 'left' or 'right', depending on if op1.id <> op2.id. 'left' == client op.
-type.transform = function(op, otherOp, side) {
-  if ((side !== 'left') && (side !== 'right')) { throw new Error(`side (${side}) should be 'left' or 'right'`); }
-  return transformer(op, otherOp, true, side);
-};
+type.transform = function (op, otherOp, side) {
+  if (side !== 'left' && side !== 'right') {
+    throw new Error(`side (${side}) should be 'left' or 'right'`)
+  }
+  return transformer(op, otherOp, true, side)
+}
 
 // Prune is the inverse of transform.
-type.prune = (op, otherOp) => transformer(op, otherOp, false);
+type.prune = (op, otherOp) => transformer(op, otherOp, false)
 
 // Compose 2 ops into 1 op.
-type.compose = function(op1, op2) {
-  let component;
-  if ((op1 === null) || (op1 === undefined)) { return op2; }
+type.compose = function (op1, op2) {
+  let component
+  if (op1 === null || op1 === undefined) {
+    return op2
+  }
 
-  checkOp(op1);
-  checkOp(op2);
+  checkOp(op1)
+  checkOp(op2)
 
-  const result = [];
+  const result = []
 
-  const [take, _] = Array.from(makeTake(op1));
+  const [take, _] = Array.from(makeTake(op1))
 
   for (component of Array.from(op2)) {
-
-    var chunk, length;
-    if (typeof(component) === 'number') { // Skip
+    var chunk, length
+    if (typeof component === 'number') {
+      // Skip
       // Just copy from op1.
-      length = component;
+      length = component
       while (length > 0) {
-        chunk = take(length);
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length)
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(result, chunk);
-        length -= componentLength(chunk);
+        append(result, chunk)
+        length -= componentLength(chunk)
       }
-
-    } else if (component.i !== undefined) { // Insert
-      append(result, {i:component.i});
-
-    } else { // Delete
-      length = component.d;
+    } else if (component.i !== undefined) {
+      // Insert
+      append(result, { i: component.i })
+    } else {
+      // Delete
+      length = component.d
       while (length > 0) {
-        chunk = take(length);
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length)
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        const chunkLength = componentLength(chunk);
+        const chunkLength = componentLength(chunk)
         if (chunk.i !== undefined) {
-          append(result, {i:chunkLength});
+          append(result, { i: chunkLength })
         } else {
-          append(result, {d:chunkLength});
+          append(result, { d: chunkLength })
         }
 
-        length -= chunkLength;
+        length -= chunkLength
       }
     }
   }
-    
+
   // Append extras from op1
-  while (component = take()) {
-    if (component.i === undefined) { throw new Error(`Remaining fragments in op1: ${component}`); }
-    append(result, component);
+  while ((component = take())) {
+    if (component.i === undefined) {
+      throw new Error(`Remaining fragments in op1: ${component}`)
+    }
+    append(result, component)
   }
 
-  return result;
-};
+  return result
+}
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  exports.types['text-tp2'] = type;
+  exports.types['text-tp2'] = type
 } else {
-  module.exports = type;
+  module.exports = type
 }
 
-
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 171 - 109
services/document-updater/app/js/sharejs/text.js

@@ -32,99 +32,139 @@
 // NOTE: The global scope here is shared with other sharejs files when built with closure.
 // Be careful what ends up in your namespace.
 
-let append, transformComponent;
-const text = {};
+let append, transformComponent
+const text = {}
 
-text.name = 'text';
+text.name = 'text'
 
-text.create = () => '';
+text.create = () => ''
 
-const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos);
+const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos)
 
-const checkValidComponent = function(c) {
-  if (typeof c.p !== 'number') { throw new Error('component missing position field'); }
+const checkValidComponent = function (c) {
+  if (typeof c.p !== 'number') {
+    throw new Error('component missing position field')
+  }
 
-  const i_type = typeof c.i;
-  const d_type = typeof c.d;
-  if (!((i_type === 'string') ^ (d_type === 'string'))) { throw new Error('component needs an i or d field'); }
+  const i_type = typeof c.i
+  const d_type = typeof c.d
+  if (!((i_type === 'string') ^ (d_type === 'string'))) {
+    throw new Error('component needs an i or d field')
+  }
 
-  if (!(c.p >= 0)) { throw new Error('position cannot be negative'); }
-};
+  if (!(c.p >= 0)) {
+    throw new Error('position cannot be negative')
+  }
+}
 
-const checkValidOp = function(op) {
-  for (const c of Array.from(op)) { checkValidComponent(c); }
-  return true;
-};
+const checkValidOp = function (op) {
+  for (const c of Array.from(op)) {
+    checkValidComponent(c)
+  }
+  return true
+}
 
-text.apply = function(snapshot, op) {
-  checkValidOp(op);
+text.apply = function (snapshot, op) {
+  checkValidOp(op)
   for (const component of Array.from(op)) {
     if (component.i != null) {
-      snapshot = strInject(snapshot, component.p, component.i);
+      snapshot = strInject(snapshot, component.p, component.i)
     } else {
-      const deleted = snapshot.slice(component.p, (component.p + component.d.length));
-      if (component.d !== deleted) { throw new Error(`Delete component '${component.d}' does not match deleted text '${deleted}'`); }
-      snapshot = snapshot.slice(0, component.p) + snapshot.slice((component.p + component.d.length));
+      const deleted = snapshot.slice(
+        component.p,
+        component.p + component.d.length
+      )
+      if (component.d !== deleted) {
+        throw new Error(
+          `Delete component '${component.d}' does not match deleted text '${deleted}'`
+        )
+      }
+      snapshot =
+        snapshot.slice(0, component.p) +
+        snapshot.slice(component.p + component.d.length)
     }
   }
-  
-  return snapshot;
-};
 
+  return snapshot
+}
 
 // Exported for use by the random op generator.
 //
 // For simplicity, this version of append does not compress adjacent inserts and deletes of
 // the same text. It would be nice to change that at some stage.
-text._append = (append = function(newOp, c) {
-  if ((c.i === '') || (c.d === '')) { return; }
+text._append = append = function (newOp, c) {
+  if (c.i === '' || c.d === '') {
+    return
+  }
   if (newOp.length === 0) {
-    return newOp.push(c);
+    return newOp.push(c)
   } else {
-    const last = newOp[newOp.length - 1];
+    const last = newOp[newOp.length - 1]
 
     // Compose the insert into the previous insert if possible
-    if ((last.i != null) && (c.i != null) && (last.p <= c.p && c.p <= (last.p + last.i.length))) {
-      return newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p};
-    } else if ((last.d != null) && (c.d != null) && (c.p <= last.p && last.p <= (c.p + c.d.length))) {
-      return newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p};
+    if (
+      last.i != null &&
+      c.i != null &&
+      last.p <= c.p &&
+      c.p <= last.p + last.i.length
+    ) {
+      return (newOp[newOp.length - 1] = {
+        i: strInject(last.i, c.p - last.p, c.i),
+        p: last.p
+      })
+    } else if (
+      last.d != null &&
+      c.d != null &&
+      c.p <= last.p &&
+      last.p <= c.p + c.d.length
+    ) {
+      return (newOp[newOp.length - 1] = {
+        d: strInject(c.d, last.p - c.p, last.d),
+        p: c.p
+      })
     } else {
-      return newOp.push(c);
+      return newOp.push(c)
     }
   }
-});
+}
 
-text.compose = function(op1, op2) {
-  checkValidOp(op1);
-  checkValidOp(op2);
+text.compose = function (op1, op2) {
+  checkValidOp(op1)
+  checkValidOp(op2)
 
-  const newOp = op1.slice();
-  for (const c of Array.from(op2)) { append(newOp, c); }
+  const newOp = op1.slice()
+  for (const c of Array.from(op2)) {
+    append(newOp, c)
+  }
 
-  return newOp;
-};
+  return newOp
+}
 
 // Attempt to compress the op components together 'as much as possible'.
 // This implementation preserves order and preserves create/delete pairs.
-text.compress = op => text.compose([], op);
+text.compress = (op) => text.compose([], op)
+
+text.normalize = function (op) {
+  const newOp = []
 
-text.normalize = function(op) {
-  const newOp = [];
-  
   // Normalize should allow ops which are a single (unwrapped) component:
   // {i:'asdf', p:23}.
   // There's no good way to test if something is an array:
   // http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
   // so this is probably the least bad solution.
-  if ((op.i != null) || (op.p != null)) { op = [op]; }
+  if (op.i != null || op.p != null) {
+    op = [op]
+  }
 
   for (const c of Array.from(op)) {
-    if (c.p == null) { c.p = 0; }
-    append(newOp, c);
+    if (c.p == null) {
+      c.p = 0
+    }
+    append(newOp, c)
   }
-  
-  return newOp;
-};
+
+  return newOp
+}
 
 // This helper method transforms a position by an op component.
 //
@@ -132,121 +172,143 @@ text.normalize = function(op) {
 // is pushed after the insert (true) or before it (false).
 //
 // insertAfter is optional for deletes.
-const transformPosition = function(pos, c, insertAfter) {
+const transformPosition = function (pos, c, insertAfter) {
   if (c.i != null) {
-    if ((c.p < pos) || ((c.p === pos) && insertAfter)) {
-      return pos + c.i.length;
+    if (c.p < pos || (c.p === pos && insertAfter)) {
+      return pos + c.i.length
     } else {
-      return pos;
+      return pos
     }
   } else {
     // I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
     // but I think its harder to read that way, and it compiles using ternary operators anyway
     // so its no slower written like this.
     if (pos <= c.p) {
-      return pos;
-    } else if (pos <= (c.p + c.d.length)) {
-      return c.p;
+      return pos
+    } else if (pos <= c.p + c.d.length) {
+      return c.p
     } else {
-      return pos - c.d.length;
+      return pos - c.d.length
     }
   }
-};
+}
 
 // Helper method to transform a cursor position as a result of an op.
 //
 // Like transformPosition above, if c is an insert, insertAfter specifies whether the cursor position
 // is pushed after an insert (true) or before it (false).
-text.transformCursor = function(position, op, side) {
-  const insertAfter = side === 'right';
-  for (const c of Array.from(op)) { position = transformPosition(position, c, insertAfter); }
-  return position;
-};
+text.transformCursor = function (position, op, side) {
+  const insertAfter = side === 'right'
+  for (const c of Array.from(op)) {
+    position = transformPosition(position, c, insertAfter)
+  }
+  return position
+}
 
 // Transform an op component by another op component. Asymmetric.
 // The result will be appended to destination.
 //
 // exported for use in JSON type
-text._tc = (transformComponent = function(dest, c, otherC, side) {
-  checkValidOp([c]);
-  checkValidOp([otherC]);
+text._tc = transformComponent = function (dest, c, otherC, side) {
+  checkValidOp([c])
+  checkValidOp([otherC])
 
   if (c.i != null) {
-    append(dest, {i:c.i, p:transformPosition(c.p, otherC, side === 'right')});
-
-  } else { // Delete
-    if (otherC.i != null) { // delete vs insert
-      let s = c.d;
+    append(dest, {
+      i: c.i,
+      p: transformPosition(c.p, otherC, side === 'right')
+    })
+  } else {
+    // Delete
+    if (otherC.i != null) {
+      // delete vs insert
+      let s = c.d
       if (c.p < otherC.p) {
-        append(dest, {d:s.slice(0, otherC.p - c.p), p:c.p});
-        s = s.slice((otherC.p - c.p));
+        append(dest, { d: s.slice(0, otherC.p - c.p), p: c.p })
+        s = s.slice(otherC.p - c.p)
       }
       if (s !== '') {
-        append(dest, {d:s, p:c.p + otherC.i.length});
+        append(dest, { d: s, p: c.p + otherC.i.length })
       }
-
-    } else { // Delete vs delete
-      if (c.p >= (otherC.p + otherC.d.length)) {
-        append(dest, {d:c.d, p:c.p - otherC.d.length});
-      } else if ((c.p + c.d.length) <= otherC.p) {
-        append(dest, c);
+    } else {
+      // Delete vs delete
+      if (c.p >= otherC.p + otherC.d.length) {
+        append(dest, { d: c.d, p: c.p - otherC.d.length })
+      } else if (c.p + c.d.length <= otherC.p) {
+        append(dest, c)
       } else {
         // They overlap somewhere.
-        const newC = {d:'', p:c.p};
+        const newC = { d: '', p: c.p }
         if (c.p < otherC.p) {
-          newC.d = c.d.slice(0, (otherC.p - c.p));
+          newC.d = c.d.slice(0, otherC.p - c.p)
         }
-        if ((c.p + c.d.length) > (otherC.p + otherC.d.length)) {
-          newC.d += c.d.slice(((otherC.p + otherC.d.length) - c.p));
+        if (c.p + c.d.length > otherC.p + otherC.d.length) {
+          newC.d += c.d.slice(otherC.p + otherC.d.length - c.p)
         }
 
         // This is entirely optional - just for a check that the deleted
         // text in the two ops matches
-        const intersectStart = Math.max(c.p, otherC.p);
-        const intersectEnd = Math.min(c.p + c.d.length, otherC.p + otherC.d.length);
-        const cIntersect = c.d.slice(intersectStart - c.p, intersectEnd - c.p);
-        const otherIntersect = otherC.d.slice(intersectStart - otherC.p, intersectEnd - otherC.p);
-        if (cIntersect !== otherIntersect) { throw new Error('Delete ops delete different text in the same region of the document'); }
+        const intersectStart = Math.max(c.p, otherC.p)
+        const intersectEnd = Math.min(
+          c.p + c.d.length,
+          otherC.p + otherC.d.length
+        )
+        const cIntersect = c.d.slice(intersectStart - c.p, intersectEnd - c.p)
+        const otherIntersect = otherC.d.slice(
+          intersectStart - otherC.p,
+          intersectEnd - otherC.p
+        )
+        if (cIntersect !== otherIntersect) {
+          throw new Error(
+            'Delete ops delete different text in the same region of the document'
+          )
+        }
 
         if (newC.d !== '') {
           // This could be rewritten similarly to insert v delete, above.
-          newC.p = transformPosition(newC.p, otherC);
-          append(dest, newC);
+          newC.p = transformPosition(newC.p, otherC)
+          append(dest, newC)
         }
       }
     }
   }
-  
-  return dest;
-});
 
-const invertComponent = function(c) {
+  return dest
+}
+
+const invertComponent = function (c) {
   if (c.i != null) {
-    return {d:c.i, p:c.p};
+    return { d: c.i, p: c.p }
   } else {
-    return {i:c.d, p:c.p};
+    return { i: c.d, p: c.p }
   }
-};
+}
 
 // No need to use append for invert, because the components won't be able to
 // cancel with one another.
-text.invert = op => Array.from(op.slice().reverse()).map((c) => invertComponent(c));
-
+text.invert = (op) =>
+  Array.from(op.slice().reverse()).map((c) => invertComponent(c))
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  if (!exports.types) { exports.types = {}; }
+  if (!exports.types) {
+    exports.types = {}
+  }
 
   // This is kind of awful - come up with a better way to hook this helper code up.
-  bootstrapTransform(text, transformComponent, checkValidOp, append);
+  bootstrapTransform(text, transformComponent, checkValidOp, append)
 
   // [] is used to prevent closure from renaming types.text
-  exports.types.text = text;
+  exports.types.text = text
 } else {
-  module.exports = text;
+  module.exports = text
 
   // The text type really shouldn't need this - it should be possible to define
   // an efficient transform function by making a sort of transform map and passing each
   // op component through it.
-  require('./helpers').bootstrapTransform(text, transformComponent, checkValidOp, append);
+  require('./helpers').bootstrapTransform(
+    text,
+    transformComponent,
+    checkValidOp,
+    append
+  )
 }
-

+ 22 - 17
services/document-updater/app/js/sharejs/types/count.js

@@ -8,25 +8,30 @@
  */
 // This is a simple type used for testing other OT code. Each op is [expectedSnapshot, increment]
 
-exports.name = 'count';
-exports.create = () => 1;
+exports.name = 'count'
+exports.create = () => 1
 
-exports.apply = function(snapshot, op) {
-  const [v, inc] = Array.from(op);
-  if (snapshot !== v) { throw new Error(`Op ${v} != snapshot ${snapshot}`); }
-  return snapshot + inc;
-};
+exports.apply = function (snapshot, op) {
+  const [v, inc] = Array.from(op)
+  if (snapshot !== v) {
+    throw new Error(`Op ${v} != snapshot ${snapshot}`)
+  }
+  return snapshot + inc
+}
 
 // transform op1 by op2. Return transformed version of op1.
-exports.transform = function(op1, op2) {
-  if (op1[0] !== op2[0]) { throw new Error(`Op1 ${op1[0]} != op2 ${op2[0]}`); }
-  return [op1[0] + op2[1], op1[1]];
-};
+exports.transform = function (op1, op2) {
+  if (op1[0] !== op2[0]) {
+    throw new Error(`Op1 ${op1[0]} != op2 ${op2[0]}`)
+  }
+  return [op1[0] + op2[1], op1[1]]
+}
 
-exports.compose = function(op1, op2) {
-  if ((op1[0] + op1[1]) !== op2[0]) { throw new Error(`Op1 ${op1} + 1 != op2 ${op2}`); }
-  return [op1[0], op1[1] + op2[1]];
-};
-
-exports.generateRandomOp = doc => [[doc, 1], doc + 1];
+exports.compose = function (op1, op2) {
+  if (op1[0] + op1[1] !== op2[0]) {
+    throw new Error(`Op1 ${op1} + 1 != op2 ${op2}`)
+  }
+  return [op1[0], op1[1] + op2[1]]
+}
 
+exports.generateRandomOp = (doc) => [[doc, 1], doc + 1]

+ 65 - 46
services/document-updater/app/js/sharejs/types/helpers.js

@@ -17,77 +17,96 @@
 
 // Add transform and transformX functions for an OT type which has transformComponent defined.
 // transformComponent(destination array, component, other component, side)
-let bootstrapTransform;
-exports._bt = (bootstrapTransform = function(type, transformComponent, checkValidOp, append) {
-  let transformX;
-  const transformComponentX = function(left, right, destLeft, destRight) {
-    transformComponent(destLeft, left, right, 'left');
-    return transformComponent(destRight, right, left, 'right');
-  };
+let bootstrapTransform
+exports._bt = bootstrapTransform = function (
+  type,
+  transformComponent,
+  checkValidOp,
+  append
+) {
+  let transformX
+  const transformComponentX = function (left, right, destLeft, destRight) {
+    transformComponent(destLeft, left, right, 'left')
+    return transformComponent(destRight, right, left, 'right')
+  }
 
   // Transforms rightOp by leftOp. Returns ['rightOp', clientOp']
-  type.transformX = (type.transformX = (transformX = function(leftOp, rightOp) {
-    checkValidOp(leftOp);
-    checkValidOp(rightOp);
+  type.transformX = type.transformX = transformX = function (leftOp, rightOp) {
+    checkValidOp(leftOp)
+    checkValidOp(rightOp)
 
-    const newRightOp = [];
+    const newRightOp = []
 
     for (let rightComponent of Array.from(rightOp)) {
       // Generate newLeftOp by composing leftOp by rightComponent
-      const newLeftOp = [];
+      const newLeftOp = []
 
-      let k = 0;
+      let k = 0
       while (k < leftOp.length) {
-        var l;
-        const nextC = [];
-        transformComponentX(leftOp[k], rightComponent, newLeftOp, nextC);
-        k++;
+        var l
+        const nextC = []
+        transformComponentX(leftOp[k], rightComponent, newLeftOp, nextC)
+        k++
 
         if (nextC.length === 1) {
-          rightComponent = nextC[0];
+          rightComponent = nextC[0]
         } else if (nextC.length === 0) {
-          for (l of Array.from(leftOp.slice(k))) { append(newLeftOp, l); }
-          rightComponent = null;
-          break;
+          for (l of Array.from(leftOp.slice(k))) {
+            append(newLeftOp, l)
+          }
+          rightComponent = null
+          break
         } else {
           // Recurse.
-          const [l_, r_] = Array.from(transformX(leftOp.slice(k), nextC));
-          for (l of Array.from(l_)) { append(newLeftOp, l); }
-          for (const r of Array.from(r_)) { append(newRightOp, r); }
-          rightComponent = null;
-          break;
+          const [l_, r_] = Array.from(transformX(leftOp.slice(k), nextC))
+          for (l of Array.from(l_)) {
+            append(newLeftOp, l)
+          }
+          for (const r of Array.from(r_)) {
+            append(newRightOp, r)
+          }
+          rightComponent = null
+          break
         }
       }
-    
-      if (rightComponent != null) { append(newRightOp, rightComponent); }
-      leftOp = newLeftOp;
+
+      if (rightComponent != null) {
+        append(newRightOp, rightComponent)
+      }
+      leftOp = newLeftOp
     }
-    
-    return [leftOp, newRightOp];
-  }));
+
+    return [leftOp, newRightOp]
+  }
 
   // Transforms op with specified type ('left' or 'right') by otherOp.
-  return type.transform = (type.transform = function(op, otherOp, type) {
-    let _;
-    if ((type !== 'left') && (type !== 'right')) { throw new Error("type must be 'left' or 'right'"); }
+  return (type.transform = type.transform = function (op, otherOp, type) {
+    let _
+    if (type !== 'left' && type !== 'right') {
+      throw new Error("type must be 'left' or 'right'")
+    }
 
-    if (otherOp.length === 0) { return op; }
+    if (otherOp.length === 0) {
+      return op
+    }
 
     // TODO: Benchmark with and without this line. I _think_ it'll make a big difference...?
-    if ((op.length === 1) && (otherOp.length === 1)) { return transformComponent([], op[0], otherOp[0], type); }
+    if (op.length === 1 && otherOp.length === 1) {
+      return transformComponent([], op[0], otherOp[0], type)
+    }
 
     if (type === 'left') {
-      let left;
-      [left, _] = Array.from(transformX(op, otherOp));
-      return left;
+      let left
+      ;[left, _] = Array.from(transformX(op, otherOp))
+      return left
     } else {
-      let right;
-      [_, right] = Array.from(transformX(otherOp, op));
-      return right;
+      let right
+      ;[_, right] = Array.from(transformX(otherOp, op))
+      return right
     }
-  });
-});
+  })
+}
 
 if (typeof WEB === 'undefined') {
-  exports.bootstrapTransform = bootstrapTransform;
+  exports.bootstrapTransform = bootstrapTransform
 }

+ 13 - 11
services/document-updater/app/js/sharejs/types/index.js

@@ -6,18 +6,20 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 
-const register = function(file) {
-  const type = require(file);
-  exports[type.name] = type;
-  try { return require(`${file}-api`); } catch (error) {}
-};
+const register = function (file) {
+  const type = require(file)
+  exports[type.name] = type
+  try {
+    return require(`${file}-api`)
+  } catch (error) {}
+}
 
 // Import all the built-in types.
-register('./simple');
-register('./count');
+register('./simple')
+register('./count')
 
-register('./text');
-register('./text-composable');
-register('./text-tp2');
+register('./text')
+register('./text-composable')
+register('./text-tp2')
 
-register('./json');
+register('./json')

+ 257 - 180
services/document-updater/app/js/sharejs/types/json-api.js

@@ -14,267 +14,344 @@
  */
 // API for JSON OT
 
-let json;
-if (typeof WEB === 'undefined') { json = require('./json'); }
+let json
+if (typeof WEB === 'undefined') {
+  json = require('./json')
+}
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  const {
-    extendDoc
-  } = exports;
-  exports.extendDoc = function(name, fn) {
-    SubDoc.prototype[name] = fn;
-    return extendDoc(name, fn);
-  };
+  const { extendDoc } = exports
+  exports.extendDoc = function (name, fn) {
+    SubDoc.prototype[name] = fn
+    return extendDoc(name, fn)
+  }
 }
 
-const depath = function(path) {
-  if ((path.length === 1) && (path[0].constructor === Array)) {
-    return path[0];
-  } else { return path; }
-};
+const depath = function (path) {
+  if (path.length === 1 && path[0].constructor === Array) {
+    return path[0]
+  } else {
+    return path
+  }
+}
 
 class SubDoc {
   constructor(doc, path) {
-    this.doc = doc;
-    this.path = path;
+    this.doc = doc
+    this.path = path
+  }
+
+  at(...path) {
+    return this.doc.at(this.path.concat(depath(path)))
+  }
+
+  get() {
+    return this.doc.getAt(this.path)
   }
 
-  at(...path) { return this.doc.at(this.path.concat(depath(path))); }
-  get() { return this.doc.getAt(this.path); }
   // for objects and lists
-  set(value, cb) { return this.doc.setAt(this.path, value, cb); }
+  set(value, cb) {
+    return this.doc.setAt(this.path, value, cb)
+  }
+
   // for strings and lists.
-  insert(pos, value, cb) { return this.doc.insertAt(this.path, pos, value, cb); }
+  insert(pos, value, cb) {
+    return this.doc.insertAt(this.path, pos, value, cb)
+  }
+
   // for strings
-  del(pos, length, cb) { return this.doc.deleteTextAt(this.path, length, pos, cb); }
+  del(pos, length, cb) {
+    return this.doc.deleteTextAt(this.path, length, pos, cb)
+  }
+
   // for objects and lists
-  remove(cb) { return this.doc.removeAt(this.path, cb); }
-  push(value, cb) { return this.insert(this.get().length, value, cb); }
-  move(from, to, cb) { return this.doc.moveAt(this.path, from, to, cb); }
-  add(amount, cb) { return this.doc.addAt(this.path, amount, cb); }
-  on(event, cb) { return this.doc.addListener(this.path, event, cb); }
-  removeListener(l) { return this.doc.removeListener(l); }
+  remove(cb) {
+    return this.doc.removeAt(this.path, cb)
+  }
+
+  push(value, cb) {
+    return this.insert(this.get().length, value, cb)
+  }
+
+  move(from, to, cb) {
+    return this.doc.moveAt(this.path, from, to, cb)
+  }
+
+  add(amount, cb) {
+    return this.doc.addAt(this.path, amount, cb)
+  }
+
+  on(event, cb) {
+    return this.doc.addListener(this.path, event, cb)
+  }
+
+  removeListener(l) {
+    return this.doc.removeListener(l)
+  }
 
   // text API compatibility
-  getLength() { return this.get().length; }
-  getText() { return this.get(); }
+  getLength() {
+    return this.get().length
+  }
+
+  getText() {
+    return this.get()
+  }
 }
 
-const traverse = function(snapshot, path) {
-  const container = {data:snapshot};
-  let key = 'data';
-  let elem = container;
+const traverse = function (snapshot, path) {
+  const container = { data: snapshot }
+  let key = 'data'
+  let elem = container
   for (const p of Array.from(path)) {
-    elem = elem[key];
-    key = p;
-    if (typeof elem === 'undefined') { throw new Error('bad path'); }
+    elem = elem[key]
+    key = p
+    if (typeof elem === 'undefined') {
+      throw new Error('bad path')
+    }
   }
-  return {elem, key};
-};
+  return { elem, key }
+}
 
-const pathEquals = function(p1, p2) {
-  if (p1.length !== p2.length) { return false; }
+const pathEquals = function (p1, p2) {
+  if (p1.length !== p2.length) {
+    return false
+  }
   for (let i = 0; i < p1.length; i++) {
-    const e = p1[i];
-    if (e !== p2[i]) { return false; }
+    const e = p1[i]
+    if (e !== p2[i]) {
+      return false
+    }
   }
-  return true;
-};
+  return true
+}
 
 json.api = {
-  provides: {json:true},
+  provides: { json: true },
 
-  at(...path) { return new SubDoc(this, depath(path)); },
+  at(...path) {
+    return new SubDoc(this, depath(path))
+  },
 
-  get() { return this.snapshot; },
-  set(value, cb) { return this.setAt([], value, cb); },
+  get() {
+    return this.snapshot
+  },
+  set(value, cb) {
+    return this.setAt([], value, cb)
+  },
 
   getAt(path) {
-    const {elem, key} = traverse(this.snapshot, path);
-    return elem[key];
+    const { elem, key } = traverse(this.snapshot, path)
+    return elem[key]
   },
 
   setAt(path, value, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    const op = {p:path};
+    const { elem, key } = traverse(this.snapshot, path)
+    const op = { p: path }
     if (elem.constructor === Array) {
-      op.li = value;
-      if (typeof elem[key] !== 'undefined') { op.ld = elem[key]; }
+      op.li = value
+      if (typeof elem[key] !== 'undefined') {
+        op.ld = elem[key]
+      }
     } else if (typeof elem === 'object') {
-      op.oi = value;
-      if (typeof elem[key] !== 'undefined') { op.od = elem[key]; }
-    } else { throw new Error('bad path'); }
-    return this.submitOp([op], cb);
+      op.oi = value
+      if (typeof elem[key] !== 'undefined') {
+        op.od = elem[key]
+      }
+    } else {
+      throw new Error('bad path')
+    }
+    return this.submitOp([op], cb)
   },
 
   removeAt(path, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    if (typeof elem[key] === 'undefined') { throw new Error('no element at that path'); }
-    const op = {p:path};
+    const { elem, key } = traverse(this.snapshot, path)
+    if (typeof elem[key] === 'undefined') {
+      throw new Error('no element at that path')
+    }
+    const op = { p: path }
     if (elem.constructor === Array) {
-      op.ld = elem[key];
+      op.ld = elem[key]
     } else if (typeof elem === 'object') {
-      op.od = elem[key];
-    } else { throw new Error('bad path'); }
-    return this.submitOp([op], cb);
+      op.od = elem[key]
+    } else {
+      throw new Error('bad path')
+    }
+    return this.submitOp([op], cb)
   },
 
   insertAt(path, pos, value, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    const op = {p:path.concat(pos)};
+    const { elem, key } = traverse(this.snapshot, path)
+    const op = { p: path.concat(pos) }
     if (elem[key].constructor === Array) {
-      op.li = value;
+      op.li = value
     } else if (typeof elem[key] === 'string') {
-      op.si = value;
+      op.si = value
     }
-    return this.submitOp([op], cb);
+    return this.submitOp([op], cb)
   },
 
   moveAt(path, from, to, cb) {
-    const op = [{p:path.concat(from), lm:to}];
-    return this.submitOp(op, cb);
+    const op = [{ p: path.concat(from), lm: to }]
+    return this.submitOp(op, cb)
   },
 
   addAt(path, amount, cb) {
-    const op = [{p:path, na:amount}];
-    return this.submitOp(op, cb);
+    const op = [{ p: path, na: amount }]
+    return this.submitOp(op, cb)
   },
 
   deleteTextAt(path, length, pos, cb) {
-    const {elem, key} = traverse(this.snapshot, path);
-    const op = [{p:path.concat(pos), sd:elem[key].slice(pos, (pos + length))}];
-    return this.submitOp(op, cb);
+    const { elem, key } = traverse(this.snapshot, path)
+    const op = [{ p: path.concat(pos), sd: elem[key].slice(pos, pos + length) }]
+    return this.submitOp(op, cb)
   },
 
   addListener(path, event, cb) {
-    const l = {path, event, cb};
-    this._listeners.push(l);
-    return l;
+    const l = { path, event, cb }
+    this._listeners.push(l)
+    return l
   },
   removeListener(l) {
-    const i = this._listeners.indexOf(l);
-    if (i < 0) { return false; }
-    this._listeners.splice(i, 1);
-    return true;
+    const i = this._listeners.indexOf(l)
+    if (i < 0) {
+      return false
+    }
+    this._listeners.splice(i, 1)
+    return true
   },
   _register() {
-    this._listeners = [];
-    this.on('change', function(op) {
+    this._listeners = []
+    this.on('change', function (op) {
       return (() => {
-        const result = [];
+        const result = []
         for (const c of Array.from(op)) {
-          var i;
-          if ((c.na !== undefined) || (c.si !== undefined) || (c.sd !== undefined)) {
+          var i
+          if (c.na !== undefined || c.si !== undefined || c.sd !== undefined) {
             // no change to structure
-            continue;
+            continue
           }
-          var to_remove = [];
+          var to_remove = []
           for (i = 0; i < this._listeners.length; i++) {
             // Transform a dummy op by the incoming op to work out what
             // should happen to the listener.
-            const l = this._listeners[i];
-            const dummy = {p:l.path, na:0};
-            const xformed = this.type.transformComponent([], dummy, c, 'left');
+            const l = this._listeners[i]
+            const dummy = { p: l.path, na: 0 }
+            const xformed = this.type.transformComponent([], dummy, c, 'left')
             if (xformed.length === 0) {
               // The op was transformed to noop, so we should delete the listener.
-              to_remove.push(i);
+              to_remove.push(i)
             } else if (xformed.length === 1) {
               // The op remained, so grab its new path into the listener.
-              l.path = xformed[0].p;
+              l.path = xformed[0].p
             } else {
-              throw new Error("Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components.");
+              throw new Error(
+                "Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components."
+              )
             }
           }
-          to_remove.sort((a, b) => b - a);
-          result.push((() => {
-            const result1 = [];
-            for (i of Array.from(to_remove)) {
-              result1.push(this._listeners.splice(i, 1));
-            }
-            return result1;
-          })());
+          to_remove.sort((a, b) => b - a)
+          result.push(
+            (() => {
+              const result1 = []
+              for (i of Array.from(to_remove)) {
+                result1.push(this._listeners.splice(i, 1))
+              }
+              return result1
+            })()
+          )
         }
-        return result;
-      })();
-    });
-    return this.on('remoteop', function(op) {
+        return result
+      })()
+    })
+    return this.on('remoteop', function (op) {
       return (() => {
-        const result = [];
+        const result = []
         for (var c of Array.from(op)) {
-          var match_path = c.na === undefined ? c.p.slice(0, c.p.length-1) : c.p;
-          result.push((() => {
-            const result1 = [];
-            for (const {path, event, cb} of Array.from(this._listeners)) {
-              var common;
-              if (pathEquals(path, match_path)) {
-                switch (event) {
-                  case 'insert':
-                    if ((c.li !== undefined) && (c.ld === undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.li));
-                    } else if ((c.oi !== undefined) && (c.od === undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.oi));
-                    } else if (c.si !== undefined) {
-                      result1.push(cb(c.p[c.p.length-1], c.si));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'delete':
-                    if ((c.li === undefined) && (c.ld !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.ld));
-                    } else if ((c.oi === undefined) && (c.od !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.od));
-                    } else if (c.sd !== undefined) {
-                      result1.push(cb(c.p[c.p.length-1], c.sd));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'replace':
-                    if ((c.li !== undefined) && (c.ld !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.ld, c.li));
-                    } else if ((c.oi !== undefined) && (c.od !== undefined)) {
-                      result1.push(cb(c.p[c.p.length-1], c.od, c.oi));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'move':
-                    if (c.lm !== undefined) {
-                      result1.push(cb(c.p[c.p.length-1], c.lm));
-                    } else {
-                      result1.push(undefined);
-                    }
-                    break;
-                  case 'add':
-                    if (c.na !== undefined) {
-                      result1.push(cb(c.na));
-                    } else {
-                      result1.push(undefined);
+          var match_path =
+            c.na === undefined ? c.p.slice(0, c.p.length - 1) : c.p
+          result.push(
+            (() => {
+              const result1 = []
+              for (const { path, event, cb } of Array.from(this._listeners)) {
+                var common
+                if (pathEquals(path, match_path)) {
+                  switch (event) {
+                    case 'insert':
+                      if (c.li !== undefined && c.ld === undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.li))
+                      } else if (c.oi !== undefined && c.od === undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.oi))
+                      } else if (c.si !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.si))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'delete':
+                      if (c.li === undefined && c.ld !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.ld))
+                      } else if (c.oi === undefined && c.od !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.od))
+                      } else if (c.sd !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.sd))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'replace':
+                      if (c.li !== undefined && c.ld !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.ld, c.li))
+                      } else if (c.oi !== undefined && c.od !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.od, c.oi))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'move':
+                      if (c.lm !== undefined) {
+                        result1.push(cb(c.p[c.p.length - 1], c.lm))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    case 'add':
+                      if (c.na !== undefined) {
+                        result1.push(cb(c.na))
+                      } else {
+                        result1.push(undefined)
+                      }
+                      break
+                    default:
+                      result1.push(undefined)
+                  }
+                } else if (
+                  (common = this.type.commonPath(match_path, path)) != null
+                ) {
+                  if (event === 'child op') {
+                    if (
+                      match_path.length === path.length &&
+                      path.length === common
+                    ) {
+                      throw new Error(
+                        "paths match length and have commonality, but aren't equal?"
+                      )
                     }
-                    break;
-                  default:
-                    result1.push(undefined);
-                }
-              } else if ((common = this.type.commonPath(match_path, path)) != null) {
-                if (event === 'child op') {
-                  if (match_path.length === path.length && path.length === common) {
-                    throw new Error("paths match length and have commonality, but aren't equal?");
+                    const child_path = c.p.slice(common + 1)
+                    result1.push(cb(child_path, c))
+                  } else {
+                    result1.push(undefined)
                   }
-                  const child_path = c.p.slice(common+1);
-                  result1.push(cb(child_path, c));
                 } else {
-                  result1.push(undefined);
+                  result1.push(undefined)
                 }
-              } else {
-                result1.push(undefined);
               }
-            }
-            return result1;
-          })());
+              return result1
+            })()
+          )
         }
-        return result;
-      })();
-    });
+        return result
+      })()
+    })
   }
-};
+}

+ 347 - 258
services/document-updater/app/js/sharejs/types/json.js

@@ -16,382 +16,459 @@
 //
 // Spec is here: https://github.com/josephg/ShareJS/wiki/JSON-Operations
 
-let text;
+let text
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  ({
-    text
-  } = exports.types);
+  ;({ text } = exports.types)
 } else {
-  text = require('./text');
+  text = require('./text')
 }
 
-const json = {};
+const json = {}
 
-json.name = 'json';
+json.name = 'json'
 
-json.create = () => null;
+json.create = () => null
 
-json.invertComponent = function(c) {
-  const c_ = {p: c.p};
-  if (c.si !== undefined) { c_.sd = c.si; }
-  if (c.sd !== undefined) { c_.si = c.sd; }
-  if (c.oi !== undefined) { c_.od = c.oi; }
-  if (c.od !== undefined) { c_.oi = c.od; }
-  if (c.li !== undefined) { c_.ld = c.li; }
-  if (c.ld !== undefined) { c_.li = c.ld; }
-  if (c.na !== undefined) { c_.na = -c.na; }
+json.invertComponent = function (c) {
+  const c_ = { p: c.p }
+  if (c.si !== undefined) {
+    c_.sd = c.si
+  }
+  if (c.sd !== undefined) {
+    c_.si = c.sd
+  }
+  if (c.oi !== undefined) {
+    c_.od = c.oi
+  }
+  if (c.od !== undefined) {
+    c_.oi = c.od
+  }
+  if (c.li !== undefined) {
+    c_.ld = c.li
+  }
+  if (c.ld !== undefined) {
+    c_.li = c.ld
+  }
+  if (c.na !== undefined) {
+    c_.na = -c.na
+  }
   if (c.lm !== undefined) {
-    c_.lm = c.p[c.p.length-1];
-    c_.p = c.p.slice(0, c.p.length - 1).concat([c.lm]);
+    c_.lm = c.p[c.p.length - 1]
+    c_.p = c.p.slice(0, c.p.length - 1).concat([c.lm])
   }
-  return c_;
-};
+  return c_
+}
 
-json.invert = op => Array.from(op.slice().reverse()).map((c) => json.invertComponent(c));
+json.invert = (op) =>
+  Array.from(op.slice().reverse()).map((c) => json.invertComponent(c))
 
-json.checkValidOp = function(op) {};
+json.checkValidOp = function (op) {}
 
-const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
-json.checkList = function(elem) {
-  if (!isArray(elem)) { throw new Error('Referenced element not a list'); }
-};
+const isArray = (o) => Object.prototype.toString.call(o) === '[object Array]'
+json.checkList = function (elem) {
+  if (!isArray(elem)) {
+    throw new Error('Referenced element not a list')
+  }
+}
 
-json.checkObj = function(elem) {
-  if (elem.constructor !== Object) { throw new Error(`Referenced element not an object (it was ${JSON.stringify(elem)})`); }
-};
+json.checkObj = function (elem) {
+  if (elem.constructor !== Object) {
+    throw new Error(
+      `Referenced element not an object (it was ${JSON.stringify(elem)})`
+    )
+  }
+}
 
-json.apply = function(snapshot, op) {
-  json.checkValidOp(op);
-  op = clone(op);
+json.apply = function (snapshot, op) {
+  json.checkValidOp(op)
+  op = clone(op)
 
-  const container = {data: clone(snapshot)};
+  const container = { data: clone(snapshot) }
 
   try {
     for (let i = 0; i < op.length; i++) {
-      const c = op[i];
-      let parent = null;
-      let parentkey = null;
-      let elem = container;
-      let key = 'data';
+      const c = op[i]
+      let parent = null
+      let parentkey = null
+      let elem = container
+      let key = 'data'
 
       for (const p of Array.from(c.p)) {
-        parent = elem;
-        parentkey = key;
-        elem = elem[key];
-        key = p;
+        parent = elem
+        parentkey = key
+        elem = elem[key]
+        key = p
 
-        if (parent == null) { throw new Error('Path invalid'); }
+        if (parent == null) {
+          throw new Error('Path invalid')
+        }
       }
 
       if (c.na !== undefined) {
         // Number add
-        if (typeof elem[key] !== 'number') { throw new Error('Referenced element not a number'); }
-        elem[key] += c.na;
-
+        if (typeof elem[key] !== 'number') {
+          throw new Error('Referenced element not a number')
+        }
+        elem[key] += c.na
       } else if (c.si !== undefined) {
         // String insert
-        if (typeof elem !== 'string') { throw new Error(`Referenced element not a string (it was ${JSON.stringify(elem)})`); }
-        parent[parentkey] = elem.slice(0, key) + c.si + elem.slice(key);
+        if (typeof elem !== 'string') {
+          throw new Error(
+            `Referenced element not a string (it was ${JSON.stringify(elem)})`
+          )
+        }
+        parent[parentkey] = elem.slice(0, key) + c.si + elem.slice(key)
       } else if (c.sd !== undefined) {
         // String delete
-        if (typeof elem !== 'string') { throw new Error('Referenced element not a string'); }
-        if (elem.slice(key, key + c.sd.length) !== c.sd) { throw new Error('Deleted string does not match'); }
-        parent[parentkey] = elem.slice(0, key) + elem.slice(key + c.sd.length);
-
-      } else if ((c.li !== undefined) && (c.ld !== undefined)) {
+        if (typeof elem !== 'string') {
+          throw new Error('Referenced element not a string')
+        }
+        if (elem.slice(key, key + c.sd.length) !== c.sd) {
+          throw new Error('Deleted string does not match')
+        }
+        parent[parentkey] = elem.slice(0, key) + elem.slice(key + c.sd.length)
+      } else if (c.li !== undefined && c.ld !== undefined) {
         // List replace
-        json.checkList(elem);
+        json.checkList(elem)
 
         // Should check the list element matches c.ld
-        elem[key] = c.li;
+        elem[key] = c.li
       } else if (c.li !== undefined) {
         // List insert
-        json.checkList(elem);
+        json.checkList(elem)
 
-        elem.splice(key, 0, c.li);
+        elem.splice(key, 0, c.li)
       } else if (c.ld !== undefined) {
         // List delete
-        json.checkList(elem);
+        json.checkList(elem)
 
         // Should check the list element matches c.ld here too.
-        elem.splice(key, 1);
+        elem.splice(key, 1)
       } else if (c.lm !== undefined) {
         // List move
-        json.checkList(elem);
+        json.checkList(elem)
         if (c.lm !== key) {
-          const e = elem[key];
+          const e = elem[key]
           // Remove it...
-          elem.splice(key, 1);
+          elem.splice(key, 1)
           // And insert it back.
-          elem.splice(c.lm, 0, e);
+          elem.splice(c.lm, 0, e)
         }
-
       } else if (c.oi !== undefined) {
         // Object insert / replace
-        json.checkObj(elem);
-        
+        json.checkObj(elem)
+
         // Should check that elem[key] == c.od
-        elem[key] = c.oi;
+        elem[key] = c.oi
       } else if (c.od !== undefined) {
         // Object delete
-        json.checkObj(elem);
+        json.checkObj(elem)
 
         // Should check that elem[key] == c.od
-        delete elem[key];
+        delete elem[key]
       } else {
-        throw new Error('invalid / missing instruction in op');
+        throw new Error('invalid / missing instruction in op')
       }
     }
   } catch (error) {
     // TODO: Roll back all already applied changes. Write tests before implementing this code.
-    throw error;
+    throw error
   }
 
-  return container.data;
-};
+  return container.data
+}
 
 // Checks if two paths, p1 and p2 match.
-json.pathMatches = function(p1, p2, ignoreLast) {
-  if (p1.length !== p2.length) { return false; }
+json.pathMatches = function (p1, p2, ignoreLast) {
+  if (p1.length !== p2.length) {
+    return false
+  }
 
   for (let i = 0; i < p1.length; i++) {
-    const p = p1[i];
-    if ((p !== p2[i]) && (!ignoreLast || (i !== (p1.length - 1)))) { return false; }
+    const p = p1[i]
+    if (p !== p2[i] && (!ignoreLast || i !== p1.length - 1)) {
+      return false
+    }
   }
-      
-  return true;
-};
-
-json.append = function(dest, c) {
-  let last;
-  c = clone(c);
-  if ((dest.length !== 0) && json.pathMatches(c.p, (last = dest[dest.length - 1]).p)) {
-    if ((last.na !== undefined) && (c.na !== undefined)) {
-      return dest[dest.length - 1] = { p: last.p, na: last.na + c.na };
-    } else if ((last.li !== undefined) && (c.li === undefined) && (c.ld === last.li)) {
+
+  return true
+}
+
+json.append = function (dest, c) {
+  let last
+  c = clone(c)
+  if (
+    dest.length !== 0 &&
+    json.pathMatches(c.p, (last = dest[dest.length - 1]).p)
+  ) {
+    if (last.na !== undefined && c.na !== undefined) {
+      return (dest[dest.length - 1] = { p: last.p, na: last.na + c.na })
+    } else if (
+      last.li !== undefined &&
+      c.li === undefined &&
+      c.ld === last.li
+    ) {
       // insert immediately followed by delete becomes a noop.
       if (last.ld !== undefined) {
         // leave the delete part of the replace
-        return delete last.li;
+        return delete last.li
       } else {
-        return dest.pop();
+        return dest.pop()
       }
-    } else if ((last.od !== undefined) && (last.oi === undefined) &&
-        (c.oi !== undefined) && (c.od === undefined)) {
-      return last.oi = c.oi;
-    } else if ((c.lm !== undefined) && (c.p[c.p.length-1] === c.lm)) {
-      return null; // don't do anything
+    } else if (
+      last.od !== undefined &&
+      last.oi === undefined &&
+      c.oi !== undefined &&
+      c.od === undefined
+    ) {
+      return (last.oi = c.oi)
+    } else if (c.lm !== undefined && c.p[c.p.length - 1] === c.lm) {
+      return null // don't do anything
     } else {
-      return dest.push(c);
+      return dest.push(c)
     }
   } else {
-    return dest.push(c);
+    return dest.push(c)
   }
-};
+}
 
-json.compose = function(op1, op2) {
-  json.checkValidOp(op1);
-  json.checkValidOp(op2);
+json.compose = function (op1, op2) {
+  json.checkValidOp(op1)
+  json.checkValidOp(op2)
 
-  const newOp = clone(op1);
-  for (const c of Array.from(op2)) { json.append(newOp, c); }
+  const newOp = clone(op1)
+  for (const c of Array.from(op2)) {
+    json.append(newOp, c)
+  }
+
+  return newOp
+}
 
-  return newOp;
-};
+json.normalize = function (op) {
+  const newOp = []
 
-json.normalize = function(op) {
-  const newOp = [];
-  
-  if (!isArray(op)) { op = [op]; }
+  if (!isArray(op)) {
+    op = [op]
+  }
 
   for (const c of Array.from(op)) {
-    if (c.p == null) { c.p = []; }
-    json.append(newOp, c);
+    if (c.p == null) {
+      c.p = []
+    }
+    json.append(newOp, c)
   }
-  
-  return newOp;
-};
+
+  return newOp
+}
 
 // hax, copied from test/types/json. Apparently this is still the fastest way to deep clone an object, assuming
 // we have browser support for JSON.
 // http://jsperf.com/cloning-an-object/12
-var clone = o => JSON.parse(JSON.stringify(o));
-
-json.commonPath = function(p1, p2) {
-  p1 = p1.slice();
-  p2 = p2.slice();
-  p1.unshift('data');
-  p2.unshift('data');
-  p1 = p1.slice(0, p1.length-1);
-  p2 = p2.slice(0, p2.length-1);
-  if (p2.length === 0) { return -1; }
-  let i = 0;
-  while ((p1[i] === p2[i]) && (i < p1.length)) {
-    i++;
+var clone = (o) => JSON.parse(JSON.stringify(o))
+
+json.commonPath = function (p1, p2) {
+  p1 = p1.slice()
+  p2 = p2.slice()
+  p1.unshift('data')
+  p2.unshift('data')
+  p1 = p1.slice(0, p1.length - 1)
+  p2 = p2.slice(0, p2.length - 1)
+  if (p2.length === 0) {
+    return -1
+  }
+  let i = 0
+  while (p1[i] === p2[i] && i < p1.length) {
+    i++
     if (i === p2.length) {
-      return i-1;
+      return i - 1
     }
   }
-};
+}
 
 // transform c so it applies to a document with otherC applied.
-json.transformComponent = function(dest, c, otherC, type) {
-  let oc;
-  c = clone(c);
-  if (c.na !== undefined) { c.p.push(0); }
-  if (otherC.na !== undefined) { otherC.p.push(0); }
+json.transformComponent = function (dest, c, otherC, type) {
+  let oc
+  c = clone(c)
+  if (c.na !== undefined) {
+    c.p.push(0)
+  }
+  if (otherC.na !== undefined) {
+    otherC.p.push(0)
+  }
 
-  const common = json.commonPath(c.p, otherC.p);
-  const common2 = json.commonPath(otherC.p, c.p);
+  const common = json.commonPath(c.p, otherC.p)
+  const common2 = json.commonPath(otherC.p, c.p)
 
-  const cplength = c.p.length;
-  const otherCplength = otherC.p.length;
+  const cplength = c.p.length
+  const otherCplength = otherC.p.length
 
-  if (c.na !== undefined) { c.p.pop(); } // hax
-  if (otherC.na !== undefined) { otherC.p.pop(); }
+  if (c.na !== undefined) {
+    c.p.pop()
+  } // hax
+  if (otherC.na !== undefined) {
+    otherC.p.pop()
+  }
 
   if (otherC.na) {
-    if ((common2 != null) && (otherCplength >= cplength) && (otherC.p[common2] === c.p[common2])) {
+    if (
+      common2 != null &&
+      otherCplength >= cplength &&
+      otherC.p[common2] === c.p[common2]
+    ) {
       if (c.ld !== undefined) {
-        oc = clone(otherC);
-        oc.p = oc.p.slice(cplength);
-        c.ld = json.apply(clone(c.ld), [oc]);
+        oc = clone(otherC)
+        oc.p = oc.p.slice(cplength)
+        c.ld = json.apply(clone(c.ld), [oc])
       } else if (c.od !== undefined) {
-        oc = clone(otherC);
-        oc.p = oc.p.slice(cplength);
-        c.od = json.apply(clone(c.od), [oc]);
+        oc = clone(otherC)
+        oc.p = oc.p.slice(cplength)
+        c.od = json.apply(clone(c.od), [oc])
       }
     }
-    json.append(dest, c);
-    return dest;
+    json.append(dest, c)
+    return dest
   }
 
-  if ((common2 != null) && (otherCplength > cplength) && (c.p[common2] === otherC.p[common2])) {
+  if (
+    common2 != null &&
+    otherCplength > cplength &&
+    c.p[common2] === otherC.p[common2]
+  ) {
     // transform based on c
     if (c.ld !== undefined) {
-      oc = clone(otherC);
-      oc.p = oc.p.slice(cplength);
-      c.ld = json.apply(clone(c.ld), [oc]);
+      oc = clone(otherC)
+      oc.p = oc.p.slice(cplength)
+      c.ld = json.apply(clone(c.ld), [oc])
     } else if (c.od !== undefined) {
-      oc = clone(otherC);
-      oc.p = oc.p.slice(cplength);
-      c.od = json.apply(clone(c.od), [oc]);
+      oc = clone(otherC)
+      oc.p = oc.p.slice(cplength)
+      c.od = json.apply(clone(c.od), [oc])
     }
   }
 
-
   if (common != null) {
-    let from, p, to;
-    const commonOperand = cplength === otherCplength;
+    let from, p, to
+    const commonOperand = cplength === otherCplength
     // transform based on otherC
     if (otherC.na !== undefined) {
       // this case is handled above due to icky path hax
-    } else if ((otherC.si !== undefined) || (otherC.sd !== undefined)) {
+    } else if (otherC.si !== undefined || otherC.sd !== undefined) {
       // String op vs string op - pass through to text type
-      if ((c.si !== undefined) || (c.sd !== undefined)) {
-        if (!commonOperand) { throw new Error("must be a string?"); }
+      if (c.si !== undefined || c.sd !== undefined) {
+        if (!commonOperand) {
+          throw new Error('must be a string?')
+        }
 
         // Convert an op component to a text op component
-        const convert = function(component) {
-          const newC = {p:component.p[component.p.length - 1]};
+        const convert = function (component) {
+          const newC = { p: component.p[component.p.length - 1] }
           if (component.si) {
-            newC.i = component.si;
+            newC.i = component.si
           } else {
-            newC.d = component.sd;
+            newC.d = component.sd
           }
-          return newC;
-        };
-
-        const tc1 = convert(c);
-        const tc2 = convert(otherC);
-          
-        const res = [];
-        text._tc(res, tc1, tc2, type);
+          return newC
+        }
+
+        const tc1 = convert(c)
+        const tc2 = convert(otherC)
+
+        const res = []
+        text._tc(res, tc1, tc2, type)
         for (const tc of Array.from(res)) {
-          const jc = { p: c.p.slice(0, common) };
-          jc.p.push(tc.p);
-          if (tc.i != null) { jc.si = tc.i; }
-          if (tc.d != null) { jc.sd = tc.d; }
-          json.append(dest, jc);
+          const jc = { p: c.p.slice(0, common) }
+          jc.p.push(tc.p)
+          if (tc.i != null) {
+            jc.si = tc.i
+          }
+          if (tc.d != null) {
+            jc.sd = tc.d
+          }
+          json.append(dest, jc)
         }
-        return dest;
+        return dest
       }
-    } else if ((otherC.li !== undefined) && (otherC.ld !== undefined)) {
+    } else if (otherC.li !== undefined && otherC.ld !== undefined) {
       if (otherC.p[common] === c.p[common]) {
         // noop
         if (!commonOperand) {
           // we're below the deleted element, so -> noop
-          return dest;
+          return dest
         } else if (c.ld !== undefined) {
           // we're trying to delete the same element, -> noop
-          if ((c.li !== undefined) && (type === 'left')) {
+          if (c.li !== undefined && type === 'left') {
             // we're both replacing one element with another. only one can
             // survive!
-            c.ld = clone(otherC.li);
+            c.ld = clone(otherC.li)
           } else {
-            return dest;
+            return dest
           }
         }
       }
     } else if (otherC.li !== undefined) {
-      if ((c.li !== undefined) && (c.ld === undefined) && commonOperand && (c.p[common] === otherC.p[common])) {
+      if (
+        c.li !== undefined &&
+        c.ld === undefined &&
+        commonOperand &&
+        c.p[common] === otherC.p[common]
+      ) {
         // in li vs. li, left wins.
         if (type === 'right') {
-          c.p[common]++;
+          c.p[common]++
         }
       } else if (otherC.p[common] <= c.p[common]) {
-        c.p[common]++;
+        c.p[common]++
       }
 
       if (c.lm !== undefined) {
         if (commonOperand) {
           // otherC edits the same list we edit
           if (otherC.p[common] <= c.lm) {
-            c.lm++;
+            c.lm++
           }
         }
       }
-          // changing c.from is handled above.
+      // changing c.from is handled above.
     } else if (otherC.ld !== undefined) {
       if (c.lm !== undefined) {
         if (commonOperand) {
           if (otherC.p[common] === c.p[common]) {
             // they deleted the thing we're trying to move
-            return dest;
+            return dest
           }
           // otherC edits the same list we edit
-          p = otherC.p[common];
-          from = c.p[common];
-          to = c.lm;
-          if ((p < to) || ((p === to) && (from < to))) {
-            c.lm--;
+          p = otherC.p[common]
+          from = c.p[common]
+          to = c.lm
+          if (p < to || (p === to && from < to)) {
+            c.lm--
           }
         }
       }
 
       if (otherC.p[common] < c.p[common]) {
-        c.p[common]--;
+        c.p[common]--
       } else if (otherC.p[common] === c.p[common]) {
         if (otherCplength < cplength) {
           // we're below the deleted element, so -> noop
-          return dest;
+          return dest
         } else if (c.ld !== undefined) {
           if (c.li !== undefined) {
             // we're replacing, they're deleting. we become an insert.
-            delete c.ld;
+            delete c.ld
           } else {
             // we're trying to delete the same element, -> noop
-            return dest;
+            return dest
           }
         }
       }
     } else if (otherC.lm !== undefined) {
-      if ((c.lm !== undefined) && (cplength === otherCplength)) {
+      if (c.lm !== undefined && cplength === otherCplength) {
         // lm vs lm, here we go!
-        from = c.p[common];
-        to = c.lm;
-        const otherFrom = otherC.p[common];
-        const otherTo = otherC.lm;
+        from = c.p[common]
+        to = c.lm
+        const otherFrom = otherC.p[common]
+        const otherTo = otherC.lm
         if (otherFrom !== otherTo) {
           // if otherFrom == otherTo, we don't need to change our op.
 
@@ -399,143 +476,155 @@ json.transformComponent = function(dest, c, otherC, type) {
           if (from === otherFrom) {
             // they moved it! tie break.
             if (type === 'left') {
-              c.p[common] = otherTo;
-              if (from === to) { // ugh
-                c.lm = otherTo;
+              c.p[common] = otherTo
+              if (from === to) {
+                // ugh
+                c.lm = otherTo
               }
             } else {
-              return dest;
+              return dest
             }
           } else {
             // they moved around it
             if (from > otherFrom) {
-              c.p[common]--;
+              c.p[common]--
             }
             if (from > otherTo) {
-              c.p[common]++;
+              c.p[common]++
             } else if (from === otherTo) {
               if (otherFrom > otherTo) {
-                c.p[common]++;
-                if (from === to) { // ugh, again
-                  c.lm++;
+                c.p[common]++
+                if (from === to) {
+                  // ugh, again
+                  c.lm++
                 }
               }
             }
 
             // step 2: where am i going to put it?
             if (to > otherFrom) {
-              c.lm--;
+              c.lm--
             } else if (to === otherFrom) {
               if (to > from) {
-                c.lm--;
+                c.lm--
               }
             }
             if (to > otherTo) {
-              c.lm++;
+              c.lm++
             } else if (to === otherTo) {
               // if we're both moving in the same direction, tie break
-              if (((otherTo > otherFrom) && (to > from)) ||
-                 ((otherTo < otherFrom) && (to < from))) {
+              if (
+                (otherTo > otherFrom && to > from) ||
+                (otherTo < otherFrom && to < from)
+              ) {
                 if (type === 'right') {
-                  c.lm++;
+                  c.lm++
                 }
               } else {
                 if (to > from) {
-                  c.lm++;
+                  c.lm++
                 } else if (to === otherFrom) {
-                  c.lm--;
+                  c.lm--
                 }
               }
             }
           }
         }
-      } else if ((c.li !== undefined) && (c.ld === undefined) && commonOperand) {
+      } else if (c.li !== undefined && c.ld === undefined && commonOperand) {
         // li
-        from = otherC.p[common];
-        to = otherC.lm;
-        p = c.p[common];
+        from = otherC.p[common]
+        to = otherC.lm
+        p = c.p[common]
         if (p > from) {
-          c.p[common]--;
+          c.p[common]--
         }
         if (p > to) {
-          c.p[common]++;
+          c.p[common]++
         }
       } else {
         // ld, ld+li, si, sd, na, oi, od, oi+od, any li on an element beneath
         // the lm
         //
         // i.e. things care about where their item is after the move.
-        from = otherC.p[common];
-        to = otherC.lm;
-        p = c.p[common];
+        from = otherC.p[common]
+        to = otherC.lm
+        p = c.p[common]
         if (p === from) {
-          c.p[common] = to;
+          c.p[common] = to
         } else {
           if (p > from) {
-            c.p[common]--;
+            c.p[common]--
           }
           if (p > to) {
-            c.p[common]++;
+            c.p[common]++
           } else if (p === to) {
             if (from > to) {
-              c.p[common]++;
+              c.p[common]++
             }
           }
         }
       }
-    } else if ((otherC.oi !== undefined) && (otherC.od !== undefined)) {
+    } else if (otherC.oi !== undefined && otherC.od !== undefined) {
       if (c.p[common] === otherC.p[common]) {
-        if ((c.oi !== undefined) && commonOperand) {
+        if (c.oi !== undefined && commonOperand) {
           // we inserted where someone else replaced
           if (type === 'right') {
             // left wins
-            return dest;
+            return dest
           } else {
             // we win, make our op replace what they inserted
-            c.od = otherC.oi;
+            c.od = otherC.oi
           }
         } else {
           // -> noop if the other component is deleting the same object (or any
           // parent)
-          return dest;
+          return dest
         }
       }
     } else if (otherC.oi !== undefined) {
-      if ((c.oi !== undefined) && (c.p[common] === otherC.p[common])) {
+      if (c.oi !== undefined && c.p[common] === otherC.p[common]) {
         // left wins if we try to insert at the same place
         if (type === 'left') {
-          json.append(dest, {p:c.p, od:otherC.oi});
+          json.append(dest, { p: c.p, od: otherC.oi })
         } else {
-          return dest;
+          return dest
         }
       }
     } else if (otherC.od !== undefined) {
       if (c.p[common] === otherC.p[common]) {
-        if (!commonOperand) { return dest; }
+        if (!commonOperand) {
+          return dest
+        }
         if (c.oi !== undefined) {
-          delete c.od;
+          delete c.od
         } else {
-          return dest;
+          return dest
         }
       }
     }
   }
-  
-  json.append(dest, c);
-  return dest;
-};
+
+  json.append(dest, c)
+  return dest
+}
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  if (!exports.types) { exports.types = {}; }
+  if (!exports.types) {
+    exports.types = {}
+  }
 
   // This is kind of awful - come up with a better way to hook this helper code up.
-  exports._bt(json, json.transformComponent, json.checkValidOp, json.append);
+  exports._bt(json, json.transformComponent, json.checkValidOp, json.append)
 
   // [] is used to prevent closure from renaming types.text
-  exports.types.json = json;
+  exports.types.json = json
 } else {
-  module.exports = json;
-
-  require('./helpers').bootstrapTransform(json, json.transformComponent, json.checkValidOp, json.append);
+  module.exports = json
+
+  require('./helpers').bootstrapTransform(
+    json,
+    json.transformComponent,
+    json.checkValidOp,
+    json.append
+  )
 }
-

+ 504 - 325
services/document-updater/app/js/sharejs/types/model.js

@@ -21,13 +21,13 @@
 //
 // Actual storage is handled by the database wrappers in db/*, wrapped by DocCache
 
-let Model;
-const {EventEmitter} = require('events');
+let Model
+const { EventEmitter } = require('events')
 
-const queue = require('./syncqueue');
-const types = require('../types');
+const queue = require('./syncqueue')
+const types = require('../types')
 
-const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
+const isArray = (o) => Object.prototype.toString.call(o) === '[object Array]'
 
 // This constructor creates a new Model object. There will be one model object
 // per server context.
@@ -41,15 +41,19 @@ const isArray = o => Object.prototype.toString.call(o) === '[object Array]';
 // The model is an event emitter. It emits the following events:
 //
 // create(docName, data): A document has been created with the specified name & data
-module.exports = (Model = function(db, options) {
+module.exports = Model = function (db, options) {
   // db can be null if the user doesn't want persistance.
 
-  let getOps;
-  if (!(this instanceof Model)) { return new Model(db, options); }
+  let getOps
+  if (!(this instanceof Model)) {
+    return new Model(db, options)
+  }
 
-  const model = this;
+  const model = this
 
-  if (options == null) { options = {}; }
+  if (options == null) {
+    options = {}
+  }
 
   // This is a cache of 'live' documents.
   //
@@ -78,164 +82,210 @@ module.exports = (Model = function(db, options) {
   //
   // In any case, the API to model is designed such that if we want to change that later
   // it should be pretty easy to do so without any external-to-the-model code changes.
-  const docs = {};
+  const docs = {}
 
   // This is a map from docName -> [callback]. It is used when a document hasn't been
   // cached and multiple getSnapshot() / getVersion() requests come in. All requests
   // are added to the callback list and called when db.getSnapshot() returns.
   //
   // callback(error, snapshot data)
-  const awaitingGetSnapshot = {};
+  const awaitingGetSnapshot = {}
 
   // The time that documents which no clients have open will stay in the cache.
   // Should be > 0.
-  if (options.reapTime == null) { options.reapTime = 3000; }
+  if (options.reapTime == null) {
+    options.reapTime = 3000
+  }
 
   // The number of operations the cache holds before reusing the space
-  if (options.numCachedOps == null) { options.numCachedOps = 10; }
+  if (options.numCachedOps == null) {
+    options.numCachedOps = 10
+  }
 
   // This option forces documents to be reaped, even when there's no database backend.
   // This is useful when you don't care about persistance and don't want to gradually
   // fill memory.
   //
   // You might want to set reapTime to a day or something.
-  if (options.forceReaping == null) { options.forceReaping = false; }
+  if (options.forceReaping == null) {
+    options.forceReaping = false
+  }
 
   // Until I come up with a better strategy, we'll save a copy of the document snapshot
   // to the database every ~20 submitted ops.
-  if (options.opsBeforeCommit == null) { options.opsBeforeCommit = 20; }
+  if (options.opsBeforeCommit == null) {
+    options.opsBeforeCommit = 20
+  }
 
   // It takes some processing time to transform client ops. The server will punt ops back to the
   // client to transform if they're too old.
-  if (options.maximumAge == null) { options.maximumAge = 40; }
+  if (options.maximumAge == null) {
+    options.maximumAge = 40
+  }
 
   // **** Cache API methods
 
   // Its important that all ops are applied in order. This helper method creates the op submission queue
   // for a single document. This contains the logic for transforming & applying ops.
-  const makeOpQueue = (docName, doc) => queue(function(opData, callback) {
-    if (!(opData.v >= 0)) { return callback('Version missing'); }
-    if (opData.v > doc.v) { return callback('Op at future version'); }
-
-    // Punt the transforming work back to the client if the op is too old.
-    if ((opData.v + options.maximumAge) < doc.v) { return callback('Op too old'); }
-
-    if (!opData.meta) { opData.meta = {}; }
-    opData.meta.ts = Date.now();
-
-    // We'll need to transform the op to the current version of the document. This
-    // calls the callback immediately if opVersion == doc.v.
-    return getOps(docName, opData.v, doc.v, function(error, ops) {
-      let snapshot;
-      if (error) { return callback(error); }
-
-      if ((doc.v - opData.v) !== ops.length) {
-        // This should never happen. It indicates that we didn't get all the ops we
-        // asked for. Its important that the submitted op is correctly transformed.
-        console.error(`Could not get old ops in model for document ${docName}`);
-        console.error(`Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`);
-        return callback('Internal error');
+  const makeOpQueue = (docName, doc) =>
+    queue(function (opData, callback) {
+      if (!(opData.v >= 0)) {
+        return callback('Version missing')
       }
-
-      if (ops.length > 0) {
-        try {
-          // If there's enough ops, it might be worth spinning this out into a webworker thread.
-          for (const oldOp of Array.from(ops)) {
-            // Dup detection works by sending the id(s) the op has been submitted with previously.
-            // If the id matches, we reject it. The client can also detect the op has been submitted
-            // already if it sees its own previous id in the ops it sees when it does catchup.
-            if (oldOp.meta.source && opData.dupIfSource && Array.from(opData.dupIfSource).includes(oldOp.meta.source)) {
-              return callback('Op already submitted');
-            }
-
-            opData.op = doc.type.transform(opData.op, oldOp.op, 'left');
-            opData.v++;
-          }
-        } catch (error1) {
-          error = error1;
-          console.error(error.stack);
-          return callback(error.message);
-        }
+      if (opData.v > doc.v) {
+        return callback('Op at future version')
       }
 
-      try {
-        snapshot = doc.type.apply(doc.snapshot, opData.op);
-      } catch (error2) {
-        error = error2;
-        console.error(error.stack);
-        return callback(error.message);
+      // Punt the transforming work back to the client if the op is too old.
+      if (opData.v + options.maximumAge < doc.v) {
+        return callback('Op too old')
       }
 
-      // The op data should be at the current version, and the new document data should be at
-      // the next version.
-      //
-      // This should never happen in practice, but its a nice little check to make sure everything
-      // is hunky-dory.
-      if (opData.v !== doc.v) {
-        // This should never happen.
-        console.error("Version mismatch detected in model. File a ticket - this is a bug.");
-        console.error(`Expecting ${opData.v} == ${doc.v}`);
-        return callback('Internal error');
+      if (!opData.meta) {
+        opData.meta = {}
       }
+      opData.meta.ts = Date.now()
 
-      // newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
-      const writeOp = (db != null ? db.writeOp : undefined) || ((docName, newOpData, callback) => callback());
-
-      return writeOp(docName, opData, function(error) {
+      // We'll need to transform the op to the current version of the document. This
+      // calls the callback immediately if opVersion == doc.v.
+      return getOps(docName, opData.v, doc.v, function (error, ops) {
+        let snapshot
         if (error) {
-          // The user should probably know about this.
-          console.warn(`Error writing ops to database: ${error}`);
-          return callback(error);
+          return callback(error)
         }
 
-        __guardMethod__(options.stats, 'writeOp', o => o.writeOp());
-
-        // This is needed when we emit the 'change' event, below.
-        const oldSnapshot = doc.snapshot;
-
-        // All the heavy lifting is now done. Finally, we'll update the cache with the new data
-        // and (maybe!) save a new document snapshot to the database.
+        if (doc.v - opData.v !== ops.length) {
+          // This should never happen. It indicates that we didn't get all the ops we
+          // asked for. Its important that the submitted op is correctly transformed.
+          console.error(
+            `Could not get old ops in model for document ${docName}`
+          )
+          console.error(
+            `Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`
+          )
+          return callback('Internal error')
+        }
 
-        doc.v = opData.v + 1;
-        doc.snapshot = snapshot;
+        if (ops.length > 0) {
+          try {
+            // If there's enough ops, it might be worth spinning this out into a webworker thread.
+            for (const oldOp of Array.from(ops)) {
+              // Dup detection works by sending the id(s) the op has been submitted with previously.
+              // If the id matches, we reject it. The client can also detect the op has been submitted
+              // already if it sees its own previous id in the ops it sees when it does catchup.
+              if (
+                oldOp.meta.source &&
+                opData.dupIfSource &&
+                Array.from(opData.dupIfSource).includes(oldOp.meta.source)
+              ) {
+                return callback('Op already submitted')
+              }
 
-        doc.ops.push(opData);
-        if (db && (doc.ops.length > options.numCachedOps)) { doc.ops.shift(); }
+              opData.op = doc.type.transform(opData.op, oldOp.op, 'left')
+              opData.v++
+            }
+          } catch (error1) {
+            error = error1
+            console.error(error.stack)
+            return callback(error.message)
+          }
+        }
 
-        model.emit('applyOp', docName, opData, snapshot, oldSnapshot);
-        doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot);
+        try {
+          snapshot = doc.type.apply(doc.snapshot, opData.op)
+        } catch (error2) {
+          error = error2
+          console.error(error.stack)
+          return callback(error.message)
+        }
 
-        // The callback is called with the version of the document at which the op was applied.
-        // This is the op.v after transformation, and its doc.v - 1.
-        callback(null, opData.v);
-    
-        // I need a decent strategy here for deciding whether or not to save the snapshot.
+        // The op data should be at the current version, and the new document data should be at
+        // the next version.
         //
-        // The 'right' strategy looks something like "Store the snapshot whenever the snapshot
-        // is smaller than the accumulated op data". For now, I'll just store it every 20
-        // ops or something. (Configurable with doc.committedVersion)
-        if (!doc.snapshotWriteLock && ((doc.committedVersion + options.opsBeforeCommit) <= doc.v)) {
-          return tryWriteSnapshot(docName, function(error) {
-            if (error) { return console.warn(`Error writing snapshot ${error}. This is nonfatal`); }
-          });
+        // This should never happen in practice, but its a nice little check to make sure everything
+        // is hunky-dory.
+        if (opData.v !== doc.v) {
+          // This should never happen.
+          console.error(
+            'Version mismatch detected in model. File a ticket - this is a bug.'
+          )
+          console.error(`Expecting ${opData.v} == ${doc.v}`)
+          return callback('Internal error')
         }
-      });
-    });
-  });
+
+        // newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
+        const writeOp =
+          (db != null ? db.writeOp : undefined) ||
+          ((docName, newOpData, callback) => callback())
+
+        return writeOp(docName, opData, function (error) {
+          if (error) {
+            // The user should probably know about this.
+            console.warn(`Error writing ops to database: ${error}`)
+            return callback(error)
+          }
+
+          __guardMethod__(options.stats, 'writeOp', (o) => o.writeOp())
+
+          // This is needed when we emit the 'change' event, below.
+          const oldSnapshot = doc.snapshot
+
+          // All the heavy lifting is now done. Finally, we'll update the cache with the new data
+          // and (maybe!) save a new document snapshot to the database.
+
+          doc.v = opData.v + 1
+          doc.snapshot = snapshot
+
+          doc.ops.push(opData)
+          if (db && doc.ops.length > options.numCachedOps) {
+            doc.ops.shift()
+          }
+
+          model.emit('applyOp', docName, opData, snapshot, oldSnapshot)
+          doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot)
+
+          // The callback is called with the version of the document at which the op was applied.
+          // This is the op.v after transformation, and its doc.v - 1.
+          callback(null, opData.v)
+
+          // I need a decent strategy here for deciding whether or not to save the snapshot.
+          //
+          // The 'right' strategy looks something like "Store the snapshot whenever the snapshot
+          // is smaller than the accumulated op data". For now, I'll just store it every 20
+          // ops or something. (Configurable with doc.committedVersion)
+          if (
+            !doc.snapshotWriteLock &&
+            doc.committedVersion + options.opsBeforeCommit <= doc.v
+          ) {
+            return tryWriteSnapshot(docName, function (error) {
+              if (error) {
+                return console.warn(
+                  `Error writing snapshot ${error}. This is nonfatal`
+                )
+              }
+            })
+          }
+        })
+      })
+    })
 
   // Add the data for the given docName to the cache. The named document shouldn't already
   // exist in the doc set.
   //
   // Returns the new doc.
-  const add = function(docName, error, data, committedVersion, ops, dbMeta) {
-    let callback, doc;
-    const callbacks = awaitingGetSnapshot[docName];
-    delete awaitingGetSnapshot[docName];
+  const add = function (docName, error, data, committedVersion, ops, dbMeta) {
+    let callback, doc
+    const callbacks = awaitingGetSnapshot[docName]
+    delete awaitingGetSnapshot[docName]
 
     if (error) {
-      if (callbacks) { for (callback of Array.from(callbacks)) { callback(error); } }
+      if (callbacks) {
+        for (callback of Array.from(callbacks)) {
+          callback(error)
+        }
+      }
     } else {
-      doc = (docs[docName] = {
+      doc = docs[docName] = {
         snapshot: data.snapshot,
         v: data.v,
         type: data.type,
@@ -244,7 +294,7 @@ module.exports = (Model = function(db, options) {
         // Cache of ops
         ops: ops || [],
 
-        eventEmitter: new EventEmitter,
+        eventEmitter: new EventEmitter(),
 
         // Timer before the document will be invalidated from the cache (if the document has no
         // listeners)
@@ -254,97 +304,121 @@ module.exports = (Model = function(db, options) {
         committedVersion: committedVersion != null ? committedVersion : data.v,
         snapshotWriteLock: false,
         dbMeta
-      });
+      }
 
-      doc.opQueue = makeOpQueue(docName, doc);
-      
-      refreshReapingTimeout(docName);
-      model.emit('add', docName, data);
-      if (callbacks) { for (callback of Array.from(callbacks)) { callback(null, doc); } }
+      doc.opQueue = makeOpQueue(docName, doc)
+
+      refreshReapingTimeout(docName)
+      model.emit('add', docName, data)
+      if (callbacks) {
+        for (callback of Array.from(callbacks)) {
+          callback(null, doc)
+        }
+      }
     }
 
-    return doc;
-  };
-  
+    return doc
+  }
+
   // This is a little helper wrapper around db.getOps. It does two things:
   //
   // - If there's no database set, it returns an error to the callback
   // - It adds version numbers to each op returned from the database
   // (These can be inferred from context so the DB doesn't store them, but its useful to have them).
-  const getOpsInternal = function(docName, start, end, callback) {
-    if (!db) { return (typeof callback === 'function' ? callback('Document does not exist') : undefined); }
+  const getOpsInternal = function (docName, start, end, callback) {
+    if (!db) {
+      return typeof callback === 'function'
+        ? callback('Document does not exist')
+        : undefined
+    }
 
-    return db.getOps(docName, start, end, function(error, ops) {
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+    return db.getOps(docName, start, end, function (error, ops) {
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
-      let v = start;
-      for (const op of Array.from(ops)) { op.v = v++; }
+      let v = start
+      for (const op of Array.from(ops)) {
+        op.v = v++
+      }
 
-      return (typeof callback === 'function' ? callback(null, ops) : undefined);
-    });
-  };
+      return typeof callback === 'function' ? callback(null, ops) : undefined
+    })
+  }
 
   // Load the named document into the cache. This function is re-entrant.
   //
   // The callback is called with (error, doc)
-  const load = function(docName, callback) {
+  const load = function (docName, callback) {
     if (docs[docName]) {
       // The document is already loaded. Return immediately.
-      __guardMethod__(options.stats, 'cacheHit', o => o.cacheHit('getSnapshot'));
-      return callback(null, docs[docName]);
+      __guardMethod__(options.stats, 'cacheHit', (o) =>
+        o.cacheHit('getSnapshot')
+      )
+      return callback(null, docs[docName])
     }
 
     // We're a memory store. If we don't have it, nobody does.
-    if (!db) { return callback('Document does not exist'); }
+    if (!db) {
+      return callback('Document does not exist')
+    }
 
-    const callbacks = awaitingGetSnapshot[docName];
+    const callbacks = awaitingGetSnapshot[docName]
 
     // The document is being loaded already. Add ourselves as a callback.
-    if (callbacks) { return callbacks.push(callback); }
+    if (callbacks) {
+      return callbacks.push(callback)
+    }
 
-    __guardMethod__(options.stats, 'cacheMiss', o1 => o1.cacheMiss('getSnapshot'));
+    __guardMethod__(options.stats, 'cacheMiss', (o1) =>
+      o1.cacheMiss('getSnapshot')
+    )
 
     // The document isn't loaded and isn't being loaded. Load it.
-    awaitingGetSnapshot[docName] = [callback];
-    return db.getSnapshot(docName, function(error, data, dbMeta) {
-      if (error) { return add(docName, error); }
+    awaitingGetSnapshot[docName] = [callback]
+    return db.getSnapshot(docName, function (error, data, dbMeta) {
+      if (error) {
+        return add(docName, error)
+      }
 
-      const type = types[data.type];
+      const type = types[data.type]
       if (!type) {
-        console.warn(`Type '${data.type}' missing`);
-        return callback("Type not found");
+        console.warn(`Type '${data.type}' missing`)
+        return callback('Type not found')
       }
-      data.type = type;
+      data.type = type
 
-      const committedVersion = data.v;
+      const committedVersion = data.v
 
       // The server can close without saving the most recent document snapshot.
       // In this case, there are extra ops which need to be applied before
       // returning the snapshot.
-      return getOpsInternal(docName, data.v, null, function(error, ops) {
-        if (error) { return callback(error); }
+      return getOpsInternal(docName, data.v, null, function (error, ops) {
+        if (error) {
+          return callback(error)
+        }
 
         if (ops.length > 0) {
-          console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`);
+          console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`)
 
           try {
             for (const op of Array.from(ops)) {
-              data.snapshot = type.apply(data.snapshot, op.op);
-              data.v++;
+              data.snapshot = type.apply(data.snapshot, op.op)
+              data.v++
             }
           } catch (e) {
             // This should never happen - it indicates that whats in the
             // database is invalid.
-            console.error(`Op data invalid for ${docName}: ${e.stack}`);
-            return callback('Op data invalid');
+            console.error(`Op data invalid for ${docName}: ${e.stack}`)
+            return callback('Op data invalid')
           }
         }
 
-        model.emit('load', docName, data);
-        return add(docName, error, data, committedVersion, ops, dbMeta);
-      });
-    });
-  };
+        model.emit('load', docName, data)
+        return add(docName, error, data, committedVersion, ops, dbMeta)
+      })
+    })
+  }
 
   // This makes sure the cache contains a document. If the doc cache doesn't contain
   // a document, it is loaded from the database and stored.
@@ -352,52 +426,75 @@ module.exports = (Model = function(db, options) {
   // Documents are stored so long as either:
   // - They have been accessed within the past #{PERIOD}
   // - At least one client has the document open
-  var refreshReapingTimeout = function(docName) {
-    const doc = docs[docName];
-    if (!doc) { return; }
+  var refreshReapingTimeout = function (docName) {
+    const doc = docs[docName]
+    if (!doc) {
+      return
+    }
 
     // I want to let the clients list be updated before this is called.
-    return process.nextTick(function() {
+    return process.nextTick(function () {
       // This is an awkward way to find out the number of clients on a document. If this
       // causes performance issues, add a numClients field to the document.
       //
       // The first check is because its possible that between refreshReapingTimeout being called and this
       // event being fired, someone called delete() on the document and hence the doc is something else now.
-      if ((doc === docs[docName]) &&
-          (doc.eventEmitter.listeners('op').length === 0) &&
-          (db || options.forceReaping) &&
-          (doc.opQueue.busy === false)) {
-
-        let reapTimer;
-        clearTimeout(doc.reapTimer);
-        return doc.reapTimer = (reapTimer = setTimeout(() => tryWriteSnapshot(docName, function() {
-          // If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
-          // in the middle of applying an operation, don't reap.
-          if ((docs[docName].reapTimer === reapTimer) && (doc.opQueue.busy === false)) { return delete docs[docName]; }
-        })
-          , options.reapTime));
+      if (
+        doc === docs[docName] &&
+        doc.eventEmitter.listeners('op').length === 0 &&
+        (db || options.forceReaping) &&
+        doc.opQueue.busy === false
+      ) {
+        let reapTimer
+        clearTimeout(doc.reapTimer)
+        return (doc.reapTimer = reapTimer = setTimeout(
+          () =>
+            tryWriteSnapshot(docName, function () {
+              // If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
+              // in the middle of applying an operation, don't reap.
+              if (
+                docs[docName].reapTimer === reapTimer &&
+                doc.opQueue.busy === false
+              ) {
+                return delete docs[docName]
+              }
+            }),
+          options.reapTime
+        ))
       }
-    });
-  };
+    })
+  }
 
-  var tryWriteSnapshot = function(docName, callback) {
-    if (!db) { return (typeof callback === 'function' ? callback() : undefined); }
+  var tryWriteSnapshot = function (docName, callback) {
+    if (!db) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    const doc = docs[docName];
+    const doc = docs[docName]
 
     // The doc is closed
-    if (!doc) { return (typeof callback === 'function' ? callback() : undefined); }
+    if (!doc) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
     // The document is already saved.
-    if (doc.committedVersion === doc.v) { return (typeof callback === 'function' ? callback() : undefined); }
+    if (doc.committedVersion === doc.v) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    if (doc.snapshotWriteLock) { return (typeof callback === 'function' ? callback('Another snapshot write is in progress') : undefined); }
+    if (doc.snapshotWriteLock) {
+      return typeof callback === 'function'
+        ? callback('Another snapshot write is in progress')
+        : undefined
+    }
 
-    doc.snapshotWriteLock = true;
+    doc.snapshotWriteLock = true
 
-    __guardMethod__(options.stats, 'writeSnapshot', o => o.writeSnapshot());
+    __guardMethod__(options.stats, 'writeSnapshot', (o) => o.writeSnapshot())
 
-    const writeSnapshot = (db != null ? db.writeSnapshot : undefined) || ((docName, docData, dbMeta, callback) => callback());
+    const writeSnapshot =
+      (db != null ? db.writeSnapshot : undefined) ||
+      ((docName, docData, dbMeta, callback) => callback())
 
     const data = {
       v: doc.v,
@@ -405,87 +502,107 @@ module.exports = (Model = function(db, options) {
       snapshot: doc.snapshot,
       // The database doesn't know about object types.
       type: doc.type.name
-    };
+    }
 
     // Commit snapshot.
-    return writeSnapshot(docName, data, doc.dbMeta, function(error, dbMeta) {
-      doc.snapshotWriteLock = false;
+    return writeSnapshot(docName, data, doc.dbMeta, function (error, dbMeta) {
+      doc.snapshotWriteLock = false
 
       // We have to use data.v here because the version in the doc could
       // have been updated between the call to writeSnapshot() and now.
-      doc.committedVersion = data.v;
-      doc.dbMeta = dbMeta;
+      doc.committedVersion = data.v
+      doc.dbMeta = dbMeta
 
-      return (typeof callback === 'function' ? callback(error) : undefined);
-    });
-  };
+      return typeof callback === 'function' ? callback(error) : undefined
+    })
+  }
 
   // *** Model interface methods
 
   // Create a new document.
   //
   // data should be {snapshot, type, [meta]}. The version of a new document is 0.
-  this.create = function(docName, type, meta, callback) {
-    if (typeof meta === 'function') { [meta, callback] = Array.from([{}, meta]); }
+  this.create = function (docName, type, meta, callback) {
+    if (typeof meta === 'function') {
+      ;[meta, callback] = Array.from([{}, meta])
+    }
 
-    if (docName.match(/\//)) { return (typeof callback === 'function' ? callback('Invalid document name') : undefined); }
-    if (docs[docName]) { return (typeof callback === 'function' ? callback('Document already exists') : undefined); }
+    if (docName.match(/\//)) {
+      return typeof callback === 'function'
+        ? callback('Invalid document name')
+        : undefined
+    }
+    if (docs[docName]) {
+      return typeof callback === 'function'
+        ? callback('Document already exists')
+        : undefined
+    }
 
-    if (typeof type === 'string') { type = types[type]; }
-    if (!type) { return (typeof callback === 'function' ? callback('Type not found') : undefined); }
+    if (typeof type === 'string') {
+      type = types[type]
+    }
+    if (!type) {
+      return typeof callback === 'function'
+        ? callback('Type not found')
+        : undefined
+    }
 
     const data = {
-      snapshot:type.create(),
-      type:type.name,
-      meta:meta || {},
-      v:0
-    };
+      snapshot: type.create(),
+      type: type.name,
+      meta: meta || {},
+      v: 0
+    }
 
-    const done = function(error, dbMeta) {
+    const done = function (error, dbMeta) {
       // dbMeta can be used to cache extra state needed by the database to access the document, like an ID or something.
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
       // From here on we'll store the object version of the type name.
-      data.type = type;
-      add(docName, null, data, 0, [], dbMeta);
-      model.emit('create', docName, data);
-      return (typeof callback === 'function' ? callback() : undefined);
-    };
+      data.type = type
+      add(docName, null, data, 0, [], dbMeta)
+      model.emit('create', docName, data)
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
     if (db) {
-      return db.create(docName, data, done);
+      return db.create(docName, data, done)
     } else {
-      return done();
+      return done()
     }
-  };
+  }
 
   // Perminantly deletes the specified document.
   // If listeners are attached, they are removed.
-  // 
+  //
   // The callback is called with (error) if there was an error. If error is null / undefined, the
   // document was deleted.
   //
   // WARNING: This isn't well supported throughout the code. (Eg, streaming clients aren't told about the
   // deletion. Subsequent op submissions will fail).
-  this.delete = function(docName, callback) {
-    const doc = docs[docName];
+  this.delete = function (docName, callback) {
+    const doc = docs[docName]
 
     if (doc) {
-      clearTimeout(doc.reapTimer);
-      delete docs[docName];
+      clearTimeout(doc.reapTimer)
+      delete docs[docName]
     }
 
-    const done = function(error) {
-      if (!error) { model.emit('delete', docName); }
-      return (typeof callback === 'function' ? callback(error) : undefined);
-    };
+    const done = function (error) {
+      if (!error) {
+        model.emit('delete', docName)
+      }
+      return typeof callback === 'function' ? callback(error) : undefined
+    }
 
     if (db) {
-      return db.delete(docName, doc != null ? doc.dbMeta : undefined, done);
+      return db.delete(docName, doc != null ? doc.dbMeta : undefined, done)
     } else {
-      return done((!doc ? 'Document does not exist' : undefined));
+      return done(!doc ? 'Document does not exist' : undefined)
     }
-  };
+  }
 
   // This gets all operations from [start...end]. (That is, its not inclusive.)
   //
@@ -502,102 +619,139 @@ module.exports = (Model = function(db, options) {
   //
   // Use getVersion() to determine if a document actually exists, if thats what you're
   // after.
-  this.getOps = (getOps = function(docName, start, end, callback) {
+  this.getOps = getOps = function (docName, start, end, callback) {
     // getOps will only use the op cache if its there. It won't fill the op cache in.
-    if (!(start >= 0)) { throw new Error('start must be 0+'); }
+    if (!(start >= 0)) {
+      throw new Error('start must be 0+')
+    }
 
-    if (typeof end === 'function') { [end, callback] = Array.from([null, end]); }
+    if (typeof end === 'function') {
+      ;[end, callback] = Array.from([null, end])
+    }
 
-    const ops = docs[docName] != null ? docs[docName].ops : undefined;
+    const ops = docs[docName] != null ? docs[docName].ops : undefined
 
     if (ops) {
-      const version = docs[docName].v;
+      const version = docs[docName].v
 
       // Ops contains an array of ops. The last op in the list is the last op applied
-      if (end == null) { end = version; }
-      start = Math.min(start, end);
+      if (end == null) {
+        end = version
+      }
+      start = Math.min(start, end)
 
-      if (start === end) { return callback(null, []); }
+      if (start === end) {
+        return callback(null, [])
+      }
 
       // Base is the version number of the oldest op we have cached
-      const base = version - ops.length;
+      const base = version - ops.length
 
       // If the database is null, we'll trim to the ops we do have and hope thats enough.
-      if ((start >= base) || (db === null)) {
-        refreshReapingTimeout(docName);
+      if (start >= base || db === null) {
+        refreshReapingTimeout(docName)
         if (options.stats != null) {
-          options.stats.cacheHit('getOps');
+          options.stats.cacheHit('getOps')
         }
 
-        return callback(null, ops.slice((start - base), (end - base)));
+        return callback(null, ops.slice(start - base, end - base))
       }
     }
 
     if (options.stats != null) {
-      options.stats.cacheMiss('getOps');
+      options.stats.cacheMiss('getOps')
     }
 
-    return getOpsInternal(docName, start, end, callback);
-  });
+    return getOpsInternal(docName, start, end, callback)
+  }
 
   // Gets the snapshot data for the specified document.
   // getSnapshot(docName, callback)
   // Callback is called with (error, {v: <version>, type: <type>, snapshot: <snapshot>, meta: <meta>})
-  this.getSnapshot = (docName, callback) => load(docName, (error, doc) => callback(error, doc ? {v:doc.v, type:doc.type, snapshot:doc.snapshot, meta:doc.meta} : undefined));
+  this.getSnapshot = (docName, callback) =>
+    load(docName, (error, doc) =>
+      callback(
+        error,
+        doc
+          ? { v: doc.v, type: doc.type, snapshot: doc.snapshot, meta: doc.meta }
+          : undefined
+      )
+    )
 
   // Gets the latest version # of the document.
   // getVersion(docName, callback)
   // callback is called with (error, version).
-  this.getVersion = (docName, callback) => load(docName, (error, doc) => callback(error, doc != null ? doc.v : undefined));
+  this.getVersion = (docName, callback) =>
+    load(docName, (error, doc) =>
+      callback(error, doc != null ? doc.v : undefined)
+    )
 
   // Apply an op to the specified document.
   // The callback is passed (error, applied version #)
   // opData = {op:op, v:v, meta:metadata}
-  // 
+  //
   // Ops are queued before being applied so that the following code applies op C before op B:
   // model.applyOp 'doc', OPA, -> model.applyOp 'doc', OPB
   // model.applyOp 'doc', OPC
-  this.applyOp = (docName, opData, callback) => // All the logic for this is in makeOpQueue, above.
-  load(docName, function(error, doc) {
-    if (error) { return callback(error); }
+  this.applyOp = (
+    docName,
+    opData,
+    callback // All the logic for this is in makeOpQueue, above.
+  ) =>
+    load(docName, function (error, doc) {
+      if (error) {
+        return callback(error)
+      }
 
-    return process.nextTick(() => doc.opQueue(opData, function(error, newVersion) {
-      refreshReapingTimeout(docName);
-      return (typeof callback === 'function' ? callback(error, newVersion) : undefined);
-    }));
-  });
+      return process.nextTick(() =>
+        doc.opQueue(opData, function (error, newVersion) {
+          refreshReapingTimeout(docName)
+          return typeof callback === 'function'
+            ? callback(error, newVersion)
+            : undefined
+        })
+      )
+    })
 
   // TODO: store (some) metadata in DB
   // TODO: op and meta should be combineable in the op that gets sent
-  this.applyMetaOp = function(docName, metaOpData, callback) {
-    const {path, value} = metaOpData.meta;
-   
-    if (!isArray(path)) { return (typeof callback === 'function' ? callback("path should be an array") : undefined); }
+  this.applyMetaOp = function (docName, metaOpData, callback) {
+    const { path, value } = metaOpData.meta
+
+    if (!isArray(path)) {
+      return typeof callback === 'function'
+        ? callback('path should be an array')
+        : undefined
+    }
 
-    return load(docName, function(error, doc) {
+    return load(docName, function (error, doc) {
       if (error != null) {
-        return (typeof callback === 'function' ? callback(error) : undefined);
+        return typeof callback === 'function' ? callback(error) : undefined
       } else {
-        let applied = false;
+        let applied = false
         switch (path[0]) {
           case 'shout':
-            doc.eventEmitter.emit('op', metaOpData);
-            applied = true;
-            break;
+            doc.eventEmitter.emit('op', metaOpData)
+            applied = true
+            break
         }
 
-        if (applied) { model.emit('applyMetaOp', docName, path, value); }
-        return (typeof callback === 'function' ? callback(null, doc.v) : undefined);
+        if (applied) {
+          model.emit('applyMetaOp', docName, path, value)
+        }
+        return typeof callback === 'function'
+          ? callback(null, doc.v)
+          : undefined
       }
-    });
-  };
+    })
+  }
 
   // Listen to all ops from the specified version. If version is in the past, all
   // ops since that version are sent immediately to the listener.
   //
   // The callback is called once the listener is attached, but before any ops have been passed
   // to the listener.
-  // 
+  //
   // This will _not_ edit the document metadata.
   //
   // If there are any listeners, we don't purge the document from the cache. But be aware, this behaviour
@@ -609,98 +763,123 @@ module.exports = (Model = function(db, options) {
   // listener is called with (opData) each time an op is applied.
   //
   // callback(error, openedVersion)
-  this.listen = function(docName, version, listener, callback) {
-    if (typeof version === 'function') { [version, listener, callback] = Array.from([null, version, listener]); }
+  this.listen = function (docName, version, listener, callback) {
+    if (typeof version === 'function') {
+      ;[version, listener, callback] = Array.from([null, version, listener])
+    }
 
-    return load(docName, function(error, doc) {
-      if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+    return load(docName, function (error, doc) {
+      if (error) {
+        return typeof callback === 'function' ? callback(error) : undefined
+      }
 
-      clearTimeout(doc.reapTimer);
+      clearTimeout(doc.reapTimer)
 
       if (version != null) {
-        return getOps(docName, version, null, function(error, data) {
-          if (error) { return (typeof callback === 'function' ? callback(error) : undefined); }
+        return getOps(docName, version, null, function (error, data) {
+          if (error) {
+            return typeof callback === 'function' ? callback(error) : undefined
+          }
 
-          doc.eventEmitter.on('op', listener);
+          doc.eventEmitter.on('op', listener)
           if (typeof callback === 'function') {
-            callback(null, version);
+            callback(null, version)
           }
           return (() => {
-            const result = [];
+            const result = []
             for (const op of Array.from(data)) {
-              var needle;
-              listener(op);
+              var needle
+              listener(op)
 
               // The listener may well remove itself during the catchup phase. If this happens, break early.
               // This is done in a quite inefficient way. (O(n) where n = #listeners on doc)
-              if ((needle = listener, !Array.from(doc.eventEmitter.listeners('op')).includes(needle))) { break; } else {
-                result.push(undefined);
+              if (
+                ((needle = listener),
+                !Array.from(doc.eventEmitter.listeners('op')).includes(needle))
+              ) {
+                break
+              } else {
+                result.push(undefined)
               }
             }
-            return result;
-          })();
-        });
-
-      } else { // Version is null / undefined. Just add the listener.
-        doc.eventEmitter.on('op', listener);
-        return (typeof callback === 'function' ? callback(null, doc.v) : undefined);
+            return result
+          })()
+        })
+      } else {
+        // Version is null / undefined. Just add the listener.
+        doc.eventEmitter.on('op', listener)
+        return typeof callback === 'function'
+          ? callback(null, doc.v)
+          : undefined
       }
-    });
-  };
+    })
+  }
 
   // Remove a listener for a particular document.
   //
   // removeListener(docName, listener)
   //
   // This is synchronous.
-  this.removeListener = function(docName, listener) {
+  this.removeListener = function (docName, listener) {
     // The document should already be loaded.
-    const doc = docs[docName];
-    if (!doc) { throw new Error('removeListener called but document not loaded'); }
+    const doc = docs[docName]
+    if (!doc) {
+      throw new Error('removeListener called but document not loaded')
+    }
 
-    doc.eventEmitter.removeListener('op', listener);
-    return refreshReapingTimeout(docName);
-  };
+    doc.eventEmitter.removeListener('op', listener)
+    return refreshReapingTimeout(docName)
+  }
 
   // Flush saves all snapshot data to the database. I'm not sure whether or not this is actually needed -
   // sharejs will happily replay uncommitted ops when documents are re-opened anyway.
-  this.flush = function(callback) {
-    if (!db) { return (typeof callback === 'function' ? callback() : undefined); }
+  this.flush = function (callback) {
+    if (!db) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
 
-    let pendingWrites = 0;
+    let pendingWrites = 0
 
     for (const docName in docs) {
-      const doc = docs[docName];
+      const doc = docs[docName]
       if (doc.committedVersion < doc.v) {
-        pendingWrites++;
+        pendingWrites++
         // I'm hoping writeSnapshot will always happen in another thread.
-        tryWriteSnapshot(docName, () => process.nextTick(function() {
-          pendingWrites--;
-          if (pendingWrites === 0) { return (typeof callback === 'function' ? callback() : undefined); }
-        }));
+        tryWriteSnapshot(docName, () =>
+          process.nextTick(function () {
+            pendingWrites--
+            if (pendingWrites === 0) {
+              return typeof callback === 'function' ? callback() : undefined
+            }
+          })
+        )
       }
     }
 
     // If nothing was queued, terminate immediately.
-    if (pendingWrites === 0) { return (typeof callback === 'function' ? callback() : undefined); }
-  };
+    if (pendingWrites === 0) {
+      return typeof callback === 'function' ? callback() : undefined
+    }
+  }
 
   // Close the database connection. This is needed so nodejs can shut down cleanly.
-  this.closeDb = function() {
-    __guardMethod__(db, 'close', o => o.close());
-    return db = null;
-  };
-
-});
+  this.closeDb = function () {
+    __guardMethod__(db, 'close', (o) => o.close())
+    return (db = null)
+  }
+}
 
 // Model inherits from EventEmitter.
-Model.prototype = new EventEmitter;
-
+Model.prototype = new EventEmitter()
 
 function __guardMethod__(obj, methodName, transform) {
-  if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
-    return transform(obj, methodName);
+  if (
+    typeof obj !== 'undefined' &&
+    obj !== null &&
+    typeof obj[methodName] === 'function'
+  ) {
+    return transform(obj, methodName)
   } else {
-    return undefined;
+    return undefined
   }
-}
+}

+ 15 - 11
services/document-updater/app/js/sharejs/types/simple.js

@@ -23,28 +23,32 @@ module.exports = {
   name: 'simple',
 
   // Create a new document snapshot
-  create() { return {str:""}; },
+  create() {
+    return { str: '' }
+  },
 
   // Apply the given op to the document snapshot. Returns the new snapshot.
   //
   // The original snapshot should not be modified.
   apply(snapshot, op) {
-    if (!(op.position >= 0 && op.position <= snapshot.str.length)) { throw new Error('Invalid position'); }
+    if (!(op.position >= 0 && op.position <= snapshot.str.length)) {
+      throw new Error('Invalid position')
+    }
 
-    let {
-      str
-    } = snapshot;
-    str = str.slice(0, op.position) + op.text + str.slice(op.position);
-    return {str};
+    let { str } = snapshot
+    str = str.slice(0, op.position) + op.text + str.slice(op.position)
+    return { str }
   },
 
   // transform op1 by op2. Return transformed version of op1.
   // sym describes the symmetry of the op. Its 'left' or 'right' depending on whether the
   // op being transformed comes from the client or the server.
   transform(op1, op2, sym) {
-    let pos = op1.position;
-    if ((op2.position < pos) || ((op2.position === pos) && (sym === 'left'))) { pos += op2.text.length; }
+    let pos = op1.position
+    if (op2.position < pos || (op2.position === pos && sym === 'left')) {
+      pos += op2.text.length
+    }
 
-    return {position:pos, text:op1.text};
+    return { position: pos, text: op1.text }
   }
-};
+}

+ 28 - 22
services/document-updater/app/js/sharejs/types/syncqueue.js

@@ -25,30 +25,36 @@
 //
 //   ^--- async thing will only be running once at any time.
 
-module.exports = function(process) {
-  if (typeof process !== 'function') { throw new Error('process is not a function'); }
-  const queue = [];
-  
-  const enqueue = function(data, callback) {
-    queue.push([data, callback]);
-    return flush();
-  };
+module.exports = function (process) {
+  if (typeof process !== 'function') {
+    throw new Error('process is not a function')
+  }
+  const queue = []
 
-  enqueue.busy = false;
+  const enqueue = function (data, callback) {
+    queue.push([data, callback])
+    return flush()
+  }
 
-  var flush = function() {
-    if (enqueue.busy || (queue.length === 0)) { return; }
+  enqueue.busy = false
 
-    enqueue.busy = true;
-    const [data, callback] = Array.from(queue.shift());
-    return process(data, function(...result) { // TODO: Make this not use varargs - varargs are really slow.
-      enqueue.busy = false;
-      // This is called after busy = false so a user can check if enqueue.busy is set in the callback.
-      if (callback) { callback.apply(null, result); }
-      return flush();
-    });
-  };
+  var flush = function () {
+    if (enqueue.busy || queue.length === 0) {
+      return
+    }
 
-  return enqueue;
-};
+    enqueue.busy = true
+    const [data, callback] = Array.from(queue.shift())
+    return process(data, function (...result) {
+      // TODO: Make this not use varargs - varargs are really slow.
+      enqueue.busy = false
+      // This is called after busy = false so a user can check if enqueue.busy is set in the callback.
+      if (callback) {
+        callback.apply(null, result)
+      }
+      return flush()
+    })
+  }
 
+  return enqueue
+}

+ 27 - 21
services/document-updater/app/js/sharejs/types/text-api.js

@@ -8,39 +8,45 @@
  */
 // Text document API for text
 
-let text;
-if (typeof WEB === 'undefined') { text = require('./text'); }
+let text
+if (typeof WEB === 'undefined') {
+  text = require('./text')
+}
 
 text.api = {
-  provides: {text:true},
+  provides: { text: true },
 
   // The number of characters in the string
-  getLength() { return this.snapshot.length; },
+  getLength() {
+    return this.snapshot.length
+  },
 
   // Get the text contents of a document
-  getText() { return this.snapshot; },
+  getText() {
+    return this.snapshot
+  },
 
   insert(pos, text, callback) {
-    const op = [{p:pos, i:text}];
-    
-    this.submitOp(op, callback);
-    return op;
+    const op = [{ p: pos, i: text }]
+
+    this.submitOp(op, callback)
+    return op
   },
-  
+
   del(pos, length, callback) {
-    const op = [{p:pos, d:this.snapshot.slice(pos, (pos + length))}];
+    const op = [{ p: pos, d: this.snapshot.slice(pos, pos + length) }]
 
-    this.submitOp(op, callback);
-    return op;
+    this.submitOp(op, callback)
+    return op
   },
-  
+
   _register() {
-    return this.on('remoteop', function(op) {
+    return this.on('remoteop', function (op) {
       return Array.from(op).map((component) =>
-        component.i !== undefined ?
-          this.emit('insert', component.p, component.i)
-        :
-          this.emit('delete', component.p, component.d));
-    });
+        component.i !== undefined
+          ? this.emit('insert', component.p, component.i)
+          : this.emit('delete', component.p, component.d)
+      )
+    })
   }
-};
+}

+ 37 - 30
services/document-updater/app/js/sharejs/types/text-composable-api.js

@@ -13,57 +13,64 @@
  */
 // Text document API for text
 
-let type;
+let type
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  type = exports.types['text-composable'];
+  type = exports.types['text-composable']
 } else {
-  type = require('./text-composable');
+  type = require('./text-composable')
 }
 
 type.api = {
-  provides: {'text':true},
+  provides: { text: true },
 
   // The number of characters in the string
-  'getLength'() { return this.snapshot.length; },
+  getLength() {
+    return this.snapshot.length
+  },
 
   // Get the text contents of a document
-  'getText'() { return this.snapshot; },
+  getText() {
+    return this.snapshot
+  },
+
+  insert(pos, text, callback) {
+    const op = type.normalize([pos, { i: text }, this.snapshot.length - pos])
 
-  'insert'(pos, text, callback) {
-    const op = type.normalize([pos, {'i':text}, (this.snapshot.length - pos)]);
-    
-    this.submitOp(op, callback);
-    return op;
+    this.submitOp(op, callback)
+    return op
   },
-  
-  'del'(pos, length, callback) {
-    const op = type.normalize([pos, {'d':this.snapshot.slice(pos, (pos + length))}, (this.snapshot.length - pos - length)]);
 
-    this.submitOp(op, callback);
-    return op;
+  del(pos, length, callback) {
+    const op = type.normalize([
+      pos,
+      { d: this.snapshot.slice(pos, pos + length) },
+      this.snapshot.length - pos - length
+    ])
+
+    this.submitOp(op, callback)
+    return op
   },
 
   _register() {
-    return this.on('remoteop', function(op) {
-      let pos = 0;
+    return this.on('remoteop', function (op) {
+      let pos = 0
       return (() => {
-        const result = [];
+        const result = []
         for (const component of Array.from(op)) {
           if (typeof component === 'number') {
-            result.push(pos += component);
+            result.push((pos += component))
           } else if (component.i !== undefined) {
-            this.emit('insert', pos, component.i);
-            result.push(pos += component.i.length);
+            this.emit('insert', pos, component.i)
+            result.push((pos += component.i.length))
           } else {
             // delete
-            result.push(this.emit('delete', pos, component.d));
+            result.push(this.emit('delete', pos, component.d))
           }
         }
-        return result;
-      })();
-    });
+        return result
+      })()
+    })
   }
-};
-          // We don't increment pos, because the position
-          // specified is after the delete has happened.
-
+}
+// We don't increment pos, because the position
+// specified is after the delete has happened.

+ 255 - 179
services/document-updater/app/js/sharejs/types/text-composable.js

@@ -27,297 +27,373 @@
 //
 // Snapshots are strings.
 
-let makeAppend;
-const p = function() {}; // require('util').debug
-const i = function() {}; // require('util').inspect
+let makeAppend
+const p = function () {} // require('util').debug
+const i = function () {} // require('util').inspect
 
-const exports = (typeof WEB !== 'undefined' && WEB !== null) ? {} : module.exports;
+const exports = typeof WEB !== 'undefined' && WEB !== null ? {} : module.exports
 
-exports.name = 'text-composable';
+exports.name = 'text-composable'
 
-exports.create = () => '';
+exports.create = () => ''
 
 // -------- Utility methods
 
-const checkOp = function(op) {
-  if (!Array.isArray(op)) { throw new Error('Op must be an array of components'); }
-  let last = null;
+const checkOp = function (op) {
+  if (!Array.isArray(op)) {
+    throw new Error('Op must be an array of components')
+  }
+  let last = null
   return (() => {
-    const result = [];
+    const result = []
     for (const c of Array.from(op)) {
-      if (typeof(c) === 'object') {
-        if (((c.i == null) || !(c.i.length > 0)) && ((c.d == null) || !(c.d.length > 0))) { throw new Error(`Invalid op component: ${i(c)}`); }
+      if (typeof c === 'object') {
+        if (
+          (c.i == null || !(c.i.length > 0)) &&
+          (c.d == null || !(c.d.length > 0))
+        ) {
+          throw new Error(`Invalid op component: ${i(c)}`)
+        }
       } else {
-        if (typeof(c) !== 'number') { throw new Error('Op components must be objects or numbers'); }
-        if (!(c > 0)) { throw new Error('Skip components must be a positive number'); }
-        if (typeof(last) === 'number') { throw new Error('Adjacent skip components should be added'); }
+        if (typeof c !== 'number') {
+          throw new Error('Op components must be objects or numbers')
+        }
+        if (!(c > 0)) {
+          throw new Error('Skip components must be a positive number')
+        }
+        if (typeof last === 'number') {
+          throw new Error('Adjacent skip components should be added')
+        }
       }
 
-      result.push(last = c);
+      result.push((last = c))
     }
-    return result;
-  })();
-};
+    return result
+  })()
+}
 
 // Makes a function for appending components to a given op.
 // Exported for the randomOpGenerator.
-exports._makeAppend = (makeAppend = op => (function(component) {
-  if ((component === 0) || (component.i === '') || (component.d === '')) {
-    
-  } else if (op.length === 0) {
-    return op.push(component);
-  } else if ((typeof(component) === 'number') && (typeof(op[op.length - 1]) === 'number')) {
-    return op[op.length - 1] += component;
-  } else if ((component.i != null) && (op[op.length - 1].i != null)) {
-    return op[op.length - 1].i += component.i;
-  } else if ((component.d != null) && (op[op.length - 1].d != null)) {
-    return op[op.length - 1].d += component.d;
-  } else {
-    return op.push(component);
+exports._makeAppend = makeAppend = (op) =>
+  function (component) {
+    if (component === 0 || component.i === '' || component.d === '') {
+    } else if (op.length === 0) {
+      return op.push(component)
+    } else if (
+      typeof component === 'number' &&
+      typeof op[op.length - 1] === 'number'
+    ) {
+      return (op[op.length - 1] += component)
+    } else if (component.i != null && op[op.length - 1].i != null) {
+      return (op[op.length - 1].i += component.i)
+    } else if (component.d != null && op[op.length - 1].d != null) {
+      return (op[op.length - 1].d += component.d)
+    } else {
+      return op.push(component)
+    }
   }
-}));
-  
+
 //  checkOp op
 
 // Makes 2 functions for taking components from the start of an op, and for peeking
 // at the next op that could be taken.
-const makeTake = function(op) {
+const makeTake = function (op) {
   // The index of the next component to take
-  let idx = 0;
+  let idx = 0
   // The offset into the component
-  let offset = 0;
+  let offset = 0
 
   // Take up to length n from the front of op. If n is null, take the next
   // op component. If indivisableField == 'd', delete components won't be separated.
   // If indivisableField == 'i', insert components won't be separated.
-  const take = function(n, indivisableField) {
-    let c;
-    if (idx === op.length) { return null; }
+  const take = function (n, indivisableField) {
+    let c
+    if (idx === op.length) {
+      return null
+    }
     // assert.notStrictEqual op.length, i, 'The op is too short to traverse the document'
 
-    if (typeof(op[idx]) === 'number') {
-      if ((n == null) || ((op[idx] - offset) <= n)) {
-        c = op[idx] - offset;
-        ++idx; offset = 0;
-        return c;
+    if (typeof op[idx] === 'number') {
+      if (n == null || op[idx] - offset <= n) {
+        c = op[idx] - offset
+        ++idx
+        offset = 0
+        return c
       } else {
-        offset += n;
-        return n;
+        offset += n
+        return n
       }
     } else {
       // Take from the string
-      const field = op[idx].i ? 'i' : 'd';
-      c = {};
-      if ((n == null) || ((op[idx][field].length - offset) <= n) || (field === indivisableField)) {
-        c[field] = op[idx][field].slice(offset);
-        ++idx; offset = 0;
+      const field = op[idx].i ? 'i' : 'd'
+      c = {}
+      if (
+        n == null ||
+        op[idx][field].length - offset <= n ||
+        field === indivisableField
+      ) {
+        c[field] = op[idx][field].slice(offset)
+        ++idx
+        offset = 0
       } else {
-        c[field] = op[idx][field].slice(offset, (offset + n));
-        offset += n;
+        c[field] = op[idx][field].slice(offset, offset + n)
+        offset += n
       }
-      return c;
+      return c
     }
-  };
-  
-  const peekType = () => op[idx];
-  
-  return [take, peekType];
-};
+  }
+
+  const peekType = () => op[idx]
+
+  return [take, peekType]
+}
 
 // Find and return the length of an op component
-const componentLength = function(component) {
-  if (typeof(component) === 'number') {
-    return component;
+const componentLength = function (component) {
+  if (typeof component === 'number') {
+    return component
   } else if (component.i != null) {
-    return component.i.length;
+    return component.i.length
   } else {
-    return component.d.length;
+    return component.d.length
   }
-};
+}
 
 // Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
 // adjacent inserts and deletes.
-exports.normalize = function(op) {
-  const newOp = [];
-  const append = makeAppend(newOp);
-  for (const component of Array.from(op)) { append(component); }
-  return newOp;
-};
+exports.normalize = function (op) {
+  const newOp = []
+  const append = makeAppend(newOp)
+  for (const component of Array.from(op)) {
+    append(component)
+  }
+  return newOp
+}
 
 // Apply the op to the string. Returns the new string.
-exports.apply = function(str, op) {
-  p(`Applying ${i(op)} to '${str}'`);
-  if (typeof(str) !== 'string') { throw new Error('Snapshot should be a string'); }
-  checkOp(op);
+exports.apply = function (str, op) {
+  p(`Applying ${i(op)} to '${str}'`)
+  if (typeof str !== 'string') {
+    throw new Error('Snapshot should be a string')
+  }
+  checkOp(op)
 
-  const pos = 0;
-  const newDoc = [];
+  const pos = 0
+  const newDoc = []
 
   for (const component of Array.from(op)) {
-    if (typeof(component) === 'number') {
-      if (component > str.length) { throw new Error('The op is too long for this document'); }
-      newDoc.push(str.slice(0, component));
-      str = str.slice(component);
+    if (typeof component === 'number') {
+      if (component > str.length) {
+        throw new Error('The op is too long for this document')
+      }
+      newDoc.push(str.slice(0, component))
+      str = str.slice(component)
     } else if (component.i != null) {
-      newDoc.push(component.i);
+      newDoc.push(component.i)
     } else {
-      if (component.d !== str.slice(0, component.d.length)) { throw new Error(`The deleted text '${component.d}' doesn't match the next characters in the document '${str.slice(0, component.d.length)}'`); }
-      str = str.slice(component.d.length);
+      if (component.d !== str.slice(0, component.d.length)) {
+        throw new Error(
+          `The deleted text '${
+            component.d
+          }' doesn't match the next characters in the document '${str.slice(
+            0,
+            component.d.length
+          )}'`
+        )
+      }
+      str = str.slice(component.d.length)
     }
   }
-  
-  if (str !== '') { throw new Error("The applied op doesn't traverse the entire document"); }
 
-  return newDoc.join('');
-};
+  if (str !== '') {
+    throw new Error("The applied op doesn't traverse the entire document")
+  }
+
+  return newDoc.join('')
+}
 
 // transform op1 by op2. Return transformed version of op1.
 // op1 and op2 are unchanged by transform.
-exports.transform = function(op, otherOp, side) {
-  let component;
-  if ((side !== 'left') && (side !== 'right')) { throw new Error(`side (${side} must be 'left' or 'right'`); }
+exports.transform = function (op, otherOp, side) {
+  let component
+  if (side !== 'left' && side !== 'right') {
+    throw new Error(`side (${side} must be 'left' or 'right'`)
+  }
 
-  checkOp(op);
-  checkOp(otherOp);
-  const newOp = [];
+  checkOp(op)
+  checkOp(otherOp)
+  const newOp = []
 
-  const append = makeAppend(newOp);
-  const [take, peek] = Array.from(makeTake(op));
+  const append = makeAppend(newOp)
+  const [take, peek] = Array.from(makeTake(op))
 
   for (component of Array.from(otherOp)) {
-    var chunk, length;
-    if (typeof(component) === 'number') { // Skip
-      length = component;
+    var chunk, length
+    if (typeof component === 'number') {
+      // Skip
+      length = component
       while (length > 0) {
-        chunk = take(length, 'i');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, 'i')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(chunk);
-        if ((typeof(chunk) !== 'object') || (chunk.i == null)) { length -= componentLength(chunk); }
+        append(chunk)
+        if (typeof chunk !== 'object' || chunk.i == null) {
+          length -= componentLength(chunk)
+        }
       }
-    } else if (component.i != null) { // Insert
+    } else if (component.i != null) {
+      // Insert
       if (side === 'left') {
         // The left insert should go first.
-        const o = peek();
-        if (o != null ? o.i : undefined) { append(take()); }
+        const o = peek()
+        if (o != null ? o.i : undefined) {
+          append(take())
+        }
       }
 
       // Otherwise, skip the inserted text.
-      append(component.i.length);
-    } else { // Delete.
+      append(component.i.length)
+    } else {
+      // Delete.
       // assert.ok component.d
-      ({
-        length
-      } = component.d);
+      ;({ length } = component.d)
       while (length > 0) {
-        chunk = take(length, 'i');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, 'i')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        if (typeof(chunk) === 'number') {
-          length -= chunk;
+        if (typeof chunk === 'number') {
+          length -= chunk
         } else if (chunk.i != null) {
-          append(chunk);
+          append(chunk)
         } else {
           // assert.ok chunk.d
           // The delete is unnecessary now.
-          length -= chunk.d.length;
+          length -= chunk.d.length
         }
       }
     }
   }
-  
+
   // Append extras from op1
-  while (component = take()) {
-    if ((component != null ? component.i : undefined) == null) { throw new Error(`Remaining fragments in the op: ${i(component)}`); }
-    append(component);
+  while ((component = take())) {
+    if ((component != null ? component.i : undefined) == null) {
+      throw new Error(`Remaining fragments in the op: ${i(component)}`)
+    }
+    append(component)
   }
 
-  return newOp;
-};
-
+  return newOp
+}
 
 // Compose 2 ops into 1 op.
-exports.compose = function(op1, op2) {
-  let component;
-  p(`COMPOSE ${i(op1)} + ${i(op2)}`);
-  checkOp(op1);
-  checkOp(op2);
+exports.compose = function (op1, op2) {
+  let component
+  p(`COMPOSE ${i(op1)} + ${i(op2)}`)
+  checkOp(op1)
+  checkOp(op2)
 
-  const result = [];
+  const result = []
 
-  const append = makeAppend(result);
-  const [take, _] = Array.from(makeTake(op1));
+  const append = makeAppend(result)
+  const [take, _] = Array.from(makeTake(op1))
 
   for (component of Array.from(op2)) {
-    var chunk, length;
-    if (typeof(component) === 'number') { // Skip
-      length = component;
+    var chunk, length
+    if (typeof component === 'number') {
+      // Skip
+      length = component
       while (length > 0) {
-        chunk = take(length, 'd');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, 'd')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(chunk);
-        if ((typeof(chunk) !== 'object') || (chunk.d == null)) { length -= componentLength(chunk); }
+        append(chunk)
+        if (typeof chunk !== 'object' || chunk.d == null) {
+          length -= componentLength(chunk)
+        }
       }
-
-    } else if (component.i != null) { // Insert
-      append({i:component.i});
-
-    } else { // Delete
-      let offset = 0;
+    } else if (component.i != null) {
+      // Insert
+      append({ i: component.i })
+    } else {
+      // Delete
+      let offset = 0
       while (offset < component.d.length) {
-        chunk = take(component.d.length - offset, 'd');
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(component.d.length - offset, 'd')
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
         // If its delete, append it. If its skip, drop it and decrease length. If its insert, check the strings match, drop it and decrease length.
-        if (typeof(chunk) === 'number') {
-          append({d:component.d.slice(offset, (offset + chunk))});
-          offset += chunk;
+        if (typeof chunk === 'number') {
+          append({ d: component.d.slice(offset, offset + chunk) })
+          offset += chunk
         } else if (chunk.i != null) {
-          if (component.d.slice(offset, (offset + chunk.i.length)) !== chunk.i) { throw new Error("The deleted text doesn't match the inserted text"); }
-          offset += chunk.i.length;
+          if (component.d.slice(offset, offset + chunk.i.length) !== chunk.i) {
+            throw new Error("The deleted text doesn't match the inserted text")
+          }
+          offset += chunk.i.length
           // The ops cancel each other out.
         } else {
           // Delete
-          append(chunk);
+          append(chunk)
         }
       }
     }
   }
-    
+
   // Append extras from op1
-  while (component = take()) {
-    if ((component != null ? component.d : undefined) == null) { throw new Error(`Trailing stuff in op1 ${i(component)}`); }
-    append(component);
+  while ((component = take())) {
+    if ((component != null ? component.d : undefined) == null) {
+      throw new Error(`Trailing stuff in op1 ${i(component)}`)
+    }
+    append(component)
   }
 
-  return result;
-};
-  
+  return result
+}
 
-const invertComponent = function(c) {
-  if (typeof(c) === 'number') {
-    return c;
+const invertComponent = function (c) {
+  if (typeof c === 'number') {
+    return c
   } else if (c.i != null) {
-    return {d:c.i};
+    return { d: c.i }
   } else {
-    return {i:c.d};
+    return { i: c.d }
   }
-};
+}
 
 // Invert an op
-exports.invert = function(op) {
-  const result = [];
-  const append = makeAppend(result);
+exports.invert = function (op) {
+  const result = []
+  const append = makeAppend(result)
 
-  for (const component of Array.from(op)) { append(invertComponent(component)); }
-  
-  return result;
-};
+  for (const component of Array.from(op)) {
+    append(invertComponent(component))
+  }
 
-if (typeof window !== 'undefined' && window !== null) {
-  if (!window.ot) { window.ot = {}; }
-  if (!window.ot.types) { window.ot.types = {}; }
-  window.ot.types.text = exports;
+  return result
 }
 
+if (typeof window !== 'undefined' && window !== null) {
+  if (!window.ot) {
+    window.ot = {}
+  }
+  if (!window.ot.types) {
+    window.ot.types = {}
+  }
+  window.ot.types.text = exports
+}

+ 71 - 61
services/document-updater/app/js/sharejs/types/text-tp2-api.js

@@ -13,111 +13,121 @@
  */
 // Text document API for text-tp2
 
-let type;
+let type
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  type = exports.types['text-tp2'];
+  type = exports.types['text-tp2']
 } else {
-  type = require('./text-tp2');
+  type = require('./text-tp2')
 }
 
-const {_takeDoc:takeDoc, _append:append} = type;
+const { _takeDoc: takeDoc, _append: append } = type
 
-const appendSkipChars = (op, doc, pos, maxlength) => (() => {
-  const result = [];
-  while (((maxlength === undefined) || (maxlength > 0)) && (pos.index < doc.data.length)) {
-    const part = takeDoc(doc, pos, maxlength, true);
-    if ((maxlength !== undefined) && (typeof part === 'string')) { maxlength -= part.length; }
-    result.push(append(op, (part.length || part)));
-  }
-  return result;
-})();
+const appendSkipChars = (op, doc, pos, maxlength) =>
+  (() => {
+    const result = []
+    while (
+      (maxlength === undefined || maxlength > 0) &&
+      pos.index < doc.data.length
+    ) {
+      const part = takeDoc(doc, pos, maxlength, true)
+      if (maxlength !== undefined && typeof part === 'string') {
+        maxlength -= part.length
+      }
+      result.push(append(op, part.length || part))
+    }
+    return result
+  })()
 
 type.api = {
-  'provides': {'text':true},
+  provides: { text: true },
 
   // The number of characters in the string
-  'getLength'() { return this.snapshot.charLength; },
+  getLength() {
+    return this.snapshot.charLength
+  },
 
   // Flatten a document into a string
-  'getText'() {
-    const strings = (Array.from(this.snapshot.data).filter((elem) => typeof elem === 'string'));
-    return strings.join('');
+  getText() {
+    const strings = Array.from(this.snapshot.data).filter(
+      (elem) => typeof elem === 'string'
+    )
+    return strings.join('')
   },
 
-  'insert'(pos, text, callback) {
-    if (pos === undefined) { pos = 0; }
+  insert(pos, text, callback) {
+    if (pos === undefined) {
+      pos = 0
+    }
+
+    const op = []
+    const docPos = { index: 0, offset: 0 }
 
-    const op = [];
-    const docPos = {index:0, offset:0};
+    appendSkipChars(op, this.snapshot, docPos, pos)
+    append(op, { i: text })
+    appendSkipChars(op, this.snapshot, docPos)
 
-    appendSkipChars(op, this.snapshot, docPos, pos);
-    append(op, {'i':text});
-    appendSkipChars(op, this.snapshot, docPos);
-    
-    this.submitOp(op, callback);
-    return op;
+    this.submitOp(op, callback)
+    return op
   },
-  
-  'del'(pos, length, callback) {
-    const op = [];
-    const docPos = {index:0, offset:0};
 
-    appendSkipChars(op, this.snapshot, docPos, pos);
-    
+  del(pos, length, callback) {
+    const op = []
+    const docPos = { index: 0, offset: 0 }
+
+    appendSkipChars(op, this.snapshot, docPos, pos)
+
     while (length > 0) {
-      const part = takeDoc(this.snapshot, docPos, length, true);
+      const part = takeDoc(this.snapshot, docPos, length, true)
       if (typeof part === 'string') {
-        append(op, {'d':part.length});
-        length -= part.length;
+        append(op, { d: part.length })
+        length -= part.length
       } else {
-        append(op, part);
+        append(op, part)
       }
     }
-    
-    appendSkipChars(op, this.snapshot, docPos);
 
-    this.submitOp(op, callback);
-    return op;
+    appendSkipChars(op, this.snapshot, docPos)
+
+    this.submitOp(op, callback)
+    return op
   },
 
-  '_register'() {
+  _register() {
     // Interpret recieved ops + generate more detailed events for them
-    return this.on('remoteop', function(op, snapshot) {
-      let textPos = 0;
-      const docPos = {index:0, offset:0};
+    return this.on('remoteop', function (op, snapshot) {
+      let textPos = 0
+      const docPos = { index: 0, offset: 0 }
 
       for (const component of Array.from(op)) {
-        var part, remainder;
+        var part, remainder
         if (typeof component === 'number') {
           // Skip
-          remainder = component;
+          remainder = component
           while (remainder > 0) {
-            part = takeDoc(snapshot, docPos, remainder);
+            part = takeDoc(snapshot, docPos, remainder)
             if (typeof part === 'string') {
-              textPos += part.length;
+              textPos += part.length
             }
-            remainder -= part.length || part;
+            remainder -= part.length || part
           }
         } else if (component.i !== undefined) {
           // Insert
           if (typeof component.i === 'string') {
-            this.emit('insert', textPos, component.i);
-            textPos += component.i.length;
+            this.emit('insert', textPos, component.i)
+            textPos += component.i.length
           }
         } else {
           // Delete
-          remainder = component.d;
+          remainder = component.d
           while (remainder > 0) {
-            part = takeDoc(snapshot, docPos, remainder);
+            part = takeDoc(snapshot, docPos, remainder)
             if (typeof part === 'string') {
-              this.emit('delete', textPos, part);
+              this.emit('delete', textPos, part)
             }
-            remainder -= part.length || part;
+            remainder -= part.length || part
           }
         }
       }
-
-    });
+    })
   }
-};
-
+}

+ 307 - 216
services/document-updater/app/js/sharejs/types/text-tp2.js

@@ -38,369 +38,460 @@
 // Eg, the document: 'Hello .....world' ('.' denotes tombstoned (deleted) characters)
 // would be represented by a document snapshot of ['Hello ', 5, 'world']
 
-let append, appendDoc, takeDoc;
+let append, appendDoc, takeDoc
 var type = {
   name: 'text-tp2',
   tp2: true,
-  create() { return {charLength:0, totalLength:0, positionCache:[], data:[]}; },
+  create() {
+    return { charLength: 0, totalLength: 0, positionCache: [], data: [] }
+  },
   serialize(doc) {
-    if (!doc.data) { throw new Error('invalid doc snapshot'); }
-    return doc.data;
+    if (!doc.data) {
+      throw new Error('invalid doc snapshot')
+    }
+    return doc.data
   },
   deserialize(data) {
-    const doc = type.create();
-    doc.data = data;
-    
+    const doc = type.create()
+    doc.data = data
+
     for (const component of Array.from(data)) {
       if (typeof component === 'string') {
-        doc.charLength += component.length;
-        doc.totalLength += component.length;
+        doc.charLength += component.length
+        doc.totalLength += component.length
       } else {
-        doc.totalLength += component;
+        doc.totalLength += component
       }
     }
-    
-    return doc;
-  }
-};
 
+    return doc
+  }
+}
 
-const checkOp = function(op) {
-  if (!Array.isArray(op)) { throw new Error('Op must be an array of components'); }
-  let last = null;
+const checkOp = function (op) {
+  if (!Array.isArray(op)) {
+    throw new Error('Op must be an array of components')
+  }
+  let last = null
   return (() => {
-    const result = [];
+    const result = []
     for (const c of Array.from(op)) {
-      if (typeof(c) === 'object') {
+      if (typeof c === 'object') {
         if (c.i !== undefined) {
-          if (((typeof(c.i) !== 'string') || !(c.i.length > 0)) && ((typeof(c.i) !== 'number') || !(c.i > 0))) { throw new Error('Inserts must insert a string or a +ive number'); }
+          if (
+            (typeof c.i !== 'string' || !(c.i.length > 0)) &&
+            (typeof c.i !== 'number' || !(c.i > 0))
+          ) {
+            throw new Error('Inserts must insert a string or a +ive number')
+          }
         } else if (c.d !== undefined) {
-          if ((typeof(c.d) !== 'number') || !(c.d > 0)) { throw new Error('Deletes must be a +ive number'); }
+          if (typeof c.d !== 'number' || !(c.d > 0)) {
+            throw new Error('Deletes must be a +ive number')
+          }
         } else {
-          throw new Error('Operation component must define .i or .d');
+          throw new Error('Operation component must define .i or .d')
         }
       } else {
-        if (typeof(c) !== 'number') { throw new Error('Op components must be objects or numbers'); }
-        if (!(c > 0)) { throw new Error('Skip components must be a positive number'); }
-        if (typeof(last) === 'number') { throw new Error('Adjacent skip components should be combined'); }
+        if (typeof c !== 'number') {
+          throw new Error('Op components must be objects or numbers')
+        }
+        if (!(c > 0)) {
+          throw new Error('Skip components must be a positive number')
+        }
+        if (typeof last === 'number') {
+          throw new Error('Adjacent skip components should be combined')
+        }
       }
 
-      result.push(last = c);
+      result.push((last = c))
     }
-    return result;
-  })();
-};
+    return result
+  })()
+}
 
 // Take the next part from the specified position in a document snapshot.
 // position = {index, offset}. It will be updated.
-type._takeDoc = (takeDoc = function(doc, position, maxlength, tombsIndivisible) {
-  if (position.index >= doc.data.length) { throw new Error('Operation goes past the end of the document'); }
+type._takeDoc = takeDoc = function (
+  doc,
+  position,
+  maxlength,
+  tombsIndivisible
+) {
+  if (position.index >= doc.data.length) {
+    throw new Error('Operation goes past the end of the document')
+  }
 
-  const part = doc.data[position.index];
+  const part = doc.data[position.index]
   // peel off data[0]
-  const result = typeof(part) === 'string' ?
-    maxlength !== undefined ?
-      part.slice(position.offset, (position.offset + maxlength))
-    :
-      part.slice(position.offset)
-  :
-    (maxlength === undefined) || tombsIndivisible ?
-      part - position.offset
-    :
-      Math.min(maxlength, part - position.offset);
-  
-  const resultLen = result.length || result;
-
-  if (((part.length || part) - position.offset) > resultLen) {
-    position.offset += resultLen;
+  const result =
+    typeof part === 'string'
+      ? maxlength !== undefined
+        ? part.slice(position.offset, position.offset + maxlength)
+        : part.slice(position.offset)
+      : maxlength === undefined || tombsIndivisible
+      ? part - position.offset
+      : Math.min(maxlength, part - position.offset)
+
+  const resultLen = result.length || result
+
+  if ((part.length || part) - position.offset > resultLen) {
+    position.offset += resultLen
   } else {
-    position.index++;
-    position.offset = 0;
+    position.index++
+    position.offset = 0
   }
-  
-  return result;
-});
+
+  return result
+}
 
 // Append a part to the end of a document
-type._appendDoc = (appendDoc = function(doc, p) {
-  if ((p === 0) || (p === '')) { return; }
+type._appendDoc = appendDoc = function (doc, p) {
+  if (p === 0 || p === '') {
+    return
+  }
 
   if (typeof p === 'string') {
-    doc.charLength += p.length;
-    doc.totalLength += p.length;
+    doc.charLength += p.length
+    doc.totalLength += p.length
   } else {
-    doc.totalLength += p;
+    doc.totalLength += p
   }
 
-  const {
-    data
-  } = doc;
+  const { data } = doc
   if (data.length === 0) {
-    data.push(p);
-  } else if (typeof(data[data.length - 1]) === typeof(p)) {
-    data[data.length - 1] += p;
+    data.push(p)
+  } else if (typeof data[data.length - 1] === typeof p) {
+    data[data.length - 1] += p
   } else {
-    data.push(p);
+    data.push(p)
   }
-});
+}
 
 // Apply the op to the document. The document is not modified in the process.
-type.apply = function(doc, op) {
-  if ((doc.totalLength === undefined) || (doc.charLength === undefined) || (doc.data.length === undefined)) {
-    throw new Error('Snapshot is invalid');
+type.apply = function (doc, op) {
+  if (
+    doc.totalLength === undefined ||
+    doc.charLength === undefined ||
+    doc.data.length === undefined
+  ) {
+    throw new Error('Snapshot is invalid')
   }
 
-  checkOp(op);
+  checkOp(op)
 
-  const newDoc = type.create();
-  const position = {index:0, offset:0};
+  const newDoc = type.create()
+  const position = { index: 0, offset: 0 }
 
   for (const component of Array.from(op)) {
-    var part, remainder;
-    if (typeof(component) === 'number') {
-      remainder = component;
+    var part, remainder
+    if (typeof component === 'number') {
+      remainder = component
       while (remainder > 0) {
-        part = takeDoc(doc, position, remainder);
-        
-        appendDoc(newDoc, part);
-        remainder -= part.length || part;
-      }
+        part = takeDoc(doc, position, remainder)
 
+        appendDoc(newDoc, part)
+        remainder -= part.length || part
+      }
     } else if (component.i !== undefined) {
-      appendDoc(newDoc, component.i);
+      appendDoc(newDoc, component.i)
     } else if (component.d !== undefined) {
-      remainder = component.d;
+      remainder = component.d
       while (remainder > 0) {
-        part = takeDoc(doc, position, remainder);
-        remainder -= part.length || part;
+        part = takeDoc(doc, position, remainder)
+        remainder -= part.length || part
       }
-      appendDoc(newDoc, component.d);
+      appendDoc(newDoc, component.d)
     }
   }
-  
-  return newDoc;
-};
+
+  return newDoc
+}
 
 // Append an op component to the end of the specified op.
 // Exported for the randomOpGenerator.
-type._append = (append = function(op, component) {
-  if ((component === 0) || (component.i === '') || (component.i === 0) || (component.d === 0)) {
-    
+type._append = append = function (op, component) {
+  if (
+    component === 0 ||
+    component.i === '' ||
+    component.i === 0 ||
+    component.d === 0
+  ) {
   } else if (op.length === 0) {
-    return op.push(component);
+    return op.push(component)
   } else {
-    const last = op[op.length - 1];
-    if ((typeof(component) === 'number') && (typeof(last) === 'number')) {
-      return op[op.length - 1] += component;
-    } else if ((component.i !== undefined) && (last.i != null) && (typeof(last.i) === typeof(component.i))) {
-      return last.i += component.i;
-    } else if ((component.d !== undefined) && (last.d != null)) {
-      return last.d += component.d;
+    const last = op[op.length - 1]
+    if (typeof component === 'number' && typeof last === 'number') {
+      return (op[op.length - 1] += component)
+    } else if (
+      component.i !== undefined &&
+      last.i != null &&
+      typeof last.i === typeof component.i
+    ) {
+      return (last.i += component.i)
+    } else if (component.d !== undefined && last.d != null) {
+      return (last.d += component.d)
     } else {
-      return op.push(component);
+      return op.push(component)
     }
   }
-});
-  
+}
+
 // Makes 2 functions for taking components from the start of an op, and for peeking
 // at the next op that could be taken.
-const makeTake = function(op) {
+const makeTake = function (op) {
   // The index of the next component to take
-  let index = 0;
+  let index = 0
   // The offset into the component
-  let offset = 0;
+  let offset = 0
 
   // Take up to length maxlength from the op. If maxlength is not defined, there is no max.
   // If insertsIndivisible is true, inserts (& insert tombstones) won't be separated.
   //
   // Returns null when op is fully consumed.
-  const take = function(maxlength, insertsIndivisible) {
-    let current;
-    if (index === op.length) { return null; }
-
-    const e = op[index];
-    if ((typeof((current = e)) === 'number') || (typeof((current = e.i)) === 'number') || ((current = e.d) !== undefined)) {
-      let c;
-      if ((maxlength == null) || ((current - offset) <= maxlength) || (insertsIndivisible && (e.i !== undefined))) {
+  const take = function (maxlength, insertsIndivisible) {
+    let current
+    if (index === op.length) {
+      return null
+    }
+
+    const e = op[index]
+    if (
+      typeof (current = e) === 'number' ||
+      typeof (current = e.i) === 'number' ||
+      (current = e.d) !== undefined
+    ) {
+      let c
+      if (
+        maxlength == null ||
+        current - offset <= maxlength ||
+        (insertsIndivisible && e.i !== undefined)
+      ) {
         // Return the rest of the current element.
-        c = current - offset;
-        ++index; offset = 0;
+        c = current - offset
+        ++index
+        offset = 0
+      } else {
+        offset += maxlength
+        c = maxlength
+      }
+      if (e.i !== undefined) {
+        return { i: c }
+      } else if (e.d !== undefined) {
+        return { d: c }
       } else {
-        offset += maxlength;
-        c = maxlength;
+        return c
       }
-      if (e.i !== undefined) { return {i:c}; } else if (e.d !== undefined) { return {d:c}; } else { return c; }
     } else {
       // Take from the inserted string
-      let result;
-      if ((maxlength == null) || ((e.i.length - offset) <= maxlength) || insertsIndivisible) {
-        result = {i:e.i.slice(offset)};
-        ++index; offset = 0;
+      let result
+      if (
+        maxlength == null ||
+        e.i.length - offset <= maxlength ||
+        insertsIndivisible
+      ) {
+        result = { i: e.i.slice(offset) }
+        ++index
+        offset = 0
       } else {
-        result = {i:e.i.slice(offset, offset + maxlength)};
-        offset += maxlength;
+        result = { i: e.i.slice(offset, offset + maxlength) }
+        offset += maxlength
       }
-      return result;
+      return result
     }
-  };
-  
-  const peekType = () => op[index];
-  
-  return [take, peekType];
-};
+  }
+
+  const peekType = () => op[index]
+
+  return [take, peekType]
+}
 
 // Find and return the length of an op component
-const componentLength = function(component) {
-  if (typeof(component) === 'number') {
-    return component;
-  } else if (typeof(component.i) === 'string') {
-    return component.i.length;
+const componentLength = function (component) {
+  if (typeof component === 'number') {
+    return component
+  } else if (typeof component.i === 'string') {
+    return component.i.length
   } else {
     // This should work because c.d and c.i must be +ive.
-    return component.d || component.i;
+    return component.d || component.i
   }
-};
+}
 
 // Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
 // adjacent inserts and deletes.
-type.normalize = function(op) {
-  const newOp = [];
-  for (const component of Array.from(op)) { append(newOp, component); }
-  return newOp;
-};
+type.normalize = function (op) {
+  const newOp = []
+  for (const component of Array.from(op)) {
+    append(newOp, component)
+  }
+  return newOp
+}
 
 // This is a helper method to transform and prune. goForwards is true for transform, false for prune.
-const transformer = function(op, otherOp, goForwards, side) {
-  let component;
-  checkOp(op);
-  checkOp(otherOp);
-  const newOp = [];
+const transformer = function (op, otherOp, goForwards, side) {
+  let component
+  checkOp(op)
+  checkOp(otherOp)
+  const newOp = []
 
-  const [take, peek] = Array.from(makeTake(op));
+  const [take, peek] = Array.from(makeTake(op))
 
   for (component of Array.from(otherOp)) {
-    var chunk;
-    let length = componentLength(component);
+    var chunk
+    let length = componentLength(component)
 
-    if (component.i !== undefined) { // Insert text or tombs
-      if (goForwards) { // transform - insert skips over inserted parts
+    if (component.i !== undefined) {
+      // Insert text or tombs
+      if (goForwards) {
+        // transform - insert skips over inserted parts
         if (side === 'left') {
           // The left insert should go first.
-          while (__guard__(peek(), x => x.i) !== undefined) { append(newOp, take()); }
+          while (__guard__(peek(), (x) => x.i) !== undefined) {
+            append(newOp, take())
+          }
         }
 
         // In any case, skip the inserted text.
-        append(newOp, length);
-
-      } else { // Prune. Remove skips for inserts.
+        append(newOp, length)
+      } else {
+        // Prune. Remove skips for inserts.
         while (length > 0) {
-          chunk = take(length, true);
+          chunk = take(length, true)
 
-          if (chunk === null) { throw new Error('The transformed op is invalid'); }
-          if (chunk.d !== undefined) { throw new Error('The transformed op deletes locally inserted characters - it cannot be purged of the insert.'); }
+          if (chunk === null) {
+            throw new Error('The transformed op is invalid')
+          }
+          if (chunk.d !== undefined) {
+            throw new Error(
+              'The transformed op deletes locally inserted characters - it cannot be purged of the insert.'
+            )
+          }
 
           if (typeof chunk === 'number') {
-            length -= chunk;
+            length -= chunk
           } else {
-            append(newOp, chunk);
+            append(newOp, chunk)
           }
         }
       }
-
-    } else { // Skip or delete
+    } else {
+      // Skip or delete
       while (length > 0) {
-        chunk = take(length, true);
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length, true)
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(newOp, chunk);
-        if (!chunk.i) { length -= componentLength(chunk); }
+        append(newOp, chunk)
+        if (!chunk.i) {
+          length -= componentLength(chunk)
+        }
       }
     }
   }
-  
+
   // Append extras from op1
-  while (component = take()) {
-    if (component.i === undefined) { throw new Error(`Remaining fragments in the op: ${component}`); }
-    append(newOp, component);
+  while ((component = take())) {
+    if (component.i === undefined) {
+      throw new Error(`Remaining fragments in the op: ${component}`)
+    }
+    append(newOp, component)
   }
 
-  return newOp;
-};
+  return newOp
+}
 
 // transform op1 by op2. Return transformed version of op1.
 // op1 and op2 are unchanged by transform.
 // side should be 'left' or 'right', depending on if op1.id <> op2.id. 'left' == client op.
-type.transform = function(op, otherOp, side) {
-  if ((side !== 'left') && (side !== 'right')) { throw new Error(`side (${side}) should be 'left' or 'right'`); }
-  return transformer(op, otherOp, true, side);
-};
+type.transform = function (op, otherOp, side) {
+  if (side !== 'left' && side !== 'right') {
+    throw new Error(`side (${side}) should be 'left' or 'right'`)
+  }
+  return transformer(op, otherOp, true, side)
+}
 
 // Prune is the inverse of transform.
-type.prune = (op, otherOp) => transformer(op, otherOp, false);
+type.prune = (op, otherOp) => transformer(op, otherOp, false)
 
 // Compose 2 ops into 1 op.
-type.compose = function(op1, op2) {
-  let component;
-  if ((op1 === null) || (op1 === undefined)) { return op2; }
+type.compose = function (op1, op2) {
+  let component
+  if (op1 === null || op1 === undefined) {
+    return op2
+  }
 
-  checkOp(op1);
-  checkOp(op2);
+  checkOp(op1)
+  checkOp(op2)
 
-  const result = [];
+  const result = []
 
-  const [take, _] = Array.from(makeTake(op1));
+  const [take, _] = Array.from(makeTake(op1))
 
   for (component of Array.from(op2)) {
-
-    var chunk, length;
-    if (typeof(component) === 'number') { // Skip
+    var chunk, length
+    if (typeof component === 'number') {
+      // Skip
       // Just copy from op1.
-      length = component;
+      length = component
       while (length > 0) {
-        chunk = take(length);
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length)
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        append(result, chunk);
-        length -= componentLength(chunk);
+        append(result, chunk)
+        length -= componentLength(chunk)
       }
-
-    } else if (component.i !== undefined) { // Insert
-      append(result, {i:component.i});
-
-    } else { // Delete
-      length = component.d;
+    } else if (component.i !== undefined) {
+      // Insert
+      append(result, { i: component.i })
+    } else {
+      // Delete
+      length = component.d
       while (length > 0) {
-        chunk = take(length);
-        if (chunk === null) { throw new Error('The op traverses more elements than the document has'); }
+        chunk = take(length)
+        if (chunk === null) {
+          throw new Error(
+            'The op traverses more elements than the document has'
+          )
+        }
 
-        const chunkLength = componentLength(chunk);
+        const chunkLength = componentLength(chunk)
         if (chunk.i !== undefined) {
-          append(result, {i:chunkLength});
+          append(result, { i: chunkLength })
         } else {
-          append(result, {d:chunkLength});
+          append(result, { d: chunkLength })
         }
 
-        length -= chunkLength;
+        length -= chunkLength
       }
     }
   }
-    
+
   // Append extras from op1
-  while (component = take()) {
-    if (component.i === undefined) { throw new Error(`Remaining fragments in op1: ${component}`); }
-    append(result, component);
+  while ((component = take())) {
+    if (component.i === undefined) {
+      throw new Error(`Remaining fragments in op1: ${component}`)
+    }
+    append(result, component)
   }
 
-  return result;
-};
+  return result
+}
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  exports.types['text-tp2'] = type;
+  exports.types['text-tp2'] = type
 } else {
-  module.exports = type;
+  module.exports = type
 }
 
-
 function __guard__(value, transform) {
-  return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
-}
+  return typeof value !== 'undefined' && value !== null
+    ? transform(value)
+    : undefined
+}

+ 226 - 148
services/document-updater/app/js/sharejs/types/text.js

@@ -32,104 +32,153 @@
 // NOTE: The global scope here is shared with other sharejs files when built with closure.
 // Be careful what ends up in your namespace.
 
-let append, transformComponent;
-const text = {};
+let append, transformComponent
+const text = {}
 
-text.name = 'text';
+text.name = 'text'
 
-text.create = () => '';
+text.create = () => ''
 
-const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos);
+const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos)
 
-const checkValidComponent = function(c) {
-  if (typeof c.p !== 'number') { throw new Error('component missing position field'); }
+const checkValidComponent = function (c) {
+  if (typeof c.p !== 'number') {
+    throw new Error('component missing position field')
+  }
 
-  const i_type = typeof c.i;
-  const d_type = typeof c.d;
-  const c_type = typeof c.c;
-  if (!((i_type === 'string') ^ (d_type === 'string') ^ (c_type === 'string'))) { throw new Error('component needs an i, d or c field'); }
+  const i_type = typeof c.i
+  const d_type = typeof c.d
+  const c_type = typeof c.c
+  if (
+    !((i_type === 'string') ^ (d_type === 'string') ^ (c_type === 'string'))
+  ) {
+    throw new Error('component needs an i, d or c field')
+  }
 
-  if (!(c.p >= 0)) { throw new Error('position cannot be negative'); }
-};
+  if (!(c.p >= 0)) {
+    throw new Error('position cannot be negative')
+  }
+}
 
-const checkValidOp = function(op) {
-  for (const c of Array.from(op)) { checkValidComponent(c); }
-  return true;
-};
+const checkValidOp = function (op) {
+  for (const c of Array.from(op)) {
+    checkValidComponent(c)
+  }
+  return true
+}
 
-text.apply = function(snapshot, op) {
-  checkValidOp(op);
+text.apply = function (snapshot, op) {
+  checkValidOp(op)
   for (const component of Array.from(op)) {
     if (component.i != null) {
-      snapshot = strInject(snapshot, component.p, component.i);
+      snapshot = strInject(snapshot, component.p, component.i)
     } else if (component.d != null) {
-      const deleted = snapshot.slice(component.p, (component.p + component.d.length));
-      if (component.d !== deleted) { throw new Error(`Delete component '${component.d}' does not match deleted text '${deleted}'`); }
-      snapshot = snapshot.slice(0, component.p) + snapshot.slice((component.p + component.d.length));
+      const deleted = snapshot.slice(
+        component.p,
+        component.p + component.d.length
+      )
+      if (component.d !== deleted) {
+        throw new Error(
+          `Delete component '${component.d}' does not match deleted text '${deleted}'`
+        )
+      }
+      snapshot =
+        snapshot.slice(0, component.p) +
+        snapshot.slice(component.p + component.d.length)
     } else if (component.c != null) {
-      const comment = snapshot.slice(component.p, (component.p + component.c.length));
-      if (component.c !== comment) { throw new Error(`Comment component '${component.c}' does not match commented text '${comment}'`); }
+      const comment = snapshot.slice(
+        component.p,
+        component.p + component.c.length
+      )
+      if (component.c !== comment) {
+        throw new Error(
+          `Comment component '${component.c}' does not match commented text '${comment}'`
+        )
+      }
     } else {
-      throw new Error("Unknown op type");
+      throw new Error('Unknown op type')
     }
   }
-  return snapshot;
-};
-
+  return snapshot
+}
 
 // Exported for use by the random op generator.
 //
 // For simplicity, this version of append does not compress adjacent inserts and deletes of
 // the same text. It would be nice to change that at some stage.
-text._append = (append = function(newOp, c) {
-  if ((c.i === '') || (c.d === '')) { return; }
+text._append = append = function (newOp, c) {
+  if (c.i === '' || c.d === '') {
+    return
+  }
   if (newOp.length === 0) {
-    return newOp.push(c);
+    return newOp.push(c)
   } else {
-    const last = newOp[newOp.length - 1];
+    const last = newOp[newOp.length - 1]
 
     // Compose the insert into the previous insert if possible
-    if ((last.i != null) && (c.i != null) && (last.p <= c.p && c.p <= (last.p + last.i.length))) {
-      return newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p};
-    } else if ((last.d != null) && (c.d != null) && (c.p <= last.p && last.p <= (c.p + c.d.length))) {
-      return newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p};
+    if (
+      last.i != null &&
+      c.i != null &&
+      last.p <= c.p &&
+      c.p <= last.p + last.i.length
+    ) {
+      return (newOp[newOp.length - 1] = {
+        i: strInject(last.i, c.p - last.p, c.i),
+        p: last.p
+      })
+    } else if (
+      last.d != null &&
+      c.d != null &&
+      c.p <= last.p &&
+      last.p <= c.p + c.d.length
+    ) {
+      return (newOp[newOp.length - 1] = {
+        d: strInject(c.d, last.p - c.p, last.d),
+        p: c.p
+      })
     } else {
-      return newOp.push(c);
+      return newOp.push(c)
     }
   }
-});
+}
 
-text.compose = function(op1, op2) {
-  checkValidOp(op1);
-  checkValidOp(op2);
+text.compose = function (op1, op2) {
+  checkValidOp(op1)
+  checkValidOp(op2)
 
-  const newOp = op1.slice();
-  for (const c of Array.from(op2)) { append(newOp, c); }
+  const newOp = op1.slice()
+  for (const c of Array.from(op2)) {
+    append(newOp, c)
+  }
 
-  return newOp;
-};
+  return newOp
+}
 
 // Attempt to compress the op components together 'as much as possible'.
 // This implementation preserves order and preserves create/delete pairs.
-text.compress = op => text.compose([], op);
+text.compress = (op) => text.compose([], op)
+
+text.normalize = function (op) {
+  const newOp = []
 
-text.normalize = function(op) {
-  const newOp = [];
-  
   // Normalize should allow ops which are a single (unwrapped) component:
   // {i:'asdf', p:23}.
   // There's no good way to test if something is an array:
   // http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
   // so this is probably the least bad solution.
-  if ((op.i != null) || (op.p != null)) { op = [op]; }
+  if (op.i != null || op.p != null) {
+    op = [op]
+  }
 
   for (const c of Array.from(op)) {
-    if (c.p == null) { c.p = 0; }
-    append(newOp, c);
+    if (c.p == null) {
+      c.p = 0
+    }
+    append(newOp, c)
   }
-  
-  return newOp;
-};
+
+  return newOp
+}
 
 // This helper method transforms a position by an op component.
 //
@@ -137,176 +186,205 @@ text.normalize = function(op) {
 // is pushed after the insert (true) or before it (false).
 //
 // insertAfter is optional for deletes.
-const transformPosition = function(pos, c, insertAfter) {
+const transformPosition = function (pos, c, insertAfter) {
   if (c.i != null) {
-    if ((c.p < pos) || ((c.p === pos) && insertAfter)) {
-      return pos + c.i.length;
+    if (c.p < pos || (c.p === pos && insertAfter)) {
+      return pos + c.i.length
     } else {
-      return pos;
+      return pos
     }
   } else if (c.d != null) {
     // I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
     // but I think its harder to read that way, and it compiles using ternary operators anyway
     // so its no slower written like this.
     if (pos <= c.p) {
-      return pos;
-    } else if (pos <= (c.p + c.d.length)) {
-      return c.p;
+      return pos
+    } else if (pos <= c.p + c.d.length) {
+      return c.p
     } else {
-      return pos - c.d.length;
+      return pos - c.d.length
     }
   } else if (c.c != null) {
-    return pos;
+    return pos
   } else {
-    throw new Error("unknown op type");
+    throw new Error('unknown op type')
   }
-};
+}
 
 // Helper method to transform a cursor position as a result of an op.
 //
 // Like transformPosition above, if c is an insert, insertAfter specifies whether the cursor position
 // is pushed after an insert (true) or before it (false).
-text.transformCursor = function(position, op, side) {
-  const insertAfter = side === 'right';
-  for (const c of Array.from(op)) { position = transformPosition(position, c, insertAfter); }
-  return position;
-};
+text.transformCursor = function (position, op, side) {
+  const insertAfter = side === 'right'
+  for (const c of Array.from(op)) {
+    position = transformPosition(position, c, insertAfter)
+  }
+  return position
+}
 
 // Transform an op component by another op component. Asymmetric.
 // The result will be appended to destination.
 //
 // exported for use in JSON type
-text._tc = (transformComponent = function(dest, c, otherC, side) {
-  let cIntersect, intersectEnd, intersectStart, newC, otherIntersect;
-  checkValidOp([c]);
-  checkValidOp([otherC]);
+text._tc = transformComponent = function (dest, c, otherC, side) {
+  let cIntersect, intersectEnd, intersectStart, newC, otherIntersect
+  checkValidOp([c])
+  checkValidOp([otherC])
 
   if (c.i != null) {
-    append(dest, {i:c.i, p:transformPosition(c.p, otherC, side === 'right')});
-
-  } else if (c.d != null) { // Delete
-    if (otherC.i != null) { // delete vs insert
-      let s = c.d;
+    append(dest, {
+      i: c.i,
+      p: transformPosition(c.p, otherC, side === 'right')
+    })
+  } else if (c.d != null) {
+    // Delete
+    if (otherC.i != null) {
+      // delete vs insert
+      let s = c.d
       if (c.p < otherC.p) {
-        append(dest, {d:s.slice(0, otherC.p - c.p), p:c.p});
-        s = s.slice((otherC.p - c.p));
+        append(dest, { d: s.slice(0, otherC.p - c.p), p: c.p })
+        s = s.slice(otherC.p - c.p)
       }
       if (s !== '') {
-        append(dest, {d:s, p:c.p + otherC.i.length});
+        append(dest, { d: s, p: c.p + otherC.i.length })
       }
-
-    } else if (otherC.d != null) { // Delete vs delete
-      if (c.p >= (otherC.p + otherC.d.length)) {
-        append(dest, {d:c.d, p:c.p - otherC.d.length});
-      } else if ((c.p + c.d.length) <= otherC.p) {
-        append(dest, c);
+    } else if (otherC.d != null) {
+      // Delete vs delete
+      if (c.p >= otherC.p + otherC.d.length) {
+        append(dest, { d: c.d, p: c.p - otherC.d.length })
+      } else if (c.p + c.d.length <= otherC.p) {
+        append(dest, c)
       } else {
         // They overlap somewhere.
-        newC = {d:'', p:c.p};
+        newC = { d: '', p: c.p }
         if (c.p < otherC.p) {
-          newC.d = c.d.slice(0, (otherC.p - c.p));
+          newC.d = c.d.slice(0, otherC.p - c.p)
         }
-        if ((c.p + c.d.length) > (otherC.p + otherC.d.length)) {
-          newC.d += c.d.slice(((otherC.p + otherC.d.length) - c.p));
+        if (c.p + c.d.length > otherC.p + otherC.d.length) {
+          newC.d += c.d.slice(otherC.p + otherC.d.length - c.p)
         }
 
         // This is entirely optional - just for a check that the deleted
         // text in the two ops matches
-        intersectStart = Math.max(c.p, otherC.p);
-        intersectEnd = Math.min(c.p + c.d.length, otherC.p + otherC.d.length);
-        cIntersect = c.d.slice(intersectStart - c.p, intersectEnd - c.p);
-        otherIntersect = otherC.d.slice(intersectStart - otherC.p, intersectEnd - otherC.p);
-        if (cIntersect !== otherIntersect) { throw new Error('Delete ops delete different text in the same region of the document'); }
+        intersectStart = Math.max(c.p, otherC.p)
+        intersectEnd = Math.min(c.p + c.d.length, otherC.p + otherC.d.length)
+        cIntersect = c.d.slice(intersectStart - c.p, intersectEnd - c.p)
+        otherIntersect = otherC.d.slice(
+          intersectStart - otherC.p,
+          intersectEnd - otherC.p
+        )
+        if (cIntersect !== otherIntersect) {
+          throw new Error(
+            'Delete ops delete different text in the same region of the document'
+          )
+        }
 
         if (newC.d !== '') {
           // This could be rewritten similarly to insert v delete, above.
-          newC.p = transformPosition(newC.p, otherC);
-          append(dest, newC);
+          newC.p = transformPosition(newC.p, otherC)
+          append(dest, newC)
         }
       }
-    
     } else if (otherC.c != null) {
-      append(dest, c);
-    
+      append(dest, c)
     } else {
-      throw new Error("unknown op type");
+      throw new Error('unknown op type')
     }
-
-  } else if (c.c != null) { // Comment
+  } else if (c.c != null) {
+    // Comment
     if (otherC.i != null) {
       if (c.p < otherC.p && otherC.p < c.p + c.c.length) {
-        const offset = otherC.p - c.p;
-        const new_c = (c.c.slice(0, +(offset-1) + 1 || undefined) + otherC.i + c.c.slice(offset));
-        append(dest, {c:new_c, p:c.p, t: c.t});
+        const offset = otherC.p - c.p
+        const new_c =
+          c.c.slice(0, +(offset - 1) + 1 || undefined) +
+          otherC.i +
+          c.c.slice(offset)
+        append(dest, { c: new_c, p: c.p, t: c.t })
       } else {
-        append(dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t});
+        append(dest, {
+          c: c.c,
+          p: transformPosition(c.p, otherC, true),
+          t: c.t
+        })
       }
-    
     } else if (otherC.d != null) {
-      if (c.p >= (otherC.p + otherC.d.length)) {
-        append(dest, {c:c.c, p:c.p - otherC.d.length, t: c.t});
-      } else if ((c.p + c.c.length) <= otherC.p) {
-        append(dest, c);
-      } else { // Delete overlaps comment
+      if (c.p >= otherC.p + otherC.d.length) {
+        append(dest, { c: c.c, p: c.p - otherC.d.length, t: c.t })
+      } else if (c.p + c.c.length <= otherC.p) {
+        append(dest, c)
+      } else {
+        // Delete overlaps comment
         // They overlap somewhere.
-        newC = {c:'', p:c.p, t: c.t};
+        newC = { c: '', p: c.p, t: c.t }
         if (c.p < otherC.p) {
-          newC.c = c.c.slice(0, (otherC.p - c.p));
+          newC.c = c.c.slice(0, otherC.p - c.p)
         }
-        if ((c.p + c.c.length) > (otherC.p + otherC.d.length)) {
-          newC.c += c.c.slice(((otherC.p + otherC.d.length) - c.p));
+        if (c.p + c.c.length > otherC.p + otherC.d.length) {
+          newC.c += c.c.slice(otherC.p + otherC.d.length - c.p)
         }
 
         // This is entirely optional - just for a check that the deleted
         // text in the two ops matches
-        intersectStart = Math.max(c.p, otherC.p);
-        intersectEnd = Math.min(c.p + c.c.length, otherC.p + otherC.d.length);
-        cIntersect = c.c.slice(intersectStart - c.p, intersectEnd - c.p);
-        otherIntersect = otherC.d.slice(intersectStart - otherC.p, intersectEnd - otherC.p);
-        if (cIntersect !== otherIntersect) { throw new Error('Delete ops delete different text in the same region of the document'); }
-
-        newC.p = transformPosition(newC.p, otherC);
-        append(dest, newC);
+        intersectStart = Math.max(c.p, otherC.p)
+        intersectEnd = Math.min(c.p + c.c.length, otherC.p + otherC.d.length)
+        cIntersect = c.c.slice(intersectStart - c.p, intersectEnd - c.p)
+        otherIntersect = otherC.d.slice(
+          intersectStart - otherC.p,
+          intersectEnd - otherC.p
+        )
+        if (cIntersect !== otherIntersect) {
+          throw new Error(
+            'Delete ops delete different text in the same region of the document'
+          )
+        }
+
+        newC.p = transformPosition(newC.p, otherC)
+        append(dest, newC)
       }
-    
     } else if (otherC.c != null) {
-      append(dest, c);
-    
+      append(dest, c)
     } else {
-      throw new Error("unknown op type");
+      throw new Error('unknown op type')
     }
   }
-  
-  return dest;
-});
 
-const invertComponent = function(c) {
+  return dest
+}
+
+const invertComponent = function (c) {
   if (c.i != null) {
-    return {d:c.i, p:c.p};
+    return { d: c.i, p: c.p }
   } else {
-    return {i:c.d, p:c.p};
+    return { i: c.d, p: c.p }
   }
-};
+}
 
 // No need to use append for invert, because the components won't be able to
 // cancel with one another.
-text.invert = op => Array.from(op.slice().reverse()).map((c) => invertComponent(c));
-
+text.invert = (op) =>
+  Array.from(op.slice().reverse()).map((c) => invertComponent(c))
 
 if (typeof WEB !== 'undefined' && WEB !== null) {
-  if (!exports.types) { exports.types = {}; }
+  if (!exports.types) {
+    exports.types = {}
+  }
 
   // This is kind of awful - come up with a better way to hook this helper code up.
-  bootstrapTransform(text, transformComponent, checkValidOp, append);
+  bootstrapTransform(text, transformComponent, checkValidOp, append)
 
   // [] is used to prevent closure from renaming types.text
-  exports.types.text = text;
+  exports.types.text = text
 } else {
-  module.exports = text;
+  module.exports = text
 
   // The text type really shouldn't need this - it should be possible to define
   // an efficient transform function by making a sort of transform map and passing each
   // op component through it.
-  require('./helpers').bootstrapTransform(text, transformComponent, checkValidOp, append);
+  require('./helpers').bootstrapTransform(
+    text,
+    transformComponent,
+    checkValidOp,
+    append
+  )
 }
-

+ 2 - 4
services/document-updater/app/js/sharejs/types/web-prelude.js

@@ -9,8 +9,6 @@
    @const
    @type {boolean}
 */
-const WEB = true;
-
-
-const exports = window.sharejs;
+const WEB = true
 
+const exports = window.sharejs

+ 2 - 4
services/document-updater/app/js/sharejs/web-prelude.js

@@ -9,8 +9,6 @@
    @const
    @type {boolean}
 */
-const WEB = true;
-
-
-const exports = window.sharejs;
+const WEB = true
 
+const exports = window.sharejs