support for circular structures (e.g. with JSON)

This commit is contained in:
DadaMonad 2015-02-24 16:09:42 +00:00
parent fea6de3bf9
commit 3ba89edf7d
13 changed files with 434 additions and 939 deletions

View File

@ -7,7 +7,7 @@ Yjs is a framework for optimistic concurrency control and automatic conflict res
In the future, we want to enable users to implement their own collaborative types. Currently we provide data types for
* Text
* Json
* Json (even circular structures)
* XML
Unlike other frameworks, Yjs supports P2P message propagation and is not bound to a specific communication protocol. Therefore, Yjs is extremely scalable and can be used in a wide range of application scenarios.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -132,6 +132,22 @@ module.exports = function() {
return this;
};
Operation.prototype._encode = function(json) {
if (json == null) {
json = {};
}
json.type = this.type;
json.uid = this.getUid();
if (this.custom_type != null) {
if (this.custom_type.constructor === String) {
json.custom_type = this.custom_type;
} else {
json.custom_type = this.custom_type._name;
}
}
return json;
};
Operation.prototype.saveOperation = function(name, op) {
if (op == null) {
@ -258,7 +274,7 @@ module.exports = function() {
this.deleted_by = [];
}
callLater = false;
if ((this.parent != null) && !this.isDeleted() && (o != null)) {
if ((this.parent != null) && !this.is_deleted && (o != null)) {
callLater = true;
}
if (o != null) {
@ -273,12 +289,8 @@ module.exports = function() {
this.callOperationSpecificDeleteEvents(o);
}
if ((_ref = this.prev_cl) != null ? _ref.isDeleted() : void 0) {
this.prev_cl.applyDelete();
return this.prev_cl.applyDelete();
}
if (this.content instanceof ops.Operation) {
this.content.applyDelete();
}
return delete this.content;
};
Insert.prototype.cleanup = function() {
@ -298,6 +310,13 @@ module.exports = function() {
}
this.prev_cl.next_cl = this.next_cl;
this.next_cl.prev_cl = this.prev_cl;
if (this.content instanceof ops.Operation && !deleted_earlyer) {
this.content.referenced_by--;
if (this.content.referenced_by <= 0 && !this.content.is_deleted) {
this.content.applyDelete();
}
}
delete this.content;
return Insert.__super__.cleanup.apply(this, arguments);
}
};
@ -317,12 +336,16 @@ module.exports = function() {
};
Insert.prototype.execute = function() {
var distance_to_origin, i, o;
var distance_to_origin, i, o, _base;
if (!this.validateSavedOperations()) {
return false;
} else {
if (this.content instanceof ops.Operation) {
this.content.insert_parent = this;
if ((_base = this.content).referenced_by == null) {
_base.referenced_by = 0;
}
this.content.referenced_by++;
}
if (this.parent != null) {
if (this.prev_cl == null) {
@ -425,15 +448,14 @@ module.exports = function() {
return position;
};
Insert.prototype._encode = function() {
var json, _ref;
json = {
'type': this.type,
'uid': this.getUid(),
'prev': this.prev_cl.getUid(),
'next': this.next_cl.getUid(),
'parent': this.parent.getUid()
};
Insert.prototype._encode = function(json) {
var _ref;
if (json == null) {
json = {};
}
json.prev = this.prev_cl.getUid();
json.next = this.next_cl.getUid();
json.parent = this.parent.getUid();
if (this.origin.type === "Delimiter") {
json.origin = "Delimiter";
} else if (this.origin !== this.prev_cl) {
@ -444,7 +466,7 @@ module.exports = function() {
} else {
json['content'] = JSON.stringify(this.content);
}
return json;
return Insert.__super__._encode.call(this, json);
};
return Insert;
@ -458,38 +480,6 @@ module.exports = function() {
}
return new this(null, content, uid, prev, next, origin, parent);
};
ops.ImmutableObject = (function(_super) {
__extends(ImmutableObject, _super);
function ImmutableObject(custom_type, uid, _at_content) {
this.content = _at_content;
ImmutableObject.__super__.constructor.call(this, custom_type, uid);
}
ImmutableObject.prototype.type = "ImmutableObject";
ImmutableObject.prototype.val = function() {
return this.content;
};
ImmutableObject.prototype._encode = function() {
var json;
json = {
'type': this.type,
'uid': this.getUid(),
'content': this.content
};
return json;
};
return ImmutableObject;
})(ops.Operation);
ops.ImmutableObject.parse = function(json) {
var content, uid;
uid = json['uid'], content = json['content'];
return new this(null, uid, content);
};
ops.Delimiter = (function(_super) {
__extends(Delimiter, _super);

View File

@ -105,20 +105,6 @@ module.exports = function() {
return this._map[property_name];
};
MapManager.prototype._encode = function() {
var json;
json = {
'type': this.type,
'uid': this.getUid()
};
if (this.custom_type.constructor === String) {
json.custom_type = this.custom_type;
} else {
json.custom_type = this.custom_type._name;
}
return json;
};
return MapManager;
})(ops.Operation);
@ -319,20 +305,6 @@ module.exports = function() {
return this;
};
ListManager.prototype._encode = function() {
var json;
json = {
'type': this.type,
'uid': this.getUid()
};
if (this.custom_type.constructor === String) {
json.custom_type = this.custom_type;
} else {
json.custom_type = this.custom_type._name;
}
return json;
};
return ListManager;
})(ops.Operation);
@ -407,15 +379,13 @@ module.exports = function() {
return typeof o.val === "function" ? o.val() : void 0;
};
ReplaceManager.prototype._encode = function() {
var json;
json = {
'type': this.type,
'uid': this.getUid(),
'beginning': this.beginning.getUid(),
'end': this.end.getUid()
};
return json;
ReplaceManager.prototype._encode = function(json) {
if (json == null) {
json = {};
}
json.beginning = this.beginning.getUid();
json.end = this.end.getUid();
return ReplaceManager.__super__._encode.call(this, json);
};
return ReplaceManager;
@ -424,46 +394,13 @@ module.exports = function() {
ops.Replaceable = (function(_super) {
__extends(Replaceable, _super);
function Replaceable(custom_type, content, parent, uid, prev, next, origin, is_deleted) {
function Replaceable(custom_type, content, parent, uid, prev, next, origin) {
this.saveOperation('parent', parent);
Replaceable.__super__.constructor.call(this, custom_type, content, uid, prev, next, origin);
this.is_deleted = is_deleted;
}
Replaceable.prototype.type = "Replaceable";
Replaceable.prototype.val = function() {
if ((this.content != null) && (this.content.getCustomType != null)) {
return this.content.getCustomType();
} else {
return this.content;
}
};
Replaceable.prototype.applyDelete = function() {
var res, _base, _base1, _base2;
res = Replaceable.__super__.applyDelete.apply(this, arguments);
if (this.content != null) {
if (this.next_cl.type !== "Delimiter") {
if (typeof (_base = this.content).deleteAllObservers === "function") {
_base.deleteAllObservers();
}
}
if (typeof (_base1 = this.content).applyDelete === "function") {
_base1.applyDelete();
}
if (typeof (_base2 = this.content).dontSync === "function") {
_base2.dontSync();
}
}
this.content = null;
return res;
};
Replaceable.prototype.cleanup = function() {
return Replaceable.__super__.cleanup.apply(this, arguments);
};
Replaceable.prototype.callOperationSpecificInsertEvents = function() {
var old_value;
if (this.next_cl.type === "Delimiter" && this.prev_cl.type !== "Delimiter") {
@ -503,39 +440,23 @@ module.exports = function() {
}
};
Replaceable.prototype._encode = function() {
var json;
json = {
'type': this.type,
'parent': this.parent.getUid(),
'prev': this.prev_cl.getUid(),
'next': this.next_cl.getUid(),
'uid': this.getUid(),
'is_deleted': this.is_deleted
};
if (this.origin.type === "Delimiter") {
json.origin = "Delimiter";
} else if (this.origin !== this.prev_cl) {
json.origin = this.origin.getUid();
Replaceable.prototype._encode = function(json) {
if (json == null) {
json = {};
}
if (this.content instanceof ops.Operation) {
json['content'] = this.content.getUid();
} else {
if ((this.content != null) && (this.content.creator != null)) {
throw new Error("You must not set creator here!");
}
json['content'] = this.content;
}
return json;
return Replaceable.__super__._encode.call(this, json);
};
return Replaceable;
})(ops.Insert);
ops.Replaceable.parse = function(json) {
var content, custom_type, is_deleted, next, origin, parent, prev, uid;
content = json['content'], parent = json['parent'], uid = json['uid'], prev = json['prev'], next = json['next'], origin = json['origin'], is_deleted = json['is_deleted'], custom_type = json['custom_type'];
return new this(custom_type, content, parent, uid, prev, next, origin, is_deleted);
var content, custom_type, next, origin, parent, prev, uid;
content = json['content'], parent = json['parent'], uid = json['uid'], prev = json['prev'], next = json['next'], origin = json['origin'], custom_type = json['custom_type'];
if (typeof content === "string") {
content = JSON.parse(content);
}
return new this(custom_type, content, parent, uid, prev, next, origin);
};
return basic_ops;
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -145,6 +145,21 @@ module.exports = ()->
l @_encode()
@
#
# @private
# Encode this operation in such a way that it can be parsed by remote peers.
#
_encode: (json = {})->
json.type = @type
json.uid = @getUid()
if @custom_type?
if @custom_type.constructor is String
json.custom_type = @custom_type
else
json.custom_type = @custom_type._name
json
#
# @private
# Operations may depend on other operations (linked lists, etc.).
@ -313,7 +328,7 @@ module.exports = ()->
applyDelete: (o)->
@deleted_by ?= []
callLater = false
if @parent? and not @isDeleted() and o? # o? : if not o?, then the delimiter deleted this Insertion. Furthermore, it would be wrong to call it. TODO: make this more expressive and save
if @parent? and not @is_deleted and o? # o? : if not o?, then the delimiter deleted this Insertion. Furthermore, it would be wrong to call it. TODO: make this more expressive and save
# call iff wasn't deleted earlyer
callLater = true
if o?
@ -328,12 +343,6 @@ module.exports = ()->
# garbage collect prev_cl
@prev_cl.applyDelete()
# delete content
if @content instanceof ops.Operation
@content.applyDelete()
delete @content
cleanup: ()->
if @next_cl.isDeleted()
# delete all ops that delete this insertion
@ -350,6 +359,17 @@ module.exports = ()->
# reconnect left/right
@prev_cl.next_cl = @next_cl
@next_cl.prev_cl = @prev_cl
# delete content
# - we must not do this in applyDelete, because this would lead to inconsistencies
# (e.g. the following operation order must be invertible :
# Insert refers to content, then the content is deleted)
# Therefore, we have to do this in the cleanup
if @content instanceof ops.Operation and not deleted_earlyer
@content.referenced_by--
if @content.referenced_by <= 0 and not @content.is_deleted
@content.applyDelete()
delete @content
super
# else
# Someone inserted something in the meantime.
@ -378,6 +398,8 @@ module.exports = ()->
else
if @content instanceof ops.Operation
@content.insert_parent = @ # TODO: this is probably not necessary and only nice for debugging
@content.referenced_by ?= 0
@content.referenced_by++
if @parent?
if not @prev_cl?
@prev_cl = @parent.beginning
@ -481,15 +503,10 @@ module.exports = ()->
# Convert all relevant information of this operation to the json-format.
# This result can be send to other clients.
#
_encode: ()->
json =
{
'type': @type
'uid' : @getUid()
'prev': @prev_cl.getUid()
'next': @next_cl.getUid()
'parent': @parent.getUid()
}
_encode: (json = {})->
json.prev = @prev_cl.getUid()
json.next = @next_cl.getUid()
json.parent = @parent.getUid()
if @origin.type is "Delimiter"
json.origin = "Delimiter"
@ -500,7 +517,7 @@ module.exports = ()->
json['content'] = @content.getUid()
else
json['content'] = JSON.stringify @content
json
super json
ops.Insert.parse = (json)->
{
@ -515,47 +532,6 @@ module.exports = ()->
content = JSON.parse(content)
new this null, content, uid, prev, next, origin, parent
#
# @nodoc
# Defines an object that is cannot be changed. You can use this to set an immutable string, or a number.
#
class ops.ImmutableObject extends ops.Operation
#
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
# @param {Object} content
#
constructor: (custom_type, uid, @content)->
super custom_type, uid
type: "ImmutableObject"
#
# @return [String] The content of this operation.
#
val : ()->
@content
#
# Encode this operation in such a way that it can be parsed by remote peers.
#
_encode: ()->
json = {
'type': @type
'uid' : @getUid()
'content' : @content
}
json
ops.ImmutableObject.parse = (json)->
{
'uid' : uid
'content' : content
} = json
new this(null, uid, content)
#
# @nodoc
# A delimiter is placed at the end and at the beginning of the associative lists.

View File

@ -79,20 +79,6 @@ module.exports = ()->
rm.execute()
@_map[property_name]
#
# @private
#
_encode: ()->
json = {
'type' : @type
'uid' : @getUid()
}
if @custom_type.constructor is String
json.custom_type = @custom_type
else
json.custom_type = @custom_type._name
json
ops.MapManager.parse = (json)->
{
'uid' : uid
@ -279,21 +265,6 @@ module.exports = ()->
delete_ops.push d._encode()
@
#
# @private
# Encode this operation in such a way that it can be parsed by remote peers.
#
_encode: ()->
json = {
'type': @type
'uid' : @getUid()
}
if @custom_type.constructor is String
json.custom_type = @custom_type
else
json.custom_type = @custom_type._name
json
ops.ListManager.parse = (json)->
{
'uid' : uid
@ -381,15 +352,10 @@ module.exports = ()->
#
# Encode this operation in such a way that it can be parsed by remote peers.
#
_encode: ()->
json =
{
'type': @type
'uid' : @getUid()
'beginning' : @beginning.getUid()
'end' : @end.getUid()
}
json
_encode: (json = {})->
json.beginning = @beginning.getUid()
json.end = @end.getUid()
super json
#
# @nodoc
@ -403,35 +369,12 @@ module.exports = ()->
# @param {ReplaceManager} parent Used to replace this Replaceable with another one.
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
#
constructor: (custom_type, content, parent, uid, prev, next, origin, is_deleted)->
constructor: (custom_type, content, parent, uid, prev, next, origin)->
@saveOperation 'parent', parent
super custom_type, content, uid, prev, next, origin # Parent is already saved by Replaceable
@is_deleted = is_deleted
type: "Replaceable"
#
# Return the content that this operation holds.
#
val: ()->
if @content? and @content.getCustomType?
@content.getCustomType()
else
@content
applyDelete: ()->
res = super
if @content?
if @next_cl.type isnt "Delimiter"
@content.deleteAllObservers?()
@content.applyDelete?()
@content.dontSync?()
@content = null
res
cleanup: ()->
super
#
# This is called, when the Insert-type was successfully executed.
# TODO: consider doing this in a more consistent manner. This could also be
@ -470,30 +413,8 @@ module.exports = ()->
#
# Encode this operation in such a way that it can be parsed by remote peers.
#
_encode: ()->
json =
{
'type': @type
'parent' : @parent.getUid()
'prev': @prev_cl.getUid()
'next': @next_cl.getUid()
'uid' : @getUid()
'is_deleted': @is_deleted
}
if @origin.type is "Delimiter"
json.origin = "Delimiter"
else if @origin isnt @prev_cl
json.origin = @origin.getUid()
if @content instanceof ops.Operation
json['content'] = @content.getUid()
else
# This could be a security concern.
# Throw error if the users wants to trick us
if @content? and @content.creator?
throw new Error "You must not set creator here!"
json['content'] = @content
json
_encode: (json = {})->
super json
ops.Replaceable.parse = (json)->
{
@ -503,10 +424,11 @@ module.exports = ()->
'prev': prev
'next': next
'origin' : origin
'is_deleted': is_deleted
'custom_type' : custom_type
} = json
new this(custom_type, content, parent, uid, prev, next, origin, is_deleted)
if typeof content is "string"
content = JSON.parse(content)
new this(custom_type, content, parent, uid, prev, next, origin)
basic_ops

View File

@ -12,17 +12,6 @@ Y = require "../lib/y.coffee"
Y.Text = require "../lib/Types/Text"
Y.List = require "../lib/Types/List"
compare = (o1, o2)->
if o1._name? and o1._name isnt o2._name
throw new Error "different types"
else if o1._name is "Object"
for name, val of o1.val()
compare(val, o2.val(name))
else if o1._name?
compare(o1.val(), o2.val())
else if o1 isnt o2
throw new Error "different values"
Test = require "./TestSuite"
class JsonTest extends Test
@ -36,12 +25,13 @@ class JsonTest extends Test
type: "JsonTest"
getRandomRoot: (user_num, root)->
getRandomRoot: (user_num, root, depth = @max_depth)->
root ?= @users[user_num]
types = @users[user_num].types
if _.random(0,1) is 1 # take root
if depth is 0 or _.random(0,1) is 1 # take root
root
else # take child
depth--
elems = null
if root._name is "Object"
elems =
@ -58,7 +48,7 @@ class JsonTest extends Test
root
else
p = elems[_.random(0, elems.length-1)]
@getRandomRoot user_num, p
@getRandomRoot user_num, p, depth
getGeneratingFunctions: (user_num)->
super(user_num).concat [
@ -105,7 +95,19 @@ class JsonTest extends Test
l = y.val().length
y.val(_.random(0,l-1), _.random(0,42))
types : [Y.List]
,
f : (y)=> # SET Object Property (circular)
y.val(@getRandomKey(), @getRandomRoot user_num)
types: [Y.Object]
,
f : (y)=> # insert Object mutable (circular)
l = y.val().length
y.val(_.random(0, l-1), @getRandomRoot user_num)
types: [Y.List]
]
###
###
describe "JsonFramework", ->
@timeout 500000
@ -135,7 +137,7 @@ describe "JsonFramework", ->
u1._model.engine.applyOp ops2, true
u2._model.engine.applyOp ops1, true
expect(compare(u1, u2)).to.not.be.undefined
expect(@yTest.compare(u1, u2)).to.not.be.undefined
it "can handle creaton of complex json (1)", ->
@yTest.users[0].val('a', new Y.Text('q'))
@ -291,5 +293,17 @@ describe "JsonFramework", ->
expect(last_task).to.equal("observer2")
u.unobserve observer2
it "can handle circular JSON", ->
u = @yTest.users[0]
u.val("me", u)
@yTest.compareAll()
u.val("stuff", new Y.Object({x: true}))
u.val("same_stuff", u.val("stuff"))
u.val("same_stuff").val("x", 5)
expect(u.val("same_stuff").val("x")).to.equal(5)
@yTest.compareAll()
u.val("stuff").val("y", u.val("stuff"))
@yTest.compareAll()

View File

@ -20,6 +20,7 @@ module.exports = class Test
@time = 0 # denotes to the time when run was started
@ops = 0 # number of operations (used with @time)
@time_now = 0 # current time
@max_depth = 4
@debug = false
@ -97,21 +98,21 @@ module.exports = class Test
getContent: (user_num)->
throw new Error "implement me!"
compare: (o1, o2)->
if o1 is o2
compare: (o1, o2, depth = (@max_depth+1))->
if o1 is o2 or depth <= 0
true
else if o1._name? and o1._name isnt o2._name
throw new Error "different types"
else if o1._name is "Object"
for name, val of o1.val()
@compare(val, o2.val(name))
@compare(val, o2.val(name), depth-1)
else if o1._name?
@compare(o1.val(), o2.val())
@compare(o1.val(), o2.val(), depth-1)
else if o1.constructor is Array and o2.constructor is Array
if o1.length isnt o2.length
throw new Error "The Arrays do not have the same size!"
for o,i in o1
@compare o, o2[i]
@compare o, o2[i], (depth-1)
else if o1 isnt o2
throw new Error "different values"

File diff suppressed because one or more lines are too long

2
y.js

File diff suppressed because one or more lines are too long