Skip to content
Browse files

Added simple sync service; updated all sync libraries to use Node sty…

…le calling

Note use of since/optimistic-locking in POST

make test insensitive to audience presence
  • Loading branch information...
1 parent b0f0f38 commit c37320a5252c65f26c2cefb7d4cfd4019d69cf2c @ianb ianb committed Oct 21, 2011
Showing with 476 additions and 17 deletions.
  1. +7 −2 docs/SYNC.md
  2. +1 −1 site/tests/doctestjs
  3. +4 −4 sync/mock-server.js
  4. +227 −0 sync/sync-service.js
  5. +1 −0 sync/tests/index.html
  6. +9 −10 sync/tests/mock-server.html
  7. +227 −0 sync/tests/sync-service.html
View
9 docs/SYNC.md
@@ -91,6 +91,7 @@ After authenticating with the server and getting back the URL of the collection,
`since` is optional; on first sync is should be empty or left off. The server will return an object:
{
+ since: timestamp,
until: timestamp,
incomplete: bool,
applications: {origin: {...}, ...}
@@ -120,15 +121,19 @@ The client should keep track of the last time it sent updates to the server, and
The updates are sent with:
- POST {collection}
+ POST {collection}?since={timestamp}
{origin: {...}, ...}
Each object must have a `last_modified` key. The response is only:
{received: timestamp}
-**NOTE:** the server could potentially check `last_modified` itself and throw away updates? It could indicate in the response what the updates were.
+`since` should be the time of the last GET (that the client did), and the server checks it against the time of the last POST to the collection. If a client is issuing a POST but hasn't seen updates from another client, then this will fail like:
+
+ {status: "failure", reason: "conflict"}
+
+**NOTE:** this is like a precondition (If-Unmodified-Since or If-Match), and there is a response 412 Precondition Failed. We could use those? We are using float timestamps instead of HTTP dates; we could use X-If-Unmodified-Since or use ETag. I don't believe, but am not sure, that we need to understand the timestamps as a newer/older kind of thing. At least for this case (`last_modified` is different).
## User Interface Concerns
2 site/tests/doctestjs
@@ -1 +1 @@
-Subproject commit 575453b085767584426ef94ec1539282bef629b4
+Subproject commit 222a8a8997ad8897ea77329d3cd1cdf20ab06721
View
8 sync/mock-server.js
@@ -23,7 +23,7 @@ MockServer.prototype.login = function (data, callback) {
// A (deliberately) bad login
setTimeout(function () {
if (callback) {
- callback({status: "failed", reason: "audience does not match"});
+ callback({error: "audience does not match"});
}
}, 10);
return;
@@ -39,7 +39,7 @@ MockServer.prototype.login = function (data, callback) {
issuer: "browserid.org"
};
if (callback) {
- callback(self._loginStatus);
+ callback(null, self._loginStatus);
}
}, 10);
};
@@ -78,7 +78,7 @@ MockServer.prototype.get = function (since, callback) {
}
setTimeout(function () {
if (callback) {
- callback(result);
+ callback(null, result);
}
}, 10);
};
@@ -94,7 +94,7 @@ MockServer.prototype.put = function (data, callback) {
this._finishAdd();
setTimeout(function () {
if (callback) {
- callback({received: new Date().getTime()});
+ callback(null, {received: new Date().getTime()});
}
}, 10);
};
View
227 sync/sync-service.js
@@ -0,0 +1,227 @@
+var SyncService = function (args) {
+ if (this === window) {
+ throw 'You forgot new';
+ }
+ this.pollTime = args.pollTime;
+ if (this.pollTime !== null && typeof this.pollTime !== 'number') {
+ throw 'Invalid pollTime argument (should be null or a number): ' + this.pollTime;
+ }
+ this.server = args.server;
+ this.repo = args.repo;
+ this.storage = args.storage || localStorage;
+ var value = this.storage.getItem('lastSyncTime');
+ if (value) {
+ this._lastSyncTime = parseFloat(value);
+ } else {
+ this._lastSyncTime = null;
+ }
+ value = this.storage.getItem('lastSyncPut');
+ if (value) {
+ this._lastSyncPut = parseFloat(value);
+ } else {
+ this._lastSyncPut = null;
+ }
+ // This will get set if the server tells us to back off on polling:
+ this._backoffTime = null;
+};
+
+SyncService.prototype.toString = function () {
+ return '[SyncService pollTime: ' + this.pollTime + ' server: ' + this.server + ']';
+};
+
+SyncService.prototype.login = function (assertionData, callback) {
+ this.server.login(assertionData, callback);
+};
+
+SyncService.prototype.loginStatus = function () {
+ return this.server.loginStatus();
+};
+
+SyncService.prototype.lastSyncTime = function () {
+ return this._lastSyncTime;
+};
+
+SyncService.prototype._setLastSyncTime = function (timestamp) {
+ if (! timestamp) {
+ // FIXME: not sure if it should ever be valid not to give a timestamp
+ timestamp = new Date().getTime();
+ }
+ if (typeof timestamp != 'number') {
+ throw 'Must _setLastSyncTime to number (not ' + timestamp + ')';
+ }
+ this._lastSyncTime = timestamp;
+ this.storage.setItem('lastSyncTime', this._lastSynctime);
+};
+
+SyncService.prototype._setLastSyncPut = function (timestamp) {
+ if (! timestamp) {
+ // FIXME: not sure if it should ever be valid not to give a timestamp
+ timestamp = new Date().getTime();
+ }
+ if (typeof timestamp != 'number') {
+ throw 'Must _setLastSyncPut to number (not ' + timestamp + ')';
+ }
+ this._lastSyncPut = timestamp;
+ this.storage.setItem('lastSyncPut', this._lastSyncPut);
+};
+
+SyncService.prototype.syncNow = function (callback) {
+ var self = this;
+ console.warn('starting syncNow');
+ this._getUpdates(function (error) {
+ if (error) {
+ if (callback) {
+ callback(error);
+ }
+ return;
+ }
+ self._putUpdates(function (error) {
+ console.warn('finished syncNow', error);
+ if (callback) {
+ callback(error);
+ }
+ });
+ });
+};
+
+SyncService.prototype._getUpdates = function (callback) {
+ var self = this;
+ console.log('getUpdates');
+ server.get(this.lastSyncTime(), function (error, results) {
+ // FIXME: check error
+ if (error) {
+ if (callback) {
+ callback(error);
+ }
+ return;
+ }
+ console.log('getUpdates response', results);
+ self._processUpdates(results.applications, function (error) {
+ console.log('processUpdates finished');
+ self._setLastSyncTime(results.until);
+ if (results.incomplete) {
+ self._getUpdates(callback);
+ return;
+ }
+ if (callback) {
+ callback(error);
+ }
+ });
+ });
+};
+
+SyncService.prototype._processUpdates = function (apps, callback) {
+ var appsToAdd = [];
+ var appsToDelete = [];
+ var deletionsToRemove = [];
+ var appsToUpdate = [];
+ var self = this;
+ this.repo.listUninstalled(function (deleted) {
+ var deletedByOrigin = {};
+ for (var i=0; i<deleted.length; i++) {
+ deletedByOrigin[deleted[i].origin] = deleted[i];
+ }
+ this.repo.list(function (existing) {
+ var existingByOrigin = {};
+ for (var i=0; i<existing.length; i++) {
+ existingByOrigin[existing[i].origin] = existing[i];
+ }
+ for (i=0; i<apps.length; i++) {
+ var app = apps[i];
+ if (deletedByOrigin.hasOwnProperty(app.origin)) {
+ if ((! app.deleted) && (app.last_modified > deletedByOrigin[app.origin].last_modified)) {
+ // A deleted app is being re-installed
+ deletionsToRemove.push(app.origin);
+ appsToAdd.push(app);
+ } // Otherwise the local deletion stands
+ continue;
+ }
+ if (existingByOrigin.hasOwnProperty(app.origin)) {
+ if (app.last_modified > existingByOrigin[app.origin].last_modified) {
+ if (app.deleted) {
+ appsToDelete.push(app);
+ } else {
+ appsToUpdate.push(app);
+ }
+ } // Otherwise the local version is newer
+ continue;
+ }
+ // In this case we've never seen this app before
+ appsToAdd.push(app);
+ }
+ var expected = 0;
+ // This is to ensure the callback isn't called until we go through all
+ // the operations
+ var finished = false;
+ var expectedCallback = function () {
+ expected--;
+ if (finished && expected == 0) {
+ callback();
+ }
+ console.log('expectedCallback', expected, finished);
+ };
+ console.log('results', {dr: deletionsToRemove, a: appsToAdd, d: appsToDelete});
+ for (i=0; i<deletionsToRemove.length; i++) {
+ var origin = deletionsToRemove[i];
+ expected++;
+ self.repo.removeDeletion(origin, expectedCallback);
+ }
+ for (i=0; i<appsToAdd.length; i++) {
+ var app = appsToAdd[i];
+ expected++;
+ self.repo.addApplication(app.origin, app, expectedCallback);
+ }
+ for (i=0; i<appsToDelete.length; i++) {
+ var app = appsToDelete[i];
+ expected++;
+ self.repo.uninstall(app.origin, expectedCallback);
+ }
+ if (expected == 0) {
+ // Apparently the operations weren't async, so they've already
+ // all run... or there was nothing to run.
+ callback();
+ }
+ finished = true;
+ }, function (error) {callback(error || true);});
+ }, function (error) {callback(error || true);});
+};
+
+SyncService.prototype._putUpdates = function (callback) {
+ console.log('putUpdates');
+ var now = new Date().getTime();
+ var lastUpdate = this._lastSyncPut;
+ var self = this;
+ this.repo.list(function (appList) {
+ var toUpdate = [];
+ for (var i=0; i<appList.length; i++) {
+ var app = appList[i];
+ if (! app.last_modified) {
+ // FIXME: this should signal some error, but to whom?
+ continue;
+ }
+ if (app.sync) {
+ continue;
+ }
+ if ((! self._lastSyncPut) || app.last_modified > self._lastSyncPut) {
+ toUpdate.push(app);
+ }
+ }
+ if (! toUpdate.length) {
+ if (callback) {
+ callback();
+ }
+ return;
+ }
+ console.log('got updates to putUpdates', toUpdate);
+ server.put(toUpdate, function (error, result) {
+ if (error) {
+ if (callback) {
+ callback(error);
+ }
+ return;
+ }
+ self._setLastSyncPut(now);
+ callback();
+ });
+ }, function (error) {callback(error || true);});
+};
View
1 sync/tests/index.html
@@ -10,6 +10,7 @@
<ul>
<li><a href="mock-server.html">mock-server</a></li>
+ <li><a href="sync-service.html">sync-service</a></li>
</ul>
</body> </html>
View
19 sync/tests/mock-server.html
@@ -38,8 +38,7 @@
> Spy('server.login', {wait: true})
> );
server.login({
- reason: "audience does not match",
- status: "failed"
+ error: "audience does not match"
})
$ writeln(server.loggedIn());
false
@@ -48,7 +47,7 @@
> {assertion: 'test@example.com?a=localhost', audience: 'localhost'},
> Spy('server.login', {wait: true})
> );
-server.login({
+server.login(null, {
audience: "localhost",
email: "test@example.com",
issuer: "browserid.org",
@@ -69,7 +68,7 @@
<pre class="doctest">
$ // The first argument is 'since'; we've never gotten anything...
> server.get(null, Spy('server.get', {wait: true}));
-server.get({
+server.get(null, {
applications: [],
since: 0,
until: ?
@@ -78,7 +77,7 @@
Error: In get(since, ...) since must be a number or null
$ // 0 is basically the same as null...
> server.get(0, Spy('server.get', {wait: true}));
-server.get({
+server.get(null, {
applications: [],
since: 0,
until: ?
@@ -87,12 +86,12 @@
> spy = Spy('server.get');
$ server.get(0, spy);
> spy.wait();
-server.get({
+server.get(null, {
applications: [],
since: 0,
until: ?
})
-$ until = spy.args[0].until;
+$ until = spy.args[1].until;
$ putSpy = Spy('server.put');
$ server.put([{
> last_modified: until+1,
@@ -103,12 +102,12 @@
> install_time: 100
> }], putSpy);
> putSpy.wait();
-server.put({
+server.put(null, {
received: ?
})
$ // Now we should see that update:
> server.get(0, Spy('server.get', {wait: true}));
-server.get({
+server.get(null, {
applications: [
{
install_data: null,
@@ -126,7 +125,7 @@
})
$ // But we won't see it if we have a later since time:
> server.get(until+2, Spy('server.get', {wait: true}));
-server.get({
+server.get(null, {
applications: [],
since: ?,
until: ?
View
227 sync/tests/sync-service.html
@@ -0,0 +1,227 @@
+<html>
+<head>
+<title>Sync Service</title>
+<link rel="stylesheet" href="../../site/tests/doctestjs/doctest.css">
+<script src="../../site/tests/doctestjs/doctest.js"></script>
+<script src="../mock-server.js"></script>
+<script src="../sync-server.js"></script>
+<script src="../sync-service.js"></script>
+
+<script>
+
+// We're writing this mock repo just for this test, so we'll do it inline
+var MockRepo = function () {
+ if (this === window) {
+ throw 'You forgot new';
+ }
+ this._applications = {};
+ this._deleted = {};
+};
+
+MockRepo.prototype.list = function (callback) {
+ var result = [];
+ for (var i in this._applications) {
+ if (this._applications.hasOwnProperty(i)) {
+ result.push(this._applications[i]);
+ }
+ }
+ // Make async for better mocking:
+ setTimeout(function () {callback(result);}, 10);
+}
+
+// Like install, but doesn't fetch manifest and doesn't confirm installation:
+MockRepo.prototype.addApplication = function (origin, installRecord, callback) {
+ this._applications[origin] = installRecord;
+ if (! installRecord.last_modified) {
+ installRecord.last_modified = new Date().getTime();
+ }
+ writeln('Added application ', origin);
+ if (callback) {
+ setTimeout(function () {callback();}, 10);
+ }
+};
+
+MockRepo.prototype.removeDeletion = function (origin, callback) {
+ writeln('Removing deletion', origin);
+ delete this._deleted[origin];
+ if (callback) {
+ setTimeout(function () {
+ callback();
+ }, 10);
+ }
+};
+
+MockRepo.prototype.uninstall = function (origin, callback) {
+ writeln('Removed application ', origin);
+ var existing = !! (this._applications[origin]);
+ delete this._applications[origin];
+ this._deleted[origin] = new Date().getTime();
+ if (callback) {
+ setTimeout(function () {
+ if (existing) {
+ callback(true);
+ } else {
+ callback({error: ["noSuchApplication", "no application exists with the origin: " + origin]});
+ }
+ }, 10);
+ }
+};
+
+MockRepo.prototype.listUninstalled = function (callback) {
+ var result = [];
+ for (var i in this._deleted) {
+ if (this._deleted.hasOwnProperty(i)) {
+ result.push({last_modified: this._deleted[i], origin: i});
+ }
+ }
+ setTimeout(function () {
+ callback(result);
+ }, 10);
+};
+
+</script>
+
+</head>
+<body class="autodoctest">
+
+<h1>Sync Service</h1>
+
+<div>This is a test of the "sync service", that is the library portion
+that communicates with the server and the repository. </div>
+
+<div class="test">
+<h2>Setup</h2>
+
+<div>We'll setup the service. Note that the service goes between a
+server and a repo. We have a "real" mock server, but the repo
+(<code>MockRepo</code>) is just defined inline (view source to see
+it).</div>
+
+<pre class="doctest">
+$ localStorage.setItem('lastSyncPut', null);
+$ localStorage.setItem('lastSyncTime', null);
+$ if (doctest.params.server !== undefined) {
+> server = new Server(doctest.params.server || '/verify');
+> } else {
+> server = new MockServer();
+> }
+$ repo = new MockRepo();
+$ // pollTime: null means we'll manually trigger
+> service = new SyncService({pollTime: null, server: server, repo: repo});
+$ writeln(service);
+[SyncService pollTime: null server: ...]
+$ service.login(
+> {assertion: 'test@example.com', audience: 'localhost'},
+> Spy('service.login', {wait: true}));
+service.login(null, {
+ audience: "localhost",...
+ email: "test@example.com",
+ issuer: "browserid.org",
+ status: "okay",
+ "valid-until": ?
+})
+$ writeln(server._collectionURL || 'none');
+...
+</pre>
+
+</div>
+
+<div class="test">
+<div>Next we'll try installing applications into the repository, and then poke
+the sync service to get it to sync them to the server.</div>
+
+<pre class="doctest">
+$ repo.addApplication(
+> 'http://example.com',
+> {
+> manifest: {name: "test app"},
+> manifest_url: "http://example.com/manifest.webapp",
+> origin: "http://example.com",
+> install_data: null,
+> install_origin: "http://store.example.com",
+> install_time: new Date().getTime()
+> }, Spy('repo.addApplication', {wait: true}));
+Added application http://example.com
+repo.addApplication()
+$ writeln(service.lastSyncTime())
+null
+$ service.syncNow(Spy('service.syncNow', {wait: true}));
+service.syncNow()
+$ // Now we confirm that the update was received by the server
+> server.get(null, Spy('server.get', {wait: true}));
+server.get(null, {
+ applications: [
+ {
+ install_data: null,
+ install_origin: "http://store.example.com",
+ install_time: ?,
+ last_modified: ?,
+ manifest: {name: "test app"},
+ manifest_url: "http://example.com/manifest.webapp",
+ origin: "http://example.com"
+ }
+ ],
+ since: 0,
+ until: ?
+})
+$ server.put([{
+> manifest: {name: "test 2"},
+> manifest_url: "http://2.example.com/manifest.webapp",
+> origin: "http://2.example.com",
+> install_data: ["test"],
+> install_origin: "http://store.example.com",
+> install_time: new Date().getTime()-1,
+> last_modified: new Date().getTime()-1
+> }], Spy('server.put', {wait: true}));
+server.put(null, {received: ?})
+$ service.syncNow(Spy('service.syncNow', {wait: true}));
+Added application http://2.example.com
+service.syncNow()
+$ server.put([{
+> origin: "http://2.example.com",
+> deleted: true,
+> last_modified: new Date().getTime()
+> }], Spy('server.put', {wait: true}));
+server.put(null, {received: ?})
+$ service.syncNow(Spy('service.syncNow', {wait: true}));
+Removed application http://2.example.com
+service.syncNow()
+$ since = new Date().getTime();
+$ repo.addApplication(
+> 'http://2.example.com',
+> {
+> manifest: {name: "test app revised"},
+> manifest_url: "http://2.example.com/manifest.webapp",
+> origin: "http://2.example.com",
+> install_data: ['receipt1'],
+> install_origin: "http://store.example.com",
+> install_time: since,
+> last_modified: since
+> },
+> Spy('repo.addApplication', {wait: true})
+> );
+Added application http://2.example.com
+repo.addApplication()
+$ service.syncNow(Spy('service.syncNow', {wait: true}));
+service.syncNow()
+$ server.get(since, Spy('server.get', {wait: true}));
+server.get(null, {
+ applications: [
+ {
+ install_data: ["receipt1"],
+ install_origin: "http://store.example.com",
+ install_time: ?,
+ last_modified: ?,
+ manifest: {name: "test app revised"},
+ manifest_url: "http://2.example.com/manifest.webapp",
+ origin: "http://2.example.com"
+ }
+ ],
+ since: ?,
+ until: ?
+})
+</pre>
+
+</div>
+
+</body> </html>

0 comments on commit c37320a

Please sign in to comment.
Something went wrong with that request. Please try again.