Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync ephemeral presence data #207

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8705c4f
Fix Doc.prototype.destroy
gkubisa Apr 18, 2018
af84be6
Update tested nodejs versions in .travis.yml
gkubisa Apr 18, 2018
09edf92
Add a test
gkubisa Apr 19, 2018
9121baf
Update tested nodejs versions in .travis.yml
gkubisa Apr 18, 2018
4dbefd1
Add .editorconfig
gkubisa Oct 17, 2017
2ef8181
Update mocha
gkubisa Apr 23, 2018
6b687db
Fix Doc.prototype.destroy
gkubisa Apr 18, 2018
1489e36
Fix hasWritePending in op's callback
gkubisa Apr 24, 2018
a4499a5
Implement ephemeral "presence" data sync
gkubisa Apr 16, 2018
33c7264
Execute some callbacks asynchronously
gkubisa Apr 27, 2018
8ff4b33
Don't send presence unnecessarily
gkubisa Apr 30, 2018
0ff380d
Re-sync presence after re-subscribe and re-connect
gkubisa Apr 30, 2018
d67dd6a
Emit presence asynchronously
gkubisa May 1, 2018
e8ec215
Add `submitted` param to `presence` event
gkubisa May 9, 2018
9c291b2
Merge branch 'share/master' into sync-presence
gkubisa Jun 5, 2018
c15448f
Merge 'upstream/master' into fix-doc-destroy
gkubisa Jun 11, 2018
173bf3a
Use the correct variable
gkubisa Jun 13, 2018
054d34d
Small test update
gkubisa Jun 21, 2018
15cdd1d
Update tested nodejs versions
gkubisa Jul 12, 2018
cfca37f
Make destroy wait for unsubscribe
gkubisa Jul 12, 2018
5e009d1
Simplify the code
gkubisa Jul 12, 2018
642ded6
Merge branch 'fix-doc-destroy' into sync-presence
gkubisa Jul 12, 2018
56b726b
Make hasPending depend on inflightPresence and pendingPresence
gkubisa Jul 12, 2018
762496a
Remove cached ops without using setTimeout
gkubisa Jul 10, 2018
e4c5e6d
Remove --exit mocha option
gkubisa Jul 20, 2018
428c46a
Workaround for circular dependency
gkubisa Jul 20, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This .editorconfig file seems extranious and IMO should be removed.


[*]
indent_style = space
indent_size = 2
end_of_line = LF
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
4 changes: 1 addition & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
language: node_js
node_js:
- "10"
- "9"
- "8"
- "6"
- "4"
script: "npm run jshint && npm run test-cover"
script: "ln -s .. node_modules/sharedb; npm run jshint && npm run test-cover"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updates to .travis.yml seem out of scope for this PR. The changes are good, but probably should be kept in a separate PR.

# Send coverage data to Coveralls
after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js"
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tracker](https://github.com/share/sharedb/issues).

- Realtime synchronization of any JSON document
- Concurrent multi-user collaboration
- Realtime synchronization of any ephemeral "presence" data
- Synchronous editing API with asynchronous eventual consistency
- Realtime query subscriptions
- Simple integration with any database - [MongoDB](https://github.com/share/sharedb-mongo), [PostgresQL](https://github.com/share/sharedb-postgres) (experimental)
Expand Down Expand Up @@ -57,6 +58,10 @@ initial data. Then you can submit editing operations on the document (using
OT). Finally you can delete the document with a delete operation. By
default, ShareDB stores all operations forever - nothing is truly deleted.

## User presence synchronization

Presence data represents a user and is automatically synchronized between all clients subscribed to the same document. Its format is defined by the document's [OT Type](https://github.com/ottypes/docs), for example it may contain a user ID and a cursor position in a text document. All clients can modify their own presence data and receive a read-only version of other client's data. Presence data is automatically cleared when a client unsubscribes from the document or disconnects. It is also automatically transformed against applied operations, so that it still makes sense in the context of a modified document, for example a cursor position may be automatically advanced when a user types at the beginning of a text document.

## Server API

### Initialization
Expand Down Expand Up @@ -223,6 +228,9 @@ Unique document ID
`doc.data` _(Object)_
Document contents. Available after document is fetched or subscribed to.

`doc.presence` _(Object)_
Copy link
Contributor

@curran curran Apr 15, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is where the following comment from @nateps and @ericyhwang apply:

Nate would like all the presence-related info to be nested underneath a top-level property, to make it easier to inspect what's related to presence. The current presence map might be doc.presence.currentDataByClient = {'src1': presenceData1, ...}.

More concretely, the docs here could change from doc.presence to doc.presence.currentDataByClient, or whatever name is preferable.

The name doc.presence.current would feel familiar for React developers, as it's similar to ref.current.

Each property under `doc.presence` contains presence data shared by a client subscribed to this document. The property name is an empty string for this client's data and connection IDs for other clients' data.

`doc.fetch(function(err) {...})`
Populate the fields on `doc` with a snapshot of the document from the server.

Expand Down Expand Up @@ -252,6 +260,9 @@ An operation was applied to the data. `source` will be `false` for ops received
`doc.on('del', function(data, source) {...})`
The document was deleted. Document contents before deletion are passed in as an argument. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally.

`doc.on('presence', function(srcList, submitted) {...})`
Presence data has changed. `srcList` is an Array of `doc.presence` property names for which values have changed. `submitted` is `true`, if the event is the result of new presence data being submitted by the local or remote user, otherwise it is `false` - eg if the presence data was transformed against an operation or was cleared on unsubscribe, disconnect or roll-back.

`doc.on('error', function(err) {...})`
There was an error fetching the document or applying an operation.

Expand Down Expand Up @@ -285,6 +296,11 @@ Invokes the given callback function after

Note that `whenNothingPending` does NOT wait for pending `model.query()` calls.

`doc.submitPresence(presenceData[, function(err) {...}])`
Set local presence data and publish it for other clients.
`presenceData` structure depends on the document type.
Presence is synchronized only when subscribed to the document.

### Class: `ShareDB.Query`

`query.ready` _(Boolean)_
Expand Down Expand Up @@ -360,6 +376,9 @@ Additional fields may be added to the error object for debugging context dependi
* 4021 - Invalid client id
* 4022 - Database adapter does not support queries
* 4023 - Cannot project snapshots of this type
* 4024 - OT Type does not support presence
* 4025 - Not subscribed to document
* 4026 - Presence data superseded

### 5000 - Internal error

Expand Down
52 changes: 52 additions & 0 deletions lib/agent.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var hat = require('hat');
var util = require('./util');
var types = require('./types');
var ShareDBError = require('./error');

/**
* Agent deserializes the wire protocol messages received from the stream and
Expand All @@ -25,6 +26,9 @@ function Agent(backend, stream) {
// Map from queryId -> emitter
this.subscribedQueries = {};

// The max presence sequence number received from the client.
this.maxPresenceSeq = 0;

// We need to track this manually to make sure we don't reply to messages
// after the stream was closed.
this.closed = false;
Expand Down Expand Up @@ -98,10 +102,17 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) {
console.error('Doc subscription stream error', collection, id, data.error);
return;
}
if (data.a === 'p') {
// Send other clients' presence data
if (data.src !== agent.clientId) agent.send(data);
return;
}
if (agent._isOwnOp(collection, data)) return;
agent._sendOp(collection, id, data);
});
stream.on('end', function() {
var presence = agent._createPresence(collection, id);
agent.backend.sendPresence(presence);
// The op stream is done sending, so release its reference
var streams = agent.subscribedDocs[collection];
if (!streams) return;
Expand Down Expand Up @@ -268,6 +279,13 @@ Agent.prototype._checkRequest = function(request) {
// Bulk request
if (request.c != null && typeof request.c !== 'string') return 'Invalid collection';
if (typeof request.b !== 'object') return 'Invalid bulk subscribe data';
} else if (request.a === 'p') {
// Presence
if (typeof request.c !== 'string') return 'Invalid collection';
if (typeof request.d !== 'string') return 'Invalid id';
if (typeof request.v !== 'number' || request.v < 0) return 'Invalid version';
if (typeof request.seq !== 'number' || request.seq <= 0) return 'Invalid seq';
if (typeof request.r !== 'undefined' && typeof request.r !== 'boolean') return 'Invalid "request reply" value';
}
};

Expand Down Expand Up @@ -300,6 +318,9 @@ Agent.prototype._handleMessage = function(request, callback) {
var op = this._createOp(request);
if (!op) return callback({code: 4000, message: 'Invalid op message'});
return this._submit(request.c, request.d, op, callback);
case 'p':
var presence = this._createPresence(request.c, request.d, request.p, request.v, request.r, request.seq);
return this._presence(presence, callback);
default:
callback({code: 4000, message: 'Invalid or unknown message'});
}
Expand Down Expand Up @@ -582,3 +603,34 @@ Agent.prototype._createOp = function(request) {
return new DeleteOp(src, request.seq, request.v, request.del);
}
};

Agent.prototype._presence = function(presence, callback) {
if (presence.seq <= this.maxPresenceSeq) {
return process.nextTick(function() {
callback(new ShareDBError(4026, 'Presence data superseded'));
});
}
this.maxPresenceSeq = presence.seq;
if (!this.subscribedDocs[presence.c] || !this.subscribedDocs[presence.c][presence.d]) {
return process.nextTick(function() {
callback(new ShareDBError(4025, 'Cannot send presence. Not subscribed to document: ' + presence.c + ' ' + presence.d));
});
}
this.backend.sendPresence(presence, function(err) {
if (err) return callback(err);
callback(null, { seq: presence.seq });
});
};

Agent.prototype._createPresence = function(collection, id, data, version, requestReply, seq) {
return {
a: 'p',
src: this.clientId,
seq: seq != null ? seq : this.maxPresenceSeq,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!= null checks for null AND undefined. !== null would check only for null.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right sorry I never use equality operator

c: collection,
d: id,
p: data,
v: version,
r: requestReply
};
};
5 changes: 5 additions & 0 deletions lib/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,11 @@ Backend.prototype.getChannels = function(collection, id) {
];
};

Backend.prototype.sendPresence = function(presence, callback) {
var channels = [ this.getDocChannel(presence.c, presence.d) ];
this.pubsub.publish(channels, presence, callback);
};

function pluckIds(snapshots) {
var ids = [];
for (var i = 0; i < snapshots.length; i++) {
Expand Down
22 changes: 22 additions & 0 deletions lib/client/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ Connection.prototype.handleMessage = function(message) {
if (doc) doc._handleOp(err, message);
return;

case 'p':
var doc = this.getExisting(message.c, message.d);
if (doc) doc._handlePresence(err, message);
return;

default:
console.warn('Ignoring unrecognized message', message);
}
Expand Down Expand Up @@ -408,6 +413,23 @@ Connection.prototype.sendOp = function(doc, op) {
this.send(message);
};

Connection.prototype.sendPresence = function(doc, data, requestReply) {
// Ensure the doc is registered so that it receives the reply message
this._addDoc(doc);
var message = {
a: 'p',
c: doc.collection,
d: doc.id,
p: data,
v: doc.version || 0,
seq: this.seq++
};
if (requestReply) {
message.r = true;
}
this.send(message);
};


/**
* Sends a message down the socket
Expand Down