syncro 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|