use callbacks instead of snapshot diffs to figure out what changes to send to the server
authorDan Mills <thunder@mozilla.com>
Fri, 05 Dec 2008 00:39:54 -0800
changeset 45080 b6f872c3030dda1bec4a2a9b7f4bd2ee8045a927
parent 45076 66d3bdc803f093968127327655de29389d5bd69c
child 45081 3a765c3738ccd6262483cc9f0f7491e11a054120
push idunknown
push userunknown
push dateunknown
use callbacks instead of snapshot diffs to figure out what changes to send to the server
services/sync/modules/engines.js
services/sync/modules/engines/bookmarks.js
services/sync/modules/service.js
services/sync/modules/trackers.js
--- a/services/sync/modules/engines.js
+++ b/services/sync/modules/engines.js
@@ -95,17 +95,17 @@ EngineManagerSvc.prototype = {
   unregister: function EngMgr_unregister(val) {
     let name = val;
     if (val instanceof Engine)
       name = val.name;
     delete this._engines[name];
   }
 };
 
-function Engine() {}
+function Engine() { /* subclasses should call this._init() */}
 Engine.prototype = {
   _notify: Wrap.notify,
 
   // "default-engine";
   get name() { throw "name property must be overridden in subclasses"; },
 
   // "Default";
   get displayName() { throw "displayName property must be overriden in subclasses"; },
@@ -129,31 +129,33 @@ Engine.prototype = {
 
   get _json() {
     let json = Cc["@mozilla.org/dom/json;1"].
       createInstance(Ci.nsIJSON);
     this.__defineGetter__("_json", function() json);
     return json;
   },
 
-  // _core, _store and _tracker need to be overridden in subclasses
+  get score() this._tracker.score,
+
+  // _core, _store, and tracker need to be overridden in subclasses
   get _store() {
     let store = new Store();
     this.__defineGetter__("_store", function() store);
     return store;
   },
 
   get _core() {
     let core = new SyncCore(this._store);
     this.__defineGetter__("_core", function() core);
     return core;
   },
 
   get _tracker() {
-    let tracker = new tracker();
+    let tracker = new Tracker();
     this.__defineGetter__("_tracker", function() tracker);
     return tracker;
   },
 
   get engineId() {
     let id = ID.get('Engine:' + this.name);
     if (!id) {
       // Copy the service login from WeaveID
@@ -169,16 +171,20 @@ Engine.prototype = {
     let levelPref = "log.logger.service.engine." + this.name;
     let level = "Debug";
     try { level = Utils.prefs.getCharPref(levelPref); }
     catch (e) { /* ignore unset prefs */ }
 
     this._log = Log4Moz.repository.getLogger("Service." + this.logName);
     this._log.level = Log4Moz.Level[level];
     this._osPrefix = "weave:" + this.name + ":";
+
+    this._tracker; // initialize tracker to load previously changed IDs
+
+    this._log.debug("Engine initialized");
   },
 
   _serializeCommands: function Engine__serializeCommands(commands) {
     let json = this._json.encode(commands);
     //json = json.replace(/ {action/g, "\n {action");
     return json;
   },
 
@@ -200,21 +206,16 @@ Engine.prototype = {
     this._log.debug("Client reset completed successfully");
   },
 
   _sync: function Engine__sync() {
     let self = yield;
     throw "_sync needs to be subclassed";
   },
 
-  _initialUpload: function Engine__initialUpload() {
-    let self = yield;
-    throw "_initialUpload needs to be subclassed";
-  },
-
   _share: function Engine__share(guid, username) {
     let self = yield;
     /* This should be overridden by the engine subclass for each datatype.
        Implementation should share the data node identified by guid,
        and all its children, if any, with the user identified by username. */
     self.done();
   },
 
@@ -242,98 +243,121 @@ Engine.prototype = {
     this._notify("reset-server", "", this._resetServer).async(this, onComplete);
   },
 
   resetClient: function Engine_resetClient(onComplete) {
     this._notify("reset-client", "", this._resetClient).async(this, onComplete);
   }
 };
 
-function NewEngine() {}
-NewEngine.prototype = {
+function SyncEngine() { /* subclasses should call this._init() */ }
+SyncEngine.prototype = {
   __proto__: Engine.prototype,
 
-  get _snapshot() {
-    let snap = new SnapshotStore(this.name);
-    this.__defineGetter__("_snapshot", function() snap);
-    return snap;
-  },
-
-  get lastSync() {
-    try {
-      return Utils.prefs.getCharPref(this.name + ".lastSync");
-    } catch (e) {
-      return 0;
-    }
-  },
-  set lastSync(value) {
-    Utils.prefs.setCharPref(this.name + ".lastSync", value);
-  },
-
-  _incoming: null,
-  get incoming() {
-    if (!this._incoming)
-      this._incoming = [];
-    return this._incoming;
-  },
-
-  _outgoing: null,
-  get outgoing() {
-    if (!this._outgoing)
-      this._outgoing = [];
-    return this._outgoing;
-  },
-
   get baseURL() {
     let url = Utils.prefs.getCharPref("serverURL");
     if (url && url[url.length-1] != '/')
       url = url + '/';
     return url;
   },
 
   get engineURL() {
     return this.baseURL + ID.get('WeaveID').username + '/' + this.name + '/';
   },
 
   get cryptoMetaURL() {
     return this.baseURL + ID.get('WeaveID').username + '/crypto/' + this.name;
   },
 
-  _remoteSetup: function NewEngine__remoteSetup() {
-    let self = yield;
-
-    let meta = yield CryptoMetas.get(self.cb, this.cryptoMetaURL);
-    if (!meta) {
-      let cryptoSvc = Cc["@labs.mozilla.com/Weave/Crypto;1"].
-        getService(Ci.IWeaveCrypto);
-      let symkey = cryptoSvc.generateRandomKey();
-      let pubkey = yield PubKeys.getDefaultKey(self.cb);
-      meta = new CryptoMeta(this.cryptoMetaURL);
-      meta.generateIV();
-      yield meta.addUnwrappedKey(self.cb, pubkey, symkey);
-      yield meta.put(self.cb);
+  get lastSync() {
+    try {
+      return Utils.prefs.getCharPref(this.name + ".lastSync");
+    } catch (e) {
+      return 0;
     }
   },
+  set lastSync(value) {
+    Utils.prefs.setCharPref(this.name + ".lastSync", value);
+  },
 
-  _createRecord: function NewEngine__newCryptoWrapper(id, payload, encrypt) {
+  // XXX these two should perhaps just be a variable inside sync(), but we have
+  //     one or two other methods that use it
+
+  get incoming() {
+    if (!this._incoming)
+      this._incoming = [];
+    return this._incoming;
+  },
+
+  get outgoing() {
+    if (!this._outgoing)
+      this._outgoing = [];
+    return this._outgoing;
+  },
+
+  // Create a new record starting from an ID
+  // Calls _serializeItem to get the actual item, but sometimes needs to be
+  // overridden anyway (to alter parentid or other properties outside the payload)
+  _createRecord: function SyncEngine__newCryptoWrapper(id, encrypt) {
     let self = yield;
 
     let record = new CryptoWrapper();
     record.uri = this.engineURL + id;
     record.encryption = this.cryptoMetaURL;
-    record.cleartext = payload;
-    if (payload.parentGUID) // FIXME: should be parentid
-      record.parentid = payload.parentGUID;
+    record.cleartext = yield this._serializeItem.async(this, self.cb, id);
+
+    if (record.cleartext) {
+      if (record.cleartext.parentid)
+        record.parentid = record.cleartext.parentid;
+      else if (record.cleartext.parentGUID) // FIXME: bookmarks-specific
+        record.parentid = record.cleartext.parentGUID;
+    }
+
     if (encrypt || encrypt == undefined)
       yield record.encrypt(self.cb, ID.get('WeaveCryptoID').password);
 
     self.done(record);
   },
 
-  _recDepth: function NewEngine__recDepth(rec) {
+  // Serialize an item.  This will become the encrypted field in the payload of
+  // a record. Needs to be overridden in a subclass
+  _serializeItem: function SyncEngine__serializeItem(id) {
+    let self = yield;
+    self.done({});
+  },
+
+  _getAllIDs: function SyncEngine__getAllIDs() {
+    let self = yield;
+    self.done({});
+  },
+
+  // Check if a record is "like" another one, even though the IDs are different,
+  // in that case, we'll change the ID of the local item to match
+  // Probably needs to be overridden in a subclass, to change which criteria
+  // make two records "the same one"
+  _recordLike: function SyncEngine__recordLike(a, b) {
+    if (a.parentid != b.parentid)
+      return false;
+    return Utils.deepEquals(a.cleartext, b.cleartext);
+  },
+
+  _changeRecordRefs: function SyncEngine__changeRecordRefs(oldID, newID) {
+    let self = yield;
+    for each (let rec in this.outgoing) {
+      if (rec.parentid == oldID)
+        rec.parentid = newID;
+    }
+  },
+
+  _changeRecordID: function SyncEngine__changeRecordID(oldID, newID) {
+    let self = yield;
+    throw "_changeRecordID must be overridden in a subclass";
+  },
+
+  _recDepth: function SyncEngine__recDepth(rec) {
     // we've calculated depth for this record already
     if (rec.depth)
       return rec.depth;
 
     // record has no parent
     if (!rec.parentid)
       return 0;
 
@@ -344,410 +368,226 @@ NewEngine.prototype = {
         return rec.depth;
       }
     }
 
     // we couldn't find the record's parent, so it's an orphan
     return 0;
   },
 
-  _recordLike: function NewEngine__recordLike(a, b) {
-    // Check that all other properties are the same
-    if (!Utils.deepEquals(a.parentid, b.parentid))
-      return false;
-    for (let key in a.cleartext) {
-      if (key == "parentGUID")
-        continue; // FIXME: bookmarks-specific
-      if (!Utils.deepEquals(a.cleartext[key], b.cleartext[key]))
-        return false;
-    }
-    for (key in b.cleartext) {
-      if (key == "parentGUID")
-        continue; // FIXME: bookmarks-specific
-      if (!Utils.deepEquals(a.cleartext[key], b.cleartext[key]))
-        return false;
-    }
-    return true;
-  },
+  // Any setup that needs to happen at the beginning of each sync.
+  // Makes sure crypto records and keys are all set-up
+  _syncStartup: function SyncEngine__syncStartup() {
+    let self = yield;
+
+    this._log.debug("Ensuring server crypto records are there");
 
-  _changeRecordRefs: function NewEngine__changeRecordRefs(oldID, newID) {
-    let self = yield;
-    for each (let rec in this.outgoing) {
-      if (rec.parentid == oldID)
-        rec.parentid = newID;
+    let meta = yield CryptoMetas.get(self.cb, this.cryptoMetaURL);
+    if (!meta) {
+      let cryptoSvc = Cc["@labs.mozilla.com/Weave/Crypto;1"].
+        getService(Ci.IWeaveCrypto);
+      let symkey = cryptoSvc.generateRandomKey();
+      let pubkey = yield PubKeys.getDefaultKey(self.cb);
+      meta = new CryptoMeta(this.cryptoMetaURL);
+      meta.generateIV();
+      yield meta.addUnwrappedKey(self.cb, pubkey, symkey);
+      yield meta.put(self.cb);
     }
   },
 
-  _changeRecordID: function NewEngine__changeRecordID(oldID, newID) {
-    let self = yield;
-    throw "_changeRecordID must be overridden in a subclass";
-  },
-
-  _sync: function NewEngine__sync() {
+  // Generate outgoing records
+  _generateOutgoing: function SyncEngine__generateOutgoing() {
     let self = yield;
 
-    // STEP 0: Get our crypto records in order
-    this._log.debug("Ensuring server crypto records are there");
-
-    yield this._remoteSetup.async(this, self.cb);
-
-    // STEP 1: Generate outgoing items
     this._log.debug("Calculating client changes");
 
+    // first sync special case: upload all items
     if (!this.lastSync) {
-      // first sync: upload all items
-      let all = this._store.wrap();
-      for (let key in all) {
-        let record = yield this._createRecord.async(this, self.cb, key, all[key]);
-        this.outgoing.push(record);
-      }
-    } else {
-      // we've synced before: use snapshot to upload changes only
-      this._snapshot.load();
-      let newsnap = this._store.wrap();
-      let updates = yield this._core.detectUpdates(self.cb,
-                                                   this._snapshot.data, newsnap);
-      for each (let cmd in updates) {
-        let data = "";
-        if (cmd.action == "create" || cmd.action == "edit")
-          data = newsnap[cmd.GUID];
-        let record = yield this._createRecord.async(this, self.cb, cmd.GUID, data);
-        this.outgoing.push(record);
-      }
+      this._log.info("First sync, uploading all items");
+      let all = yield this._getAllIDs.async(this, self.cb);
+      for (let key in all)
+        this._tracker.addChangedID(key);
     }
 
-    // STEP 2.1: Find new items from server, place into incoming queue
+    // generate queue from changed items list
+    // NOTE we want changed items -> outgoing -> server to be as atomic as
+    // possible, so we clear the changed IDs after we upload the changed records
+    // NOTE2 don't encrypt, we'll do that before uploading instead
+    for (let id in this._tracker.changedIDs) {
+      this.outgoing.push(yield this._createRecord.async(this, self.cb, id, false));
+    }
+  },
+
+  // Generate outgoing records
+  _fetchIncoming: function SyncEngine__fetchIncoming() {
+    let self = yield;
+
     this._log.debug("Downloading server changes");
 
     let newitems = new Collection(this.engineURL);
     newitems.modified = this.lastSync;
     newitems.full = true;
     yield newitems.get(self.cb);
 
     let item;
     while ((item = yield newitems.iter.next(self.cb))) {
       this.incoming.push(item);
     }
+  },
 
-    // STEP 2.2: Decrypt items, then analyze incoming records and sort them
+  // Process incoming records to get them ready for reconciliation and applying later
+  // i.e., decrypt them, and sort them
+  _processIncoming: function SyncEngine__processIncoming() {
+    let self = yield;
+
+    this._log.debug("Decrypting and sorting incoming changes");
+
     for each (let inc in this.incoming) {
       yield inc.decrypt(self.cb, ID.get('WeaveCryptoID').password);
       this._recDepth(inc); // note: doesn't need access to payload
     }
     this.incoming.sort(function(a, b) {
       if ((typeof(a.depth) == "number" && typeof(b.depth) == "undefined") ||
           (typeof(a.depth) == "number" && b.depth == null) ||
           (a.depth > b.depth))
         return 1;
       if ((typeof(a.depth) == "undefined" && typeof(b.depth) == "number") ||
           (a.depth == null && typeof(b.depth) == "number") ||
           (a.depth < b.depth))
         return -1;
-      if (a.cleartext.index > b.cleartext.index)
-        return 1;
-      if (a.cleartext.index < b.cleartext.index)
-        return -1;
+      if (a.cleartext && b.cleartext) {
+        if (a.cleartext.index > b.cleartext.index)
+          return 1;
+        if (a.cleartext.index < b.cleartext.index)
+          return -1;
+      }
       return 0;
     });
+  },
 
-    // STEP 3: Reconcile
+  // Reconciliation has two steps:
+  // 1) Check for the same item (same ID) on both the incoming and outgoing
+  // queues.  This means the same item was modified on this profile and another
+  // at the same time.  In this case, this client wins (which really means, the
+  // last profile you sync wins).
+  // 2) Check if any incoming & outgoing items are actually the same, even
+  // though they have different IDs.  This happens when the same item is added
+  // on two different machines at the same time.  For example, when a profile
+  // is synced for the first time after having (manually or otherwise) imported
+  // bookmarks imported, every bookmark will match this condition.
+  // When two items with different IDs are "the same" we change the local ID to
+  // match the remote one.
+  _reconcile: function SyncEngine__reconcile() {
+    let self = yield;
+
     this._log.debug("Reconciling server/client changes");
 
-    // STEP 3.1: Check for the same item (same ID) on both incoming & outgoing
-    //           queues.  Client one wins in this case.
+    // Check for the same item (same ID) on both incoming & outgoing queues
     let conflicts = [];
     for (let i = 0; i < this.incoming.length; i++) {
       for each (let out in this.outgoing) {
         if (this.incoming[i].id == out.id) {
           conflicts.push({in: this.incoming[i], out: out});
           delete this.incoming[i];
           break;
         }
       }
     }
-    this._incoming = this.incoming.filter(function(i) i); // removes any holes
+    this._incoming = this.incoming.filter(function(n) n); // removes any holes
     if (conflicts.length)
       this._log.debug("Conflicts found.  Conflicting server changes discarded");
 
-    // STEP 3.2: Check if any incoming & outgoing items are really the same but
-    //           with different IDs
+    // Check for items with different IDs which we think are the same one
     for (let i = 0; i < this.incoming.length; i++) {
       for (let o = 0; o < this.outgoing.length; o++) {
         if (this._recordLike(this.incoming[i], this.outgoing[o])) {
           // change refs in outgoing queue
           yield this._changeRecordRefs.async(this, self.cb,
                                              this.outgoing[o].id,
                                              this.incoming[i].id);
           // change actual id of item
-          yield this._changeRecordID.async(this, self.cb,
-                                           this.outgoing[o].id,
-                                           this.incoming[i].id);
+          yield this._changeItemID.async(this, self.cb,
+                                         this.outgoing[o].id,
+                                         this.incoming[i].id);
           delete this.incoming[i];
           delete this.outgoing[o];
           break;
         }
       }
-      this._outgoing = this.outgoing.filter(function(i) i); // removes any holes
+      this._outgoing = this.outgoing.filter(function(n) n); // removes any holes
     }
-    this._incoming = this.incoming.filter(function(i) i); // removes any holes
+    this._incoming = this.incoming.filter(function(n) n); // removes any holes
+  },
 
-    // STEP 4: Apply incoming items
+  // Apply incoming records
+  _applyIncoming: function SyncEngine__applyIncoming() {
+    let self = yield;
     if (this.incoming.length) {
       this._log.debug("Applying server changes");
+      this._tracker.disable();
       let inc;
       while ((inc = this.incoming.shift())) {
         yield this._store.applyIncoming(self.cb, inc);
         if (inc.modified > this.lastSync)
           this.lastSync = inc.modified;
       }
+      this._tracker.enable();
     }
+  },
 
-    // STEP 5: Upload outgoing items
+  // Upload outgoing records
+  _uploadOutgoing: function SyncEngine__uploadOutgoing() {
+    let self = yield;
     if (this.outgoing.length) {
       this._log.debug("Uploading client changes");
       let up = new Collection(this.engineURL);
       let out;
       while ((out = this.outgoing.pop())) {
+        yield out.encrypt(self.cb, ID.get('WeaveCryptoID').password);
         yield up.pushRecord(self.cb, out);
       }
       yield up.post(self.cb);
       if (up.data.modified > this.lastSync)
         this.lastSync = up.data.modified;
     }
-
-    // STEP 6: Save the current snapshot so as to calculate changes at next sync
-    this._log.debug("Saving snapshot for next sync");
-    this._snapshot.data = this._store.wrap();
-    this._snapshot.save();
-
-    self.done();
-  },
-
-  _resetServer: function NewEngine__resetServer() {
-    let self = yield;
-    let all = new Resource(this.engineURL);
-    yield all.delete(self.cb);
-  }
-};
-
-function SyncEngine() {}
-SyncEngine.prototype = {
-  __proto__: new Engine(),
-
-  get _remote() {
-    let remote = new RemoteStore(this);
-    this.__defineGetter__("_remote", function() remote);
-    return remote;
-  },
-
-  get _snapshot() {
-    let snap = new SnapshotStore(this.name);
-    this.__defineGetter__("_snapshot", function() snap);
-    return snap;
-  },
-
-  _resetServer: function SyncEngine__resetServer() {
-    let self = yield;
-    yield this._remote.wipe(self.cb);
+    this._tracker.clearChangedIDs();
   },
 
-  _resetClient: function SyncEngine__resetClient() {
+  // Any cleanup necessary.
+  // Save the current snapshot so as to calculate changes at next sync
+  _syncFinish: function SyncEngine__syncFinish() {
     let self = yield;
-    this._log.debug("Resetting client state");
-    this._snapshot.wipe();
-    this._store.wipe();
-    this._log.debug("Client reset completed successfully");
-  },
-
-  _initialUpload: function Engine__initialUpload() {
-    let self = yield;
-    this._log.info("Initial upload to server");
-    this._snapshot.data = this._store.wrap();
-    this._snapshot.version = 0;
-    this._snapshot.GUID = null; // in case there are other snapshots out there
-    yield this._remote.initialize(self.cb, this._snapshot);
-    this._snapshot.save();
+    this._log.debug("Finishing up sync");
+    this._tracker.resetScore();
   },
 
-  //       original
-  //         / \
-  //      A /   \ B
-  //       /     \
-  // client --C-> server
-  //       \     /
-  //      D \   / C
-  //         \ /
-  //        final
-
-  // If we have a saved snapshot, original == snapshot.  Otherwise,
-  // it's the empty set {}.
-
-  // C is really the diff between server -> final, so if we determine
-  // D we can calculate C from that.  In the case where A and B have
-  // no conflicts, C == A and D == B.
-
-  // Sync flow:
-  // 1) Fetch server deltas
-  // 1.1) Construct current server status from snapshot + server deltas
-  // 1.2) Generate single delta from snapshot -> current server status ("B")
-  // 2) Generate local deltas from snapshot -> current client status ("A")
-  // 3) Reconcile client/server deltas and generate new deltas for them.
-  //    Reconciliation won't generate C directly, we will simply diff
-  //    server->final after step 3.1.
-  // 3.1) Apply local delta with server changes ("D")
-  // 3.2) Append server delta to the delta file and upload ("C")
-
   _sync: function SyncEngine__sync() {
     let self = yield;
 
-    this._log.info("Beginning sync");
-    this._os.notifyObservers(null, "weave:service:sync:engine:start", this.displayName);
-
-    this._snapshot.load();
-
-    try {
-      this._remote.status.data; // FIXME - otherwise we get an error...
-      yield this._remote.openSession(self.cb, this._snapshot);
-
-    } catch (e if e.status == 404) {
-      yield this._initialUpload.async(this, self.cb);
-      return;
-    }
-
-    // 1) Fetch server deltas
-
-    this._os.notifyObservers(null, "weave:service:sync:status", "status.downloading-deltas");
-    let serverSnap = yield this._remote.wrap(self.cb);
-    let serverUpdates = yield this._core.detectUpdates(self.cb,
-                                                       this._snapshot.data, serverSnap);
-
-    // 2) Generate local deltas from snapshot -> current client status
-
-    this._os.notifyObservers(null, "weave:service:sync:status", "status.calculating-differences");
-    let localSnap = new SnapshotStore();
-    localSnap.data = this._store.wrap();
-    this._core.detectUpdates(self.cb, this._snapshot.data, localSnap.data);
-    let localUpdates = yield;
-
-    this._log.trace("local json:\n" + localSnap.serialize());
-    this._log.trace("Local updates: " + this._serializeCommands(localUpdates));
-    this._log.trace("Server updates: " + this._serializeCommands(serverUpdates));
+    yield this._syncStartup.async(this, self.cb);
 
-    if (serverUpdates.length == 0 && localUpdates.length == 0) {
-      this._os.notifyObservers(null, "weave:service:sync:status", "status.no-changes-required");
-      this._log.info("Sync complete: no changes needed on client or server");
-      this._snapshot.version = this._remote.status.data.maxVersion;
-      this._snapshot.save();
-      self.done(true);
-      return;
-    }
-
-    // 3) Reconcile client/server deltas and generate new deltas for them.
-
-    this._os.notifyObservers(null, "weave:service:sync:status", "status.reconciling-updates");
-    this._log.info("Reconciling client/server updates");
-    let ret = yield this._core.reconcile(self.cb, localUpdates, serverUpdates);
-
-    let clientChanges = ret.propagations[0];
-    let serverChanges = ret.propagations[1];
-    let clientConflicts = ret.conflicts[0];
-    let serverConflicts = ret.conflicts[1];
+    // Populate incoming and outgoing queues
+    yield this._generateOutgoing.async(this, self.cb);
+    yield this._fetchIncoming.async(this, self.cb);
 
-    this._log.info("Changes for client: " + clientChanges.length);
-    this._log.info("Predicted changes for server: " + serverChanges.length);
-    this._log.info("Client conflicts: " + clientConflicts.length);
-    this._log.info("Server conflicts: " + serverConflicts.length);
-    this._log.trace("Changes for client: " + this._serializeCommands(clientChanges));
-    this._log.trace("Predicted changes for server: " + this._serializeCommands(serverChanges));
-    this._log.trace("Client conflicts: " + this._serializeConflicts(clientConflicts));
-    this._log.trace("Server conflicts: " + this._serializeConflicts(serverConflicts));
-
-    if (!(clientChanges.length || serverChanges.length ||
-          clientConflicts.length || serverConflicts.length)) {
-      this._os.notifyObservers(null, "weave:service:sync:status", "status.no-changes-required");
-      this._log.info("Sync complete: no changes needed on client or server");
-      this._snapshot.data = localSnap.data;
-      this._snapshot.version = this._remote.status.data.maxVersion;
-      this._snapshot.save();
-      self.done(true);
-      return;
-    }
+    // Decrypt and sort incoming records, then reconcile
+    yield this._processIncoming.async(this, self.cb);
+    yield this._reconcile.async(this, self.cb);
 
-    if (clientConflicts.length || serverConflicts.length)
-      this._log.warn("Conflicts found!  Discarding server changes");
-
-    // 3.1) Apply server changes to local store
-
-    if (clientChanges.length) {
-      this._log.info("Applying changes locally");
-      this._os.notifyObservers(null, "weave:service:sync:status", "status.applying-changes");
-
-      // apply to real store
-      yield this._store.applyCommands.async(this._store, self.cb, clientChanges);
-
-      // get the current state
-      let newSnap = new SnapshotStore();
-      newSnap.data = this._store.wrap();
-
-      // apply to the snapshot we got in step 1, and compare with current state
-      yield localSnap.applyCommands.async(localSnap, self.cb, clientChanges);
-      let diff = yield this._core.detectUpdates(self.cb,
-                                                localSnap.data, newSnap.data);
-      if (diff.length != 0) {
-        this._log.warn("Commands did not apply correctly");
-        this._log.trace("Diff from snapshot+commands -> " +
-                        "new snapshot after commands:\n" +
-                        this._serializeCommands(diff));
-      }
-
-      // update the local snap to the current state, we'll use it below
-      localSnap.data = newSnap.data;
-      localSnap.version = this._remote.status.data.maxVersion;
-    }
-
-    // 3.2) Append server delta to the delta file and upload
+    // Apply incoming records, upload outgoing records
+    yield this._applyIncoming.async(this, self.cb);
+    yield this._uploadOutgoing.async(this, self.cb);
 
-    // Generate a new diff, from the current server snapshot to the
-    // current client snapshot.  In the case where there are no
-    // conflicts, it should be the same as what the resolver returned
-
-    this._os.notifyObservers(null, "weave:service:sync:status",
-                             "status.calculating-differences");
-    let serverDelta = yield this._core.detectUpdates(self.cb,
-                                                     serverSnap, localSnap.data);
-
-    // Log an error if not the same
-    if (!(serverConflicts.length ||
-          Utils.deepEquals(serverChanges, serverDelta)))
-      this._log.warn("Predicted server changes differ from " +
-                     "actual server->client diff (can be ignored in many cases)");
-
-    this._log.info("Actual changes for server: " + serverDelta.length);
-    this._log.trace("Actual changes for server: " +
-                    this._serializeCommands(serverDelta));
+    yield this._syncFinish.async(this, self.cb);
+  },
 
-    if (serverDelta.length) {
-      this._log.info("Uploading changes to server");
-      this._os.notifyObservers(null, "weave:service:sync:status",
-                               "status.uploading-deltas");
-
-      yield this._remote.appendDelta(self.cb, localSnap, serverDelta,
-                                     {maxVersion: this._snapshot.version,
-                                      deltasEncryption: Crypto.defaultAlgorithm});
-      localSnap.version = this._remote.status.data.maxVersion;
-
-      this._log.info("Successfully updated deltas and status on server");
-    }
-
-    this._snapshot.data = localSnap.data;
-    this._snapshot.version = localSnap.version;
-    this._snapshot.save();
-
-    this._log.info("Sync complete");
-    self.done(true);
+  _resetServer: function SyncEngine__resetServer() {
+    let self = yield;
+    let all = new Resource(this.engineURL);
+    yield all.delete(self.cb);
   }
 };
 
 function BlobEngine() {
   // subclasses should call _init
   // we don't call it here because it requires serverPrefix to be set
 }
 BlobEngine.prototype = {
--- a/services/sync/modules/engines/bookmarks.js
+++ b/services/sync/modules/engines/bookmarks.js
@@ -660,17 +660,17 @@ BookmarksSharingManager.prototype = {
     let serverContents = jsonService.decode( json );
 
     // prune tree / get what we want
     this._log.trace("UpdateIncomingShare: Pruning.");
     for (let guid in serverContents) {
       if (serverContents[guid].type != "bookmark")
         delete serverContents[guid];
       else
-        serverContents[guid].parentGUID = mountData.rootGUID;
+        serverContents[guid].parentid = mountData.rootGUID;
     }
 
     /* Wipe old local contents of the folder, starting from the node: */
     this._log.trace("Wiping local contents of incoming share...");
     this._bms.removeFolderChildren( mountData.node );
 
     /* Create diff FROM current contents (i.e. nothing) TO the incoming
      * data from serverContents.  Then apply the diff. */
@@ -706,63 +706,91 @@ BookmarksSharingManager.prototype = {
       let path = this._annoSvc.getItemAnnotation(a[i], SERVER_PATH_ANNO);
       if ( creator == user && path == serverPath ) {
         this._bms.removeFolder( a[i]);
       }
     }
   }
 }
 
-
-
 function BookmarksEngine(pbeId) {
   this._init(pbeId);
 }
 BookmarksEngine.prototype = {
-  __proto__: NewEngine.prototype,
-  get _super() NewEngine.prototype,
+  __proto__: SyncEngine.prototype,
+  get _super() SyncEngine.prototype,
+
+  get name() "bookmarks",
+  get displayName() "Bookmarks",
+  get logName() "BmkEngine",
+  get serverPrefix() "user-data/bookmarks/",
 
-  get name() { return "bookmarks"; },
-  get displayName() { return "Bookmarks"; },
-  get logName() { return "BmkEngine"; },
-  get serverPrefix() { return "user-data/bookmarks/"; },
+  get _store() {
+    let store = new BookmarksStore();
+    this.__defineGetter__("_store", function() store);
+    return store;
+  },
 
-  __store: null,
-  get _store() {
-    if (!this.__store)
-      this.__store = new BookmarksStore();
-    return this.__store;
+  get _core() {
+    let core = new BookmarksSyncCore();
+    this.__defineGetter__("_core", function() core);
+    return core;
+  },
+
+  get _tracker() {
+    let tracker = new BookmarksTracker();
+    this.__defineGetter__("_tracker", function() tracker);
+    return tracker;
   },
 
-  __core: null,
-  get _core() {
-    if (!this.__core)
-      this.__core = new BookmarksSyncCore(this._store);
-    return this.__core;
+  _getAllIDs: function BmkEngine__getAllIDs() {
+    let self = yield;
+    let all = this._store.wrap(); // FIXME: using store is an inefficient hack...
+    delete all["unfiled"];
+    delete all["toolbar"];
+    delete all["menu"];
+    self.done(all);
+  },
+
+  _serializeItem: function BmkEngine__serializeItem(id) {
+    let self = yield;
+    let all = this._store.wrap(); // FIXME OMG SO INEFFICIENT
+    self.done(all[id]);
   },
 
-  __tracker: null,
-  get _tracker() {
-    if (!this.__tracker)
-      this.__tracker = new BookmarksTracker(this);
-    return this.__tracker;
+  _recordLike: function SyncEngine__recordLike(a, b) {
+    if (a.parentid != b.parentid)
+      return false;
+    for (let key in a.cleartext) {
+      if (key == "index")
+        continue;
+      if (!Utils.deepEquals(a.cleartext[key], b.cleartext[key]))
+        return false;
+    }
+    for (key in b.cleartext) {
+      if (key == "index")
+        continue;
+      if (!Utils.deepEquals(a.cleartext[key], b.cleartext[key]))
+        return false;
+    }
+    return true;
   },
 
-  _changeRecordRefs: function NewEngine__changeRecordRefs(oldID, newID) {
+  _changeRecordRefs: function BmkEngine__changeRecordRefs(oldID, newID) {
     let self = yield;
     for each (let rec in this.outgoing) {
       if (rec.parentid == oldID) {
         rec.parentid = newID;
-        rec.cleartext.parentGUID = newID;
+        rec.cleartext.parentid = newID;
         yield rec.encrypt(self.cb, ID.get('WeaveCryptoID').password);
       }
     }
   },
 
-  _changeRecordID: function BSS__changeRecordID(oldID, newID) {
+  _changeRecordID: function BmkEngine__changeRecordID(oldID, newID) {
     let self = yield;
     yield this._store._changeRecordID.async(this._store, self.cb, oldID, newID);
   }
 
   // XXX for sharing, will need to re-add code to get new shares before syncing,
   //     and updating incoming/outgoing shared folders after syncing
 };
 
@@ -804,17 +832,17 @@ BookmarksSyncCore.prototype = {
     //   guaranteed to refer to two different items
     // * The parent GUID check works because reconcile() fixes up the
     //   parent GUIDs as it runs, and the command list is sorted by
     //   depth
     if (!a || !b ||
         a.action != b.action ||
         a.action != "create" ||
         a.data.type != b.data.type ||
-        a.data.parentGUID != b.data.parentGUID ||
+        a.data.parentid != b.data.parentid ||
         a.GUID == b.GUID)
       return false;
 
     // Bookmarks and folders are allowed to be in a different index as long as
     // they are in the same folder.  Separators must be at
     // the same index to qualify for 'likeness'.
     switch (a.data.type) {
     case "bookmark":
@@ -927,33 +955,33 @@ BookmarksStore.prototype = {
   _applyIncoming: function BStore__applyIncoming(record) {
     let self = yield;
 
     if (!this._lookup)
       this.wrap();
 
     this._log.trace("RECORD: " + record.id + " -> " + uneval(record.cleartext));
 
-    if (record.cleartext == "")
+    if (!record.cleartext)
       this._removeCommand({GUID: record.id});
     else if (this._getItemIdForGUID(record.id) < 0)
       this._createCommand({GUID: record.id, data: record.cleartext});
     else {
       let data = Utils.deepCopy(record.cleartext);
       delete data.GUID;
       this._editCommand({GUID: record.id, data: data});
     }
   },
   applyIncoming: function BStore_applyIncoming(onComplete, record) {
     this._applyIncoming.async(this, onComplete, record);
   },
 
   _createCommand: function BStore__createCommand(command) {
     let newId;
-    let parentId = this._getItemIdForGUID(command.data.parentGUID);
+    let parentId = this._getItemIdForGUID(command.data.parentid);
 
     if (parentId < 0) {
       this._log.warn("Creating node with unknown parent -> reparenting to root");
       parentId = this._bms.bookmarksMenuFolder;
     }
 
     switch (command.data.type) {
     case "query":
@@ -1036,30 +1064,36 @@ BookmarksStore.prototype = {
     case "separator":
       this._log.debug(" -> creating separator");
       newId = this._bms.insertSeparator(parentId, command.data.index);
       break;
     default:
       this._log.error("_createCommand: Unknown item type: " + command.data.type);
       break;
     }
-    if (newId)
+    if (newId) {
+      this._log.trace("Setting GUID of new item " + newId + " to " + command.GUID);
       this._bms.setItemGUID(newId, command.GUID);
+      let foo = this._bms.getItemGUID(command.GUID);
+      if (foo == newId)
+        this._log.debug("OK!");
+    }
   },
 
   _removeCommand: function BStore__removeCommand(command) {
     if (command.GUID == "menu" ||
         command.GUID == "toolbar" ||
         command.GUID == "unfiled") {
       this._log.warn("Attempted to remove root node (" + command.GUID +
                      ").  Skipping command.");
       return;
     }
 
     var itemId = this._bms.getItemIdForGUID(command.GUID);
+    this._log.debug("woo: " + itemId);
     if (itemId < 0) {
       this._log.warn("Attempted to remove item " + command.GUID +
                      ", but it does not exist.  Skipping.");
       return;
     }
     var type = this._bms.getItemType(itemId);
 
     switch (type) {
@@ -1085,17 +1119,17 @@ BookmarksStore.prototype = {
     if (command.GUID == "menu" ||
         command.GUID == "toolbar" ||
         command.GUID == "unfiled") {
       this._log.debug("Attempted to edit root node (" + command.GUID +
                       ").  Skipping command.");
       return;
     }
 
-    var itemId = this._bms.getItemIdForGUID(command.GUID);
+    var itemId = this._getItemIdForGUID(command.GUID);
     if (itemId < 0) {
       this._log.debug("Item for GUID " + command.GUID + " not found.  Skipping.");
       return;
     }
     this._log.trace("Editing item " + itemId);
 
     for (let key in command.data) {
       switch (key) {
@@ -1108,35 +1142,35 @@ BookmarksStore.prototype = {
         break;
       case "URI":
         this._bms.changeBookmarkURI(itemId, Utils.makeURI(command.data.URI));
         break;
       case "index":
         let curIdx = this._bms.getItemIndex(itemId);
         if (curIdx != command.data.index) {
           // ignore index if we're going to move the item to another folder altogether
-          if (command.data.parentGUID &&
+          if (command.data.parentid &&
               (this._bms.getFolderIdForItem(itemId) !=
-               this._getItemIdForGUID(command.data.parentGUID)))
+               this._getItemIdForGUID(command.data.parentid)))
             break;
           this._log.trace("Moving item (changing index)");
           this._bms.moveItem(itemId, this._bms.getFolderIdForItem(itemId),
                              command.data.index);
         }
         break;
-      case "parentGUID": {
-        if (command.data.parentGUID &&
+      case "parentid": {
+        if (command.data.parentid &&
             (this._bms.getFolderIdForItem(itemId) !=
-             this._getItemIdForGUID(command.data.parentGUID))) {
+             this._getItemIdForGUID(command.data.parentid))) {
           this._log.trace("Moving item (changing folder)");
           let index = -1;
           if (command.data.index && command.data.index >= 0)
             index = command.data.index;
           this._bms.moveItem(itemId,
-                             this._getItemIdForGUID(command.data.parentGUID), index);
+                             this._getItemIdForGUID(command.data.parentid), index);
         }
       } break;
       case "tags": {
         // filter out null/undefined/empty tags
         let tags = command.data.tags.filter(function(t) t);
         let tagsURI = this._bms.getBookmarkURI(itemId);
         this._ts.untagURI(tagsURI, null);
         this._ts.tagURI(tagsURI, tags);
@@ -1180,20 +1214,22 @@ BookmarksStore.prototype = {
 	break;
       default:
         this._log.warn("Can't change item property: " + key);
         break;
       }
     }
   },
 
-  _changeRecordID: function BSS__changeRecordID(oldID, newID) {
+  _changeItemID: function BSS__changeItemID(oldID, newID) {
     let self = yield;
 
-    var itemId = this._bms.getItemIdForGUID(oldID);
+    var itemId = this._getItemIdForGUID(oldID);
+    if (itemId == null) // toplevel folder
+      return;
     if (itemId < 0) {
       this._log.warn("Can't change GUID " + oldID + " to " +
                       newID + ": Item does not exist");
       return;
     }
 
     var collision = this._getItemIdForGUID(newID);
     if (collision >= 0) {
@@ -1207,26 +1243,26 @@ BookmarksStore.prototype = {
   },
 
   _getNode: function BSS__getNode(folder) {
     let query = this._hsvc.getNewQuery();
     query.setFolders([folder], 1);
     return this._hsvc.executeQuery(query, this._hsvc.getNewQueryOptions()).root;
   },
 
-  __wrap: function BSS___wrap(node, items, parentGUID, index, guidOverride) {
+  __wrap: function BSS___wrap(node, items, parentid, index, guidOverride) {
     let GUID, item;
 
     // we override the guid for the root items, "menu", "toolbar", etc.
     if (guidOverride) {
       GUID = guidOverride;
       item = {};
     } else {
       GUID = this._bms.getItemGUID(node.itemId);
-      item = {parentGUID: parentGUID, index: index};
+      item = {parentid: parentid, index: index};
     }
 
     if (node.type == node.RESULT_TYPE_FOLDER) {
       if (this._ls.isLivemark(node.itemId)) {
         item.type = "livemark";
         let siteURI = this._ls.getSiteURI(node.itemId);
         let feedURI = this._ls.getFeedURI(node.itemId);
         item.siteURI = siteURI? siteURI.spec : "";
@@ -1406,62 +1442,75 @@ BookmarksStore.prototype = {
 };
 
 /*
  * Tracker objects for each engine may need to subclass the
  * getScore routine, which returns the current 'score' for that
  * engine. How the engine decides to set the score is upto it,
  * as long as the value between 0 and 100 actually corresponds
  * to its urgency to sync.
- *
- * Here's an example BookmarksTracker. We don't subclass getScore
- * because the observer methods take care of updating _score which
- * getScore returns by default.
  */
-function BookmarksTracker(engine) {
-  this._init(engine);
+function BookmarksTracker() {
+  this._init();
 }
 BookmarksTracker.prototype = {
   __proto__: Tracker.prototype,
-  _logName: "BMTracker",
+  _logName: "BmkTracker",
+  file: "bookmarks",
+
+  get _bms() {
+    let bms = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+      getService(Ci.nsINavBookmarksService);
+    this.__defineGetter__("_bms", function() bms);
+    return bms;
+  },
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]),
 
-  _init: function BMT__init(engine) {
-    this._engine = engine;
-    this._log = Log4Moz.repository.getLogger("Service." + this._logName);
-    this._score = 0;
-    Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
-      getService(Ci.nsINavBookmarksService).
-      addObserver(this, false);
+  _init: function BMT__init() {
+    this.__proto__.__proto__._init.call(this);
+    this._bms.addObserver(this, false);
   },
 
   // FIXME: not getting any events whatsoever!
 
   /* Every add/remove/change is worth 10 points */
 
   onItemAdded: function BMT_onEndUpdateBatch(itemId, folder, index) {
-    this._log.debug("Item " + itemId + " added, adding to queue");
+    if (!this.enabled)
+      return;
+    let guid = this._bms.getItemGUID(itemId);
+    this._log.debug("Item " + guid + " added, adding to queue");
+    this.addChangedID(guid);
     this._score += 10;
-    //let all = this._engine._store.wrap();
-    //let record = yield this._engine._createRecord.async(this, self.cb, key, all[itemId]);
-    //this._engine.outgoing.push(record);
   },
 
   onItemRemoved: function BMT_onItemRemoved(itemId, folder, index) {
-    this._log.debug("Item " + itemId + " removed, adding to queue");
+    if (!this.enabled)
+      return;
+    let guid = this._bms.getItemGUID(itemId);
+    this._log.debug("Item " + guid + " removed, adding to queue");
+    this.addChangedID(guid);
     this._score += 10;
   },
 
   onItemChanged: function BMT_onItemChanged(itemId, property, isAnnotationProperty, value) {
-    this._log.debug("Item " + itemId + " changed, adding to queue");
+    if (!this.enabled)
+      return;
+    let guid = this._bms.getItemGUID(itemId);
+    this._log.debug("Item " + guid + " changed, adding to queue");
+    this.addChangedID(guid);
     this._score += 10;
   },
 
   onItemMoved: function BMT_onItemMoved(itemId, oldParent, oldIndex, newParent, newIndex) {
-    this._log.debug("Item " + itemId + " moved, adding to queue");
+    if (!this.enabled)
+      return;
+    let guid = this._bms.getItemGUID(itemId);
+    this._log.debug("Item " + guid + " moved, adding to queue");
+    this.addChangedID(guid);
     this._score += 10;
   },
 
   onBeginUpdateBatch: function BMT_onBeginUpdateBatch() {},
   onEndUpdateBatch: function BMT_onEndUpdateBatch() {},
   onItemVisited: function BMT_onItemVisited(itemId, aVisitID, time) {}
 };
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -595,17 +595,17 @@ WeaveSvc.prototype = {
     let engines = Engines.getAll();
     for each (let engine in engines) {
       if (!engine.enabled)
         continue;
 
       if (!(engine.name in this._syncThresholds))
         this._syncThresholds[engine.name] = INITIAL_THRESHOLD;
 
-      let score = engine._tracker.score;
+      let score = engine.score;
       if (score >= this._syncThresholds[engine.name]) {
         this._log.debug(engine.name + " score " + score +
                         " reaches threshold " +
                         this._syncThresholds[engine.name] + "; syncing");
         this._notify(engine.name + "-engine:sync", "",
                      this._syncEngine, engine).async(this, self.cb);
         yield;
 
@@ -636,20 +636,18 @@ WeaveSvc.prototype = {
     if (this._syncError) {
       this._syncError = false;
       throw "Some engines did not sync correctly";
     }
   },
 
   _syncEngine: function WeaveSvc__syncEngine(engine) {
     let self = yield;
-    try {
-      yield engine.sync(self.cb);
-      engine._tracker.resetScore();
-    } catch(e) {
+    try { yield engine.sync(self.cb); }
+    catch(e) {
       // FIXME: FT module is not printing out exceptions - it should be
       this._log.warn("Engine exception: " + e);
       let ok = FaultTolerance.Service.onException(e);
       if (!ok)
         this._syncError = true;
     }
   },
 
--- a/services/sync/modules/trackers.js
+++ b/services/sync/modules/trackers.js
@@ -46,49 +46,147 @@ Cu.import("resource://weave/log4moz.js")
 Cu.import("resource://weave/constants.js");
 Cu.import("resource://weave/util.js");
 Cu.import("resource://weave/async.js");
 
 Function.prototype.async = Async.sugar;
 
 /*
  * Trackers are associated with a single engine and deal with
- * listening for changes to their particular data type
+ * listening for changes to their particular data type.
+ * 
+ * There are two things they keep track of:
+ * 1) A score, indicating how urgently the engine wants to sync
+ * 2) A list of IDs for all the changed items that need to be synced
  * and updating their 'score', indicating how urgently they
  * want to sync.
  *
- * 'score's range from 0 (Nothing's changed)
- * to 100 (I need to sync now!)
- * -1 is also a valid score
- * (don't sync me unless the user specifically requests it)
- *
- * Setting a score outside of this range will raise an exception.
- * Well not yet, but it will :)
  */
 function Tracker() {
   this._init();
 }
 Tracker.prototype = {
   _logName: "Tracker",
-  _score: 0,
+  file: "none",
+
+  get _json() {
+    let json = Cc["@mozilla.org/dom/json;1"].createInstance(Ci.nsIJSON);
+    this.__defineGetter__("_json", function() json);
+    return json;
+  },
 
   _init: function T__init() {
-    this._log = Log4Moz.repository.getLogger("Service." + this._logName);
+    this._log = Log4Moz.repository.getLogger(this._logName);
     this._score = 0;
+    this.loadChangedIDs();
+    this.enable();
+  },
+
+  get enabled() {
+    return this._enabled;
+  },
+
+  enable: function T_enable() {
+    this._enabled = true;
   },
 
-  /* Should be called by service periodically
-   * before deciding which engines to sync
+  disable: function T_disable() {
+    this._enabled = false;
+  },
+
+  /*
+   * Score can be called as often as desired to decide which engines to sync
+   *
+   * Valid values for score:
+   * -1: Do not sync unless the user specifically requests it (almost disabled)
+   * 0: Nothing has changed
+   * 100: Please sync me ASAP!
+   * 
+   * Setting it to other values should (but doesn't currently) throw an exception
    */
   get score() {
     if (this._score >= 100)
       return 100;
     else
       return this._score;
   },
 
-  /* Should be called by service everytime a sync
-   * has been done for an engine
-   */
+  // Should be called by service everytime a sync has been done for an engine
   resetScore: function T_resetScore() {
     this._score = 0;
+  },
+
+  /*
+   * Changed IDs are in an object (hash) to make it easy to check if
+   * one is already set or not.
+   * Note that it would be nice to make these methods asynchronous so
+   * as to not block when writing to disk.  However, these will often
+   * get called from observer callbacks, and so it is better to make
+   * them synchronous.
+   */
+
+  get changedIDs() {
+    let items = {};
+    this.__defineGetter__("changedIDs", function() items);
+    return items;
+  },
+
+  saveChangedIDs: function T_saveChangedIDs() {
+    this._log.trace("Saving changed IDs to disk");
+
+    let file = Utils.getProfileFile(
+      {path: "weave/changes/" + this.file + ".json",
+       autoCreate: true});
+    let out = this._json.encode(this.changedIDs);
+    let [fos] = Utils.open(file, ">");
+    fos.writeString(out);
+    fos.close();
+  },
+
+  loadChangedIDs: function T_loadChangedIDs() {
+    let file = Utils.getProfileFile("weave/changes/" + this.file + ".json");
+    if (!file.exists())
+      return;
+
+    this._log.trace("Loading previously changed IDs from disk");
+
+    try {
+      let [is] = Utils.open(file, "<");
+      let json = Utils.readStream(is);
+      is.close();
+
+      let ids = this._json.decode(json);
+      for (let id in ids) {
+        this.changedIDs[id] = 1;
+      }
+    } catch (e) {
+      this._log.warn("Could not load changed IDs from previous session");
+      this._log.debug("Exception: " + e);
+    }
+  },
+
+  addChangedID: function T_addChangedID(id) {
+    if (!this.enabled)
+      return;
+    if (!this.changedIDs[id]) {
+      this.changedIDs[id] = true;
+      this.saveChangedIDs();
+    }
+  },
+
+  removeChangedID: function T_removeChangedID(id) {
+    if (!this.enabled)
+      return;
+    if (this.changedIDs[id]) {
+      delete this.changedIDs[id];
+      this.saveChangedIDs();
+    }
+  },
+
+  clearChangedIDs: function T_clearChangedIDs() {
+    if (!this.enabled)
+      return;
+    for (let id in this.changedIDs) {
+      delete this.changedIDs[id];
+    }
+    this.saveChangedIDs();
   }
 };