syncro 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/README +138 -0
- data/Rakefile +15 -0
- data/VERSION +1 -0
- data/examples/.gitignore +2 -0
- data/examples/test_client.rb +45 -0
- data/examples/test_server.rb +49 -0
- data/lib/syncro.rb +31 -0
- data/lib/syncro/app.rb +92 -0
- data/lib/syncro/base.rb +8 -0
- data/lib/syncro/client.rb +83 -0
- data/lib/syncro/marshal.rb +11 -0
- data/lib/syncro/model.rb +8 -0
- data/lib/syncro/protocol/message.rb +24 -0
- data/lib/syncro/protocol/message_buffer.rb +50 -0
- data/lib/syncro/redis.rb +2 -0
- data/lib/syncro/redis/client.rb +27 -0
- data/lib/syncro/redis/scribe.rb +38 -0
- data/lib/syncro/response.rb +16 -0
- data/lib/syncro/scriber.rb +8 -0
- data/lib/syncro/scriber/base.rb +28 -0
- data/lib/syncro/scriber/model.rb +44 -0
- data/lib/syncro/scriber/observer.rb +127 -0
- data/lib/syncro/scriber/scribe.rb +53 -0
- data/lib/syncro/scriber/scribe_observer.rb +31 -0
- data/syncro.gemspec +72 -0
- metadata +116 -0
data/.gitignore
ADDED
data/README
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
Syncro let's you synchronise Ruby classes and state between remote clients.
|
2
|
+
Syncro also supports offline sync.
|
3
|
+
|
4
|
+
You can record changes to a Ruby class, then Syncro will replay them on a remote client,
|
5
|
+
synchronising state between the two.
|
6
|
+
|
7
|
+
Syncro leaves the connection management up to you. You can use any networking library (EventMachine/TCPSockets etc).
|
8
|
+
|
9
|
+
Each client is represented by a GUID. Even if your architecture isn't P2P clients, the server is
|
10
|
+
considered a "client", and needs a GUID too (even if that's just the string "server").
|
11
|
+
|
12
|
+
Syncro is already setup to support ActiveModel compliant classes (such as ActiveRecord/SuperModel).
|
13
|
+
For example, to synchronise a ActiveRecord class:
|
14
|
+
|
15
|
+
class TestSync < ActiveRecord::Base
|
16
|
+
include Syncro::Model
|
17
|
+
end
|
18
|
+
|
19
|
+
If you want to use Syncro with non ActiveModel compliant classes, you need to implement the class method "sync_play",
|
20
|
+
and create Syncro::Scriber::Scribe objects whenever the class changes - checkout Syncro::Scriber::Model and Syncro::Scriber::Observer.
|
21
|
+
|
22
|
+
To synchronize a class you need to:
|
23
|
+
* Include Syncro::Model on that class
|
24
|
+
* Call the following upon connection:
|
25
|
+
@client = Syncro::Client.for(:client_uuid1)
|
26
|
+
@client.connect(connection_instance)
|
27
|
+
@client.sync
|
28
|
+
* Call the following when the connection receives data:
|
29
|
+
@client.receive_data(data)
|
30
|
+
* Call the following when the client disconnects:
|
31
|
+
@client.disconnect
|
32
|
+
|
33
|
+
That's it!
|
34
|
+
|
35
|
+
Have a look at the example test client and server.
|
36
|
+
|
37
|
+
= More information
|
38
|
+
|
39
|
+
To Syncro, everything is a client - even the server. Every client is represented by a unique identifier.
|
40
|
+
This could be a client ID, or in the case of a server a fixed string.
|
41
|
+
|
42
|
+
Each client records changes to its classes. When a class changes, a 'Scribe' is created detailing that change.
|
43
|
+
Replaying that Scribe on remote clients synchronises class state.
|
44
|
+
|
45
|
+
When a client synchronises, it asks the remote client for all Scribes since the last sync. The client then replays
|
46
|
+
the Scribes, synchronising state. The remote client then does the same. If the clients are connected, and a Scribe
|
47
|
+
is created, the remote client is immediately notified.
|
48
|
+
|
49
|
+
When clients connect for the first time, client objects are created. These record the time synchronisation happened (the last Scribe they processed).
|
50
|
+
|
51
|
+
Scribes & Clients can be stored in two ways:
|
52
|
+
* Marshaled to disk (see SuperModel::Marshal)
|
53
|
+
* In Redis
|
54
|
+
|
55
|
+
ActiveRecord support hasn't been added (but could be easily).
|
56
|
+
|
57
|
+
You should use Redis on the server for performance reasons.
|
58
|
+
|
59
|
+
= Limiting access
|
60
|
+
|
61
|
+
By default, all model changes are synced with everyone. This isn't ideal for a lot of use cases - for example if a user had many pages, those pages are specific to the user and shouldn't be synced with anyone else.
|
62
|
+
|
63
|
+
To limit access, you need to implement the following methods on the class.
|
64
|
+
|
65
|
+
def scribe_clients
|
66
|
+
[authed_client_uuid1, authed_client_uuid2]
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.scribe_authorized?(scribe)
|
70
|
+
# Check scribe.type and scribe.from_client
|
71
|
+
# to work out if the client is authorised to
|
72
|
+
# synchronise this scribe.
|
73
|
+
end
|
74
|
+
|
75
|
+
= Quick example
|
76
|
+
|
77
|
+
The following is an example of using EM, SuperModel and Syncro.
|
78
|
+
Any changes to the class "Test" will be reflected across both clients:
|
79
|
+
|
80
|
+
require "syncro"
|
81
|
+
require "syncro/marshal"
|
82
|
+
require "eventmachine"
|
83
|
+
|
84
|
+
class Test < SuperModel::Base
|
85
|
+
include SuperModel::Marshal::Model
|
86
|
+
include Syncro::Model
|
87
|
+
end
|
88
|
+
|
89
|
+
class MyConnection < EM::Connection
|
90
|
+
def connection_complete
|
91
|
+
@client = Syncro::Client.for(:server)
|
92
|
+
@client.connect(self)
|
93
|
+
@client.sync
|
94
|
+
end
|
95
|
+
|
96
|
+
def receive_data(data)
|
97
|
+
puts "Received: #{data}"
|
98
|
+
@client.receive_data(data)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
SuperModel::Marshal.path = "dump.db"
|
103
|
+
SuperModel::Marshal.load
|
104
|
+
|
105
|
+
at_exit {
|
106
|
+
SuperModel::Marshal.dump
|
107
|
+
}
|
108
|
+
|
109
|
+
EM.run {
|
110
|
+
EM.connect("0.0.0.0", 10000, MyConnection)
|
111
|
+
}
|
112
|
+
|
113
|
+
== Protocol
|
114
|
+
|
115
|
+
Syncro uses a very simple JSON protocol.
|
116
|
+
Each message is a JSON hash. The only mandatory field is "type".
|
117
|
+
{"type" => "foo", ...}
|
118
|
+
|
119
|
+
Each message is preceded by a short int, representing the message size.
|
120
|
+
For example, in Ruby:
|
121
|
+
data = {:type => "foo", :bar => 1}
|
122
|
+
message = [data.length].pack('n') + data
|
123
|
+
|
124
|
+
At the moment, there are only two types of message:
|
125
|
+
* sync (args: from)
|
126
|
+
* add_scribe (args: scribe)
|
127
|
+
|
128
|
+
Have a look at app.rb for implementation details.
|
129
|
+
|
130
|
+
If the messages are split up already, i.e. you don't need the binary length preceding
|
131
|
+
the message, you can instead use the method @client.receive_message(json_string_or_hash)
|
132
|
+
This is particular useful for WebSocket clients.
|
133
|
+
|
134
|
+
== Roadmap
|
135
|
+
|
136
|
+
* A JavaScript Syncro client for web apps that have offline capabilities, and can use WebSockets to
|
137
|
+
sync with the server. WebSockets should be on the iPhone/iPad soon.
|
138
|
+
* Easier protocol extensions, for things like authenticating clients (which are done by subclassing atm).
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
begin
|
2
|
+
require 'jeweler'
|
3
|
+
Jeweler::Tasks.new do |gemspec|
|
4
|
+
gemspec.name = "syncro"
|
5
|
+
gemspec.summary = "Sync Ruby classes between clients."
|
6
|
+
gemspec.email = "info@eribium.org"
|
7
|
+
gemspec.homepage = "http://github.com/maccman/syncro"
|
8
|
+
gemspec.description = "Sync Ruby classes between clients."
|
9
|
+
gemspec.authors = ["Alex MacCaw"]
|
10
|
+
gemspec.add_dependency("activesupport", ">=3.0.0.beta")
|
11
|
+
gemspec.add_dependency("supermodel", ">=0.0.1")
|
12
|
+
end
|
13
|
+
rescue LoadError
|
14
|
+
puts "Jeweler not available. Install it with: sudo gem install jeweler"
|
15
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.3
|
data/examples/.gitignore
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w[.. lib syncro])
|
2
|
+
require "eventmachine"
|
3
|
+
require "supermodel"
|
4
|
+
|
5
|
+
class Test < SuperModel::Base
|
6
|
+
include SuperModel::Marshal::Model
|
7
|
+
include Syncro::Model
|
8
|
+
end
|
9
|
+
|
10
|
+
class MyConnection < EM::Connection
|
11
|
+
def connection_complete
|
12
|
+
@client = Syncro::Client.for(:server)
|
13
|
+
@client.connect(self)
|
14
|
+
@client.sync
|
15
|
+
end
|
16
|
+
|
17
|
+
def receive_data(data)
|
18
|
+
puts "Received: #{data}"
|
19
|
+
@client.receive_data(data)
|
20
|
+
end
|
21
|
+
|
22
|
+
def send_data(data)
|
23
|
+
puts "Sending: #{data}"
|
24
|
+
super(data)
|
25
|
+
end
|
26
|
+
|
27
|
+
def unbind
|
28
|
+
@client.disconnect
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
require "syncro/marshal"
|
33
|
+
|
34
|
+
SuperModel::Marshal.path = "dump_client.db"
|
35
|
+
SuperModel::Marshal.load
|
36
|
+
|
37
|
+
at_exit {
|
38
|
+
SuperModel::Marshal.dump
|
39
|
+
}
|
40
|
+
|
41
|
+
unless $0 =~ /irb/
|
42
|
+
EM.run {
|
43
|
+
EM.connect("0.0.0.0", 10000, MyConnection)
|
44
|
+
}
|
45
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w[.. lib syncro])
|
2
|
+
require "eventmachine"
|
3
|
+
require "supermodel"
|
4
|
+
|
5
|
+
class Test < SuperModel::Base
|
6
|
+
include SuperModel::Marshal::Model
|
7
|
+
include Syncro::Model
|
8
|
+
|
9
|
+
def scribe_clients
|
10
|
+
[:client_uuid1, :client_uuid2]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class MyConnection < EM::Connection
|
15
|
+
def post_init
|
16
|
+
@client = Syncro::Client.for(:client_uuid1)
|
17
|
+
@client.connect(self)
|
18
|
+
@client.sync
|
19
|
+
end
|
20
|
+
|
21
|
+
def receive_data(data)
|
22
|
+
puts "Received: #{data}"
|
23
|
+
@client.receive_data(data)
|
24
|
+
end
|
25
|
+
|
26
|
+
def send_data(data)
|
27
|
+
puts "Sending: #{data}"
|
28
|
+
super(data)
|
29
|
+
end
|
30
|
+
|
31
|
+
def unbind
|
32
|
+
@client.disconnect
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
require "syncro/marshal"
|
37
|
+
|
38
|
+
SuperModel::Marshal.path = "dump_server.db"
|
39
|
+
SuperModel::Marshal.load
|
40
|
+
|
41
|
+
at_exit {
|
42
|
+
SuperModel::Marshal.dump
|
43
|
+
}
|
44
|
+
|
45
|
+
unless $0 =~ /irb/
|
46
|
+
EM.run {
|
47
|
+
EM.start_server("0.0.0.0", 10000, MyConnection)
|
48
|
+
}
|
49
|
+
end
|
data/lib/syncro.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
gem "supermodel"
|
2
|
+
require "supermodel"
|
3
|
+
|
4
|
+
gem "activesupport"
|
5
|
+
require "active_support"
|
6
|
+
require "active_support/core_ext/class/attribute"
|
7
|
+
|
8
|
+
module Syncro
|
9
|
+
class SyncroError < StandardError; end
|
10
|
+
|
11
|
+
def klasses
|
12
|
+
@klasses ||= []
|
13
|
+
end
|
14
|
+
module_function :klasses
|
15
|
+
end
|
16
|
+
|
17
|
+
$:.unshift(File.dirname(__FILE__))
|
18
|
+
|
19
|
+
require "syncro/app"
|
20
|
+
require "syncro/client"
|
21
|
+
require "syncro/base"
|
22
|
+
require "syncro/model"
|
23
|
+
require "syncro/protocol/message"
|
24
|
+
require "syncro/protocol/message_buffer"
|
25
|
+
require "syncro/response"
|
26
|
+
require "syncro/scriber"
|
27
|
+
require "syncro/scriber/base"
|
28
|
+
require "syncro/scriber/model"
|
29
|
+
require "syncro/scriber/observer"
|
30
|
+
require "syncro/scriber/scribe"
|
31
|
+
require "syncro/scriber/scribe_observer"
|
data/lib/syncro/app.rb
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
module Syncro
|
2
|
+
class App
|
3
|
+
attr_reader :client, :message
|
4
|
+
|
5
|
+
def initialize(client)
|
6
|
+
@client = client
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(message)
|
10
|
+
@message = message
|
11
|
+
method = "invoke_#{message.type}"
|
12
|
+
send(method) if respond_to?(method)
|
13
|
+
end
|
14
|
+
|
15
|
+
def sync
|
16
|
+
invoke(:sync, :from => client.last_scribe_id) do |resp|
|
17
|
+
scribes = resp.map {|s|
|
18
|
+
scribe = Scriber::Scribe.new(s)
|
19
|
+
scribe.from_client = client.to_s
|
20
|
+
scribe
|
21
|
+
}
|
22
|
+
allowed_scribes = scribes.select {|s|
|
23
|
+
allowed_klasses.include?(s.klass)
|
24
|
+
}
|
25
|
+
allowed_scribes.each {|s| s.play }
|
26
|
+
|
27
|
+
if scribes.any?
|
28
|
+
client.update_attribute(
|
29
|
+
:last_scribe_id,
|
30
|
+
scribes.last.id
|
31
|
+
)
|
32
|
+
end
|
33
|
+
yield if block_given?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def add_scribe(scribe, &block)
|
38
|
+
invoke(
|
39
|
+
:add_scribe,
|
40
|
+
:scribe => scribe,
|
41
|
+
&block
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
protected
|
46
|
+
def invoke_sync
|
47
|
+
result = begin
|
48
|
+
if message[:from]
|
49
|
+
Scriber::Scribe.since(client, message[:from])
|
50
|
+
else
|
51
|
+
Scriber::Scribe.for_client(client)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
respond(result)
|
55
|
+
end
|
56
|
+
|
57
|
+
def invoke_add_scribe
|
58
|
+
scribe = Scriber::Scribe.new(message[:scribe])
|
59
|
+
scribe.from_client = client.to_s
|
60
|
+
return unless allowed_klasses.include?(scribe.klass)
|
61
|
+
scribe.play
|
62
|
+
respond(true)
|
63
|
+
client.update_attribute(
|
64
|
+
:last_scribe_id,
|
65
|
+
scribe.id
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
def invoke_response
|
70
|
+
Response.call(client, message[:result])
|
71
|
+
end
|
72
|
+
|
73
|
+
def allowed_klasses
|
74
|
+
Syncro.klasses.map(&:to_s)
|
75
|
+
end
|
76
|
+
|
77
|
+
def invoke(type, hash = {}, &block)
|
78
|
+
message = Protocol::Message.new
|
79
|
+
message.type = type
|
80
|
+
message.merge!(hash)
|
81
|
+
Response.expect(client, &block)
|
82
|
+
client.send_message(message)
|
83
|
+
end
|
84
|
+
|
85
|
+
def respond(res = nil)
|
86
|
+
message = Protocol::Message.new
|
87
|
+
message.type = :response
|
88
|
+
message[:result] = res
|
89
|
+
client.send_message(message)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/syncro/base.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
module Syncro
|
2
|
+
class Client < SuperModel::Base
|
3
|
+
include SuperModel::RandomID
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def for(uid)
|
7
|
+
find_or_create_by_uid(uid)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
attributes :uid, :connection, :last_scribe_id
|
12
|
+
|
13
|
+
def connected?
|
14
|
+
!!self.connection
|
15
|
+
end
|
16
|
+
|
17
|
+
def connect(connection)
|
18
|
+
self.connection = connection
|
19
|
+
end
|
20
|
+
|
21
|
+
def disconnect
|
22
|
+
end
|
23
|
+
|
24
|
+
def sync(&block)
|
25
|
+
app.sync(&block)
|
26
|
+
end
|
27
|
+
|
28
|
+
def add_scribe(scribe, &block)
|
29
|
+
app.add_scribe(scribe, &block)
|
30
|
+
end
|
31
|
+
|
32
|
+
def receive_data(data)
|
33
|
+
buffer << data
|
34
|
+
buffer.messages.each do |msg|
|
35
|
+
receive_message(msg)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def receive_message(data)
|
40
|
+
message = begin
|
41
|
+
case data
|
42
|
+
when String
|
43
|
+
Protocol::Message.fromJSON(data)
|
44
|
+
when Protocol::Message
|
45
|
+
data
|
46
|
+
else
|
47
|
+
Protocol::Message.new(data)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
app.call(message)
|
51
|
+
end
|
52
|
+
|
53
|
+
def send_message(message)
|
54
|
+
return unless connected?
|
55
|
+
if connection.respond_to?(:send_message)
|
56
|
+
connection.send_message(message)
|
57
|
+
elsif connection.respond_to?(:send_data)
|
58
|
+
connection.send_data(message.serialize)
|
59
|
+
else
|
60
|
+
connection.write(message.serialize)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def to_s
|
65
|
+
(uid || id).to_s
|
66
|
+
end
|
67
|
+
|
68
|
+
def serializable_hash(options = {})
|
69
|
+
options[:except] ||= []
|
70
|
+
options[:except] << :connection
|
71
|
+
super(options)
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
def app
|
76
|
+
@app ||= App.new(self)
|
77
|
+
end
|
78
|
+
|
79
|
+
def buffer
|
80
|
+
@buffer ||= Protocol::MessageBuffer.new
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
data/lib/syncro/model.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module Syncro
|
4
|
+
module Protocol
|
5
|
+
class Message < HashWithIndifferentAccess
|
6
|
+
def self.fromJSON(str)
|
7
|
+
self.new(JSON.parse(str))
|
8
|
+
end
|
9
|
+
|
10
|
+
def type
|
11
|
+
self[:type].try(:to_sym)
|
12
|
+
end
|
13
|
+
|
14
|
+
def type=(sym)
|
15
|
+
self[:type] = sym
|
16
|
+
end
|
17
|
+
|
18
|
+
def serialize
|
19
|
+
data = self.to_json
|
20
|
+
[data.length].pack('n') + data
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Protocol
|
3
|
+
class MessageBuffer < StringIO
|
4
|
+
# How much left to be read
|
5
|
+
def left
|
6
|
+
size - pos
|
7
|
+
end
|
8
|
+
|
9
|
+
def back(n)
|
10
|
+
seek(n * -1, IO::SEEK_CUR)
|
11
|
+
end
|
12
|
+
|
13
|
+
def trim
|
14
|
+
string.replace(read)
|
15
|
+
end
|
16
|
+
|
17
|
+
def messages
|
18
|
+
messages = []
|
19
|
+
rewind
|
20
|
+
while !eof?
|
21
|
+
break unless left > 2
|
22
|
+
len = read_I16
|
23
|
+
msg = read(len)
|
24
|
+
if !msg || msg.length != len
|
25
|
+
back(2 + msg.length)
|
26
|
+
break
|
27
|
+
end
|
28
|
+
messages << msg
|
29
|
+
end
|
30
|
+
trim
|
31
|
+
wind
|
32
|
+
messages
|
33
|
+
end
|
34
|
+
|
35
|
+
def wind
|
36
|
+
seek(0, IO::SEEK_END)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
def read_I16
|
41
|
+
dat = read(2)
|
42
|
+
len, = dat.unpack('n')
|
43
|
+
if (len > 0x7fff)
|
44
|
+
len = 0 - ((len - 1) ^ 0xffff)
|
45
|
+
end
|
46
|
+
len
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/syncro/redis.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Redis
|
3
|
+
class Syncro::Client
|
4
|
+
include SuperModel::Redis::Model
|
5
|
+
indexes :uid
|
6
|
+
|
7
|
+
# We need to hold the current connections
|
8
|
+
# in memory (as they can't be serialized).
|
9
|
+
|
10
|
+
class_attribute :connections
|
11
|
+
self.connections = {}
|
12
|
+
|
13
|
+
def connection
|
14
|
+
self.class.connections[self.id]
|
15
|
+
end
|
16
|
+
|
17
|
+
def connect(connection)
|
18
|
+
return unless self.id
|
19
|
+
self.class.connections[self.id] = connection
|
20
|
+
end
|
21
|
+
|
22
|
+
def disconnect
|
23
|
+
self.class.connections.delete(self.id)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Redis
|
3
|
+
class Syncro::Scriber::Scribe
|
4
|
+
include SuperModel::Redis::Model
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def since(client, id)
|
8
|
+
items = redis.zrange(redis_key(:clients, client), id, -1)
|
9
|
+
items += redis.zrange(redis_key(:clients, :all), id, -1)
|
10
|
+
items = from_ids(items)
|
11
|
+
items = items.reject {|item|
|
12
|
+
item.from_client == client.to_s
|
13
|
+
}
|
14
|
+
items
|
15
|
+
end
|
16
|
+
|
17
|
+
def for_client(client)
|
18
|
+
since(client, 0)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
serialize :data, :clients
|
23
|
+
|
24
|
+
after_save :index_clients
|
25
|
+
|
26
|
+
protected
|
27
|
+
def index_clients
|
28
|
+
if clients.blank?
|
29
|
+
redis.zadd(self.class.redis_key(:clients, :all), id, id)
|
30
|
+
else
|
31
|
+
clients.each {|client|
|
32
|
+
redis.zadd(self.class.redis_key(:clients, client), id, id)
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Syncro
|
2
|
+
class Response
|
3
|
+
cattr_accessor :callbacks
|
4
|
+
self.callbacks = {}
|
5
|
+
|
6
|
+
def self.expect(client, prok = nil, &block)
|
7
|
+
self.callbacks[client] ||= []
|
8
|
+
self.callbacks[client] << (prok||block)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.call(client, args)
|
12
|
+
callback = self.callbacks[client].shift
|
13
|
+
callback && callback.call(args)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Scriber
|
3
|
+
module Base
|
4
|
+
def self.included(base)
|
5
|
+
Scriber.klasses << base
|
6
|
+
base.extend ClassMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
def scribe_clients
|
10
|
+
:all
|
11
|
+
end
|
12
|
+
|
13
|
+
def scribe_create?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
module ClassMethods
|
18
|
+
def record(type, options = {})
|
19
|
+
options.merge!({
|
20
|
+
:klass => self,
|
21
|
+
:type => type
|
22
|
+
})
|
23
|
+
Scribe.create(options)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Scriber
|
3
|
+
module Model
|
4
|
+
def self.included(base)
|
5
|
+
base.send :include, Base
|
6
|
+
base.extend ClassMethods
|
7
|
+
Observer.instance.add_observer!(base)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def scribe_play(scribe) #:nodoc:
|
12
|
+
return unless scribe_authorized?(scribe)
|
13
|
+
Observer.from(scribe.from_client) do
|
14
|
+
method = "scribe_play_#{scribe.type}"
|
15
|
+
send(method, scribe) if respond_to?(method)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def scribe_play_create(scribe)
|
20
|
+
create(scribe.data)
|
21
|
+
end
|
22
|
+
|
23
|
+
def scribe_play_update(scribe)
|
24
|
+
update(scribe.data[0], scribe.data[1])
|
25
|
+
end
|
26
|
+
|
27
|
+
def scribe_play_destroy(scribe)
|
28
|
+
destroy(scribe.data[0])
|
29
|
+
end
|
30
|
+
|
31
|
+
def scribe_authorized?(scribe)
|
32
|
+
true
|
33
|
+
end
|
34
|
+
|
35
|
+
def scribe_options(value = nil)
|
36
|
+
@scribe_options = value if value
|
37
|
+
@scribe_options ||= {}
|
38
|
+
@scribe_options
|
39
|
+
end
|
40
|
+
alias_method :scribe_options=, :scribe_options
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Scriber
|
3
|
+
class Observer
|
4
|
+
include Singleton
|
5
|
+
class_attribute :observed_methods
|
6
|
+
self.observed_methods = []
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def method_added(method)
|
10
|
+
return unless defined?(ActiveRecord)
|
11
|
+
self.observed_methods += [method] if ActiveRecord::Callbacks::CALLBACKS.include?(method.to_sym)
|
12
|
+
end
|
13
|
+
|
14
|
+
def from_client
|
15
|
+
@from_client
|
16
|
+
end
|
17
|
+
|
18
|
+
def from(client, &block)
|
19
|
+
@from_client = client
|
20
|
+
result = yield
|
21
|
+
@from_client = nil
|
22
|
+
result
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def after_create(rec)
|
27
|
+
rec.class.record(
|
28
|
+
:create,
|
29
|
+
:data => rec.attributes,
|
30
|
+
:clients => active_clients(rec),
|
31
|
+
:from_client => from_client
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def after_update(rec)
|
36
|
+
changed_to = rec.changes.inject({}) {|hash, (key, (from, to))|
|
37
|
+
hash[key] = to
|
38
|
+
hash
|
39
|
+
}
|
40
|
+
rec.class.record(
|
41
|
+
:update,
|
42
|
+
:data => [rec.id, changed_to],
|
43
|
+
:clients => active_clients(rec),
|
44
|
+
:from_client => from_client
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def after_destroy(rec)
|
49
|
+
rec.class.record(
|
50
|
+
:destroy,
|
51
|
+
:data => [rec.id],
|
52
|
+
:clients => active_clients(rec),
|
53
|
+
:from_client => from_client
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
def update(observed_method, object) #:nodoc:
|
58
|
+
return unless respond_to?(observed_method)
|
59
|
+
return unless allowed?(object, observed_method)
|
60
|
+
# Is sending to clients disabled, or no clients specified?
|
61
|
+
return unless scribe_clients(object)
|
62
|
+
# Clients specified, but no non-disabled clients to send too
|
63
|
+
return if scribe_clients(object).any? && active_clients(object).empty?
|
64
|
+
send(observed_method, object)
|
65
|
+
end
|
66
|
+
|
67
|
+
def observed_class_inherited(subclass) #:nodoc:
|
68
|
+
subclass.add_observer(self)
|
69
|
+
end
|
70
|
+
|
71
|
+
def add_observer!(klass)
|
72
|
+
klass.add_observer(self)
|
73
|
+
|
74
|
+
# For ActiveRecord
|
75
|
+
self.class.observed_methods.each do |method|
|
76
|
+
callback = :"_notify_observers_for_#{method}"
|
77
|
+
if (klass.instance_methods & [callback, callback.to_s]).empty?
|
78
|
+
klass.class_eval "def #{callback}; notify_observers(:#{method}); end"
|
79
|
+
klass.send(method, callback)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
protected
|
85
|
+
def allowed?(object, observed_method)
|
86
|
+
return false unless object.scribe_create?
|
87
|
+
|
88
|
+
method = observed_method.to_s
|
89
|
+
method.gsub!(/before_|after_/, "")
|
90
|
+
|
91
|
+
options = object.class.scribe_options
|
92
|
+
|
93
|
+
options[:only] = Array.wrap(options[:only]).map { |n| n.to_s }
|
94
|
+
options[:except] = Array.wrap(options[:except]).map { |n| n.to_s }
|
95
|
+
|
96
|
+
if options[:only].any?
|
97
|
+
return false unless options[:only].include?(method)
|
98
|
+
end
|
99
|
+
|
100
|
+
if options[:except].any?
|
101
|
+
return false if options[:except].include?(method)
|
102
|
+
end
|
103
|
+
true
|
104
|
+
end
|
105
|
+
|
106
|
+
def from_client
|
107
|
+
self.class.from_client
|
108
|
+
end
|
109
|
+
|
110
|
+
def scribe_clients(object)
|
111
|
+
clients = object.scribe_clients
|
112
|
+
case clients
|
113
|
+
when :all
|
114
|
+
[]
|
115
|
+
when Array
|
116
|
+
clients.empty? ? false : clients
|
117
|
+
else
|
118
|
+
false
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def active_clients(object)
|
123
|
+
scribe_clients(object) - [from_client]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Scriber
|
3
|
+
class Scribe < SuperModel::Base
|
4
|
+
include SuperModel::RandomID
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def since(client, id)
|
8
|
+
record = find(id)
|
9
|
+
index = records.index(record)
|
10
|
+
items = records.slice((index + 1)..-1)
|
11
|
+
return [] unless items
|
12
|
+
items = items.select {|item|
|
13
|
+
item.clients.blank? || item.clients.include?(client.to_s)
|
14
|
+
}
|
15
|
+
items = items.reject {|item|
|
16
|
+
item.from_client == client
|
17
|
+
}
|
18
|
+
items.dup
|
19
|
+
rescue SuperModel::UnknownRecord
|
20
|
+
[]
|
21
|
+
end
|
22
|
+
|
23
|
+
def for_client(client)
|
24
|
+
items = records.values.select {|item|
|
25
|
+
item.clients.blank? || item.clients.include?(client.to_s)
|
26
|
+
}
|
27
|
+
items = items.reject {|item|
|
28
|
+
item.from_client == client.to_s
|
29
|
+
}
|
30
|
+
items.dup
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
attributes :klass, :type, :data, :clients, :from_client
|
35
|
+
validates_presence_of :klass, :type
|
36
|
+
|
37
|
+
def play
|
38
|
+
klass.constantize.scribe_play(self)
|
39
|
+
end
|
40
|
+
|
41
|
+
def klass=(klass)
|
42
|
+
write_attribute(:klass, klass.to_s)
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_json(options = {})
|
46
|
+
options[:except] ||= []
|
47
|
+
options[:except] << :clients
|
48
|
+
options[:except] << :from_client
|
49
|
+
super(options)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Syncro
|
2
|
+
module Scriber
|
3
|
+
class ScribeObserver
|
4
|
+
include Singleton
|
5
|
+
|
6
|
+
def after_create(rec)
|
7
|
+
if rec.clients.blank?
|
8
|
+
clients = Client.all.reject {|client|
|
9
|
+
client.to_s == rec.from_client
|
10
|
+
}
|
11
|
+
else
|
12
|
+
clients = rec.clients.map {|r| Client.for(r) }
|
13
|
+
end
|
14
|
+
|
15
|
+
clients.each {|c| c.add_scribe(rec) }
|
16
|
+
end
|
17
|
+
|
18
|
+
def update(observed_method, object) #:nodoc:
|
19
|
+
send(observed_method, object) if respond_to?(observed_method)
|
20
|
+
end
|
21
|
+
|
22
|
+
def observed_class_inherited(subclass) #:nodoc:
|
23
|
+
subclass.add_observer(self)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
Syncro::Scriber::Scribe.add_observer(
|
30
|
+
Syncro::Scriber::ScribeObserver.instance
|
31
|
+
)
|
data/syncro.gemspec
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{syncro}
|
8
|
+
s.version = "0.0.3"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Alex MacCaw"]
|
12
|
+
s.date = %q{2010-03-11}
|
13
|
+
s.description = %q{Sync Ruby classes between clients.}
|
14
|
+
s.email = %q{info@eribium.org}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"README"
|
17
|
+
]
|
18
|
+
s.files = [
|
19
|
+
".gitignore",
|
20
|
+
"README",
|
21
|
+
"Rakefile",
|
22
|
+
"VERSION",
|
23
|
+
"examples/.gitignore",
|
24
|
+
"examples/test_client.rb",
|
25
|
+
"examples/test_server.rb",
|
26
|
+
"lib/syncro.rb",
|
27
|
+
"lib/syncro/app.rb",
|
28
|
+
"lib/syncro/base.rb",
|
29
|
+
"lib/syncro/client.rb",
|
30
|
+
"lib/syncro/marshal.rb",
|
31
|
+
"lib/syncro/model.rb",
|
32
|
+
"lib/syncro/protocol/message.rb",
|
33
|
+
"lib/syncro/protocol/message_buffer.rb",
|
34
|
+
"lib/syncro/redis.rb",
|
35
|
+
"lib/syncro/redis/client.rb",
|
36
|
+
"lib/syncro/redis/scribe.rb",
|
37
|
+
"lib/syncro/response.rb",
|
38
|
+
"lib/syncro/scriber.rb",
|
39
|
+
"lib/syncro/scriber/base.rb",
|
40
|
+
"lib/syncro/scriber/model.rb",
|
41
|
+
"lib/syncro/scriber/observer.rb",
|
42
|
+
"lib/syncro/scriber/scribe.rb",
|
43
|
+
"lib/syncro/scriber/scribe_observer.rb",
|
44
|
+
"syncro.gemspec"
|
45
|
+
]
|
46
|
+
s.homepage = %q{http://github.com/maccman/syncro}
|
47
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
48
|
+
s.require_paths = ["lib"]
|
49
|
+
s.rubygems_version = %q{1.3.6}
|
50
|
+
s.summary = %q{Sync Ruby classes between clients.}
|
51
|
+
s.test_files = [
|
52
|
+
"examples/test_client.rb",
|
53
|
+
"examples/test_server.rb"
|
54
|
+
]
|
55
|
+
|
56
|
+
if s.respond_to? :specification_version then
|
57
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
58
|
+
s.specification_version = 3
|
59
|
+
|
60
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
61
|
+
s.add_runtime_dependency(%q<activesupport>, [">= 3.0.0.beta"])
|
62
|
+
s.add_runtime_dependency(%q<supermodel>, [">= 0.0.1"])
|
63
|
+
else
|
64
|
+
s.add_dependency(%q<activesupport>, [">= 3.0.0.beta"])
|
65
|
+
s.add_dependency(%q<supermodel>, [">= 0.0.1"])
|
66
|
+
end
|
67
|
+
else
|
68
|
+
s.add_dependency(%q<activesupport>, [">= 3.0.0.beta"])
|
69
|
+
s.add_dependency(%q<supermodel>, [">= 0.0.1"])
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
metadata
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: syncro
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 0
|
8
|
+
- 3
|
9
|
+
version: 0.0.3
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Alex MacCaw
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2010-03-11 00:00:00 +00:00
|
18
|
+
default_executable:
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: activesupport
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
segments:
|
28
|
+
- 3
|
29
|
+
- 0
|
30
|
+
- 0
|
31
|
+
- beta
|
32
|
+
version: 3.0.0.beta
|
33
|
+
type: :runtime
|
34
|
+
version_requirements: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: supermodel
|
37
|
+
prerelease: false
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
segments:
|
43
|
+
- 0
|
44
|
+
- 0
|
45
|
+
- 1
|
46
|
+
version: 0.0.1
|
47
|
+
type: :runtime
|
48
|
+
version_requirements: *id002
|
49
|
+
description: Sync Ruby classes between clients.
|
50
|
+
email: info@eribium.org
|
51
|
+
executables: []
|
52
|
+
|
53
|
+
extensions: []
|
54
|
+
|
55
|
+
extra_rdoc_files:
|
56
|
+
- README
|
57
|
+
files:
|
58
|
+
- .gitignore
|
59
|
+
- README
|
60
|
+
- Rakefile
|
61
|
+
- VERSION
|
62
|
+
- examples/.gitignore
|
63
|
+
- examples/test_client.rb
|
64
|
+
- examples/test_server.rb
|
65
|
+
- lib/syncro.rb
|
66
|
+
- lib/syncro/app.rb
|
67
|
+
- lib/syncro/base.rb
|
68
|
+
- lib/syncro/client.rb
|
69
|
+
- lib/syncro/marshal.rb
|
70
|
+
- lib/syncro/model.rb
|
71
|
+
- lib/syncro/protocol/message.rb
|
72
|
+
- lib/syncro/protocol/message_buffer.rb
|
73
|
+
- lib/syncro/redis.rb
|
74
|
+
- lib/syncro/redis/client.rb
|
75
|
+
- lib/syncro/redis/scribe.rb
|
76
|
+
- lib/syncro/response.rb
|
77
|
+
- lib/syncro/scriber.rb
|
78
|
+
- lib/syncro/scriber/base.rb
|
79
|
+
- lib/syncro/scriber/model.rb
|
80
|
+
- lib/syncro/scriber/observer.rb
|
81
|
+
- lib/syncro/scriber/scribe.rb
|
82
|
+
- lib/syncro/scriber/scribe_observer.rb
|
83
|
+
- syncro.gemspec
|
84
|
+
has_rdoc: true
|
85
|
+
homepage: http://github.com/maccman/syncro
|
86
|
+
licenses: []
|
87
|
+
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options:
|
90
|
+
- --charset=UTF-8
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
segments:
|
98
|
+
- 0
|
99
|
+
version: "0"
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
segments:
|
105
|
+
- 0
|
106
|
+
version: "0"
|
107
|
+
requirements: []
|
108
|
+
|
109
|
+
rubyforge_project:
|
110
|
+
rubygems_version: 1.3.6
|
111
|
+
signing_key:
|
112
|
+
specification_version: 3
|
113
|
+
summary: Sync Ruby classes between clients.
|
114
|
+
test_files:
|
115
|
+
- examples/test_client.rb
|
116
|
+
- examples/test_server.rb
|