jason-rails 0.4.0 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -1
  3. data/.ruby-version +1 -0
  4. data/Gemfile.lock +152 -2
  5. data/README.md +117 -5
  6. data/app/controllers/jason/api/pusher_controller.rb +15 -0
  7. data/app/controllers/jason/api_controller.rb +44 -2
  8. data/client/lib/JasonContext.d.ts +6 -1
  9. data/client/lib/JasonProvider.d.ts +2 -2
  10. data/client/lib/JasonProvider.js +5 -124
  11. data/client/lib/createJasonReducers.js +48 -3
  12. data/client/lib/createOptDis.js +0 -2
  13. data/client/lib/createPayloadHandler.d.ts +9 -1
  14. data/client/lib/createPayloadHandler.js +47 -55
  15. data/client/lib/createServerActionQueue.d.ts +10 -0
  16. data/client/lib/createServerActionQueue.js +48 -0
  17. data/client/lib/createServerActionQueue.test.d.ts +1 -0
  18. data/client/lib/createServerActionQueue.test.js +37 -0
  19. data/client/lib/createTransportAdapter.d.ts +5 -0
  20. data/client/lib/createTransportAdapter.js +20 -0
  21. data/client/lib/index.d.ts +3 -2
  22. data/client/lib/pruneIdsMiddleware.d.ts +2 -0
  23. data/client/lib/pruneIdsMiddleware.js +24 -0
  24. data/client/lib/restClient.d.ts +2 -0
  25. data/client/lib/restClient.js +17 -0
  26. data/client/lib/transportAdapters/actionCableAdapter.d.ts +5 -0
  27. data/client/lib/transportAdapters/actionCableAdapter.js +35 -0
  28. data/client/lib/transportAdapters/pusherAdapter.d.ts +5 -0
  29. data/client/lib/transportAdapters/pusherAdapter.js +68 -0
  30. data/client/lib/useJason.d.ts +5 -0
  31. data/client/lib/useJason.js +94 -0
  32. data/client/lib/useJason.test.d.ts +1 -0
  33. data/client/lib/useJason.test.js +85 -0
  34. data/client/lib/useSub.d.ts +1 -1
  35. data/client/lib/useSub.js +6 -3
  36. data/client/package.json +5 -3
  37. data/client/src/JasonProvider.tsx +5 -123
  38. data/client/src/createJasonReducers.ts +56 -3
  39. data/client/src/createOptDis.ts +0 -2
  40. data/client/src/createPayloadHandler.ts +53 -64
  41. data/client/src/createServerActionQueue.test.ts +42 -0
  42. data/client/src/createServerActionQueue.ts +47 -0
  43. data/client/src/createTransportAdapter.ts +13 -0
  44. data/client/src/pruneIdsMiddleware.ts +24 -0
  45. data/client/src/restClient.ts +14 -0
  46. data/client/src/transportAdapters/actionCableAdapter.ts +38 -0
  47. data/client/src/transportAdapters/pusherAdapter.ts +72 -0
  48. data/client/src/useJason.test.ts +87 -0
  49. data/client/src/useJason.ts +110 -0
  50. data/client/src/useSub.ts +6 -3
  51. data/client/yarn.lock +71 -3
  52. data/config/routes.rb +5 -1
  53. data/jason-rails.gemspec +4 -0
  54. data/lib/jason.rb +61 -1
  55. data/lib/jason/api_model.rb +2 -12
  56. data/lib/jason/broadcaster.rb +19 -0
  57. data/lib/jason/channel.rb +50 -21
  58. data/lib/jason/graph_helper.rb +165 -0
  59. data/lib/jason/includes_helper.rb +108 -0
  60. data/lib/jason/lua_generator.rb +71 -0
  61. data/lib/jason/publisher.rb +82 -37
  62. data/lib/jason/publisher_old.rb +112 -0
  63. data/lib/jason/subscription.rb +349 -97
  64. data/lib/jason/subscription_old.rb +171 -0
  65. data/lib/jason/version.rb +1 -1
  66. metadata +80 -11
  67. data/app/assets/config/jason_engine_manifest.js +0 -1
  68. data/app/assets/images/jason/engine/.keep +0 -0
  69. data/app/assets/stylesheets/jason/engine/application.css +0 -15
  70. data/app/helpers/jason/engine/application_helper.rb +0 -6
  71. data/app/jobs/jason/engine/application_job.rb +0 -6
  72. data/app/mailers/jason/engine/application_mailer.rb +0 -8
  73. data/app/models/jason/engine/application_record.rb +0 -7
  74. data/app/views/layouts/jason/engine/application.html.erb +0 -15
@@ -0,0 +1,71 @@
1
+ class Jason::LuaGenerator
2
+ ## TODO load these scripts and evalsha
3
+ def cache_json(model_name, id, payload)
4
+ cmd = <<~LUA
5
+ local gidx = redis.call('INCR', 'jason:gidx')
6
+ redis.call( 'set', 'jason:cache:' .. ARGV[1] .. ':' .. ARGV[2] .. ':gidx', gidx )
7
+ redis.call( 'hset', 'jason:cache:' .. ARGV[1], ARGV[2], ARGV[3] )
8
+ return gidx
9
+ LUA
10
+
11
+ result = $redis_jason.eval cmd, [], [model_name, id, payload.to_json]
12
+ end
13
+
14
+ def get_payload(model_name, sub_id)
15
+ # If value has changed, return old value and new idx. Otherwise do nothing.
16
+ cmd = <<~LUA
17
+ local t = {}
18
+ local models = {}
19
+ local ids = redis.call('smembers', 'jason:subscriptions:' .. ARGV[2] .. ':ids:' .. ARGV[1])
20
+
21
+ for k,id in pairs(ids) do
22
+ models[#models+1] = redis.call( 'hget', 'jason:cache:' .. ARGV[1], id)
23
+ end
24
+
25
+ t[#t+1] = models
26
+ t[#t+1] = redis.call( 'get', 'jason:subscription:' .. ARGV[2] .. ':' .. ARGV[1] .. ':idx' )
27
+
28
+ return t
29
+ LUA
30
+
31
+ $redis_jason.eval cmd, [], [model_name, sub_id]
32
+ end
33
+
34
+ def get_subscription(model_name, id, sub_id, gidx)
35
+ # If value has changed, return old value and new idx. Otherwise do nothing.
36
+ cmd = <<~LUA
37
+ local last_gidx = redis.call('get', 'jason:cache:' .. ARGV[1] .. ':' .. ARGV[2] .. ':gidx') or 0
38
+
39
+ if (ARGV[4] >= last_gidx) then
40
+ local sub_idx = redis.call( 'incr', 'jason:subscription:' .. ARGV[3] .. ':' .. ARGV[1] .. ':idx' )
41
+ return sub_idx
42
+ else
43
+ return false
44
+ end
45
+ LUA
46
+
47
+ $redis_jason.eval cmd, [], [model_name, id, sub_id, gidx]
48
+ end
49
+
50
+ def update_set_with_diff(key, add_members, remove_members)
51
+ cmd = <<~LUA
52
+ local old_members = redis.call('smembers', KEYS[1])
53
+ local add_size = ARGV[1]
54
+
55
+ for k, m in pairs({unpack(ARGV, 2, add_size + 1)}) do
56
+ redis.call('sadd', KEYS[1], m)
57
+ end
58
+
59
+ for k, m in pairs({unpack(ARGV, add_size + 2, #ARGV)}) do
60
+ redis.call('srem', KEYS[1], m)
61
+ end
62
+
63
+ return old_members
64
+ LUA
65
+
66
+ args = [add_members.size, add_members, remove_members].flatten
67
+
68
+ old_members = $redis_jason.eval cmd, [key], args
69
+ return [old_members, (old_members + add_members - remove_members).uniq]
70
+ end
71
+ end
@@ -1,76 +1,121 @@
1
1
  module Jason::Publisher
2
2
  extend ActiveSupport::Concern
3
3
 
4
+ # Warning: Could be expensive. Mainly useful for rebuilding cache after changing Jason config or on deploy
5
+ def self.cache_all
6
+ Rails.application.eager_load!
7
+ ActiveRecord::Base.descendants.each do |klass|
8
+ klass.cache_all if klass.respond_to?(:cache_all)
9
+ end
10
+ end
11
+
4
12
  def cache_json
5
13
  as_json_config = api_model.as_json_config
6
14
  scope = api_model.scope
7
15
 
16
+ # Exists
8
17
  if self.persisted? && (scope.blank? || self.class.unscoped.send(scope).exists?(self.id))
9
18
  payload = self.reload.as_json(as_json_config)
10
- $redis_jason.hset("jason:#{self.class.name.underscore}:cache", self.id, payload.to_json)
19
+ gidx = Jason::LuaGenerator.new.cache_json(self.class.name.underscore, self.id, payload)
20
+ return [payload, gidx]
21
+ # Has been destroyed
11
22
  else
12
- $redis_jason.hdel("jason:#{self.class.name.underscore}:cache", self.id)
23
+ $redis_jason.hdel("jason:cache:#{self.class.name.underscore}", self.id)
24
+ return []
13
25
  end
14
26
  end
15
27
 
16
- def publish_json
17
- cache_json
28
+ def force_publish_json
29
+ # As-if newly created
30
+ publish_json(self.attributes.map { |k,v| [k, [nil, v]] }.to_h)
31
+ end
32
+
33
+ def publish_json(previous_changes = {})
34
+ payload, gidx = cache_json
35
+
18
36
  return if skip_publish_json
19
- self.class.jason_subscriptions.each do |id, config_json|
20
- config = JSON.parse(config_json)
37
+ subs = jason_subscriptions # Get this first, because could be changed
38
+
39
+ # Situations where IDs may need to change and this can't be immediately determined
40
+ # - An instance is created where it belongs_to an instance under a subscription
41
+ # - An instance belongs_to association changes - e.g. comment.post_id changes to/from one with a subscription
42
+ # - TODO: The value of an instance changes so that it enters/leaves a subscription
43
+
44
+ # TODO: Optimize this, by caching associations rather than checking each time instance is saved
45
+ jason_assocs = self.class.reflect_on_all_associations(:belongs_to).select { |assoc| assoc.klass.respond_to?(:has_jason?) }
46
+ jason_assocs.each do |assoc|
47
+ if previous_changes[assoc.foreign_key].present?
48
+ Jason::Subscription.update_ids(
49
+ self.class.name.underscore,
50
+ id,
51
+ assoc.name.to_s.singularize,
52
+ previous_changes[assoc.foreign_key][0],
53
+ previous_changes[assoc.foreign_key][1]
54
+ )
55
+ elsif (persisted? && @was_a_new_record && send(assoc.foreign_key).present?)
56
+ Jason::Subscription.update_ids(
57
+ self.class.name.underscore,
58
+ id,
59
+ assoc.name.to_s.singularize,
60
+ nil,
61
+ send(assoc.foreign_key)
62
+ )
63
+ end
64
+ end
65
+
66
+ if !persisted? # Deleted
67
+ Jason::Subscription.remove_ids(
68
+ self.class.name.underscore,
69
+ [id]
70
+ )
71
+ end
21
72
 
22
- if (config['conditions'] || {}).all? { |field, value| self.send(field) == value }
23
- Jason::Subscription.new(id: id).update(self.class.name.underscore)
73
+ # - An instance is created where it belongs_to an _all_ subscription
74
+ if previous_changes['id'].present?
75
+ Jason::Subscription.add_id(self.class.name.underscore, id)
76
+ end
77
+
78
+ if persisted?
79
+ jason_subscriptions.each do |sub_id|
80
+ Jason::Subscription.new(id: sub_id).update(self.class.name.underscore, id, payload, gidx)
24
81
  end
25
82
  end
26
83
  end
27
84
 
28
85
  def publish_json_if_changed
29
86
  subscribed_fields = api_model.subscribed_fields
30
- publish_json if (self.previous_changes.keys.map(&:to_sym) & subscribed_fields).present? || !self.persisted?
87
+ publish_json(self.previous_changes) if (self.previous_changes.keys.map(&:to_sym) & subscribed_fields).present? || !self.persisted?
31
88
  end
32
89
 
33
- class_methods do
34
- def subscriptions
35
- $redis_jason.hgetall("jason:#{self.name.underscore}:subscriptions")
36
- end
90
+ def jason_subscriptions
91
+ Jason::Subscription.for_instance(self.class.name.underscore, id)
92
+ end
37
93
 
38
- def jason_subscriptions
39
- $redis_jason.hgetall("jason:#{self.name.underscore}:subscriptions")
40
- end
94
+ def jason_cached_value
95
+ JSON.parse($redis_jason.hget("jason:cache:#{self.class.name.underscore}", id) || '{}')
96
+ end
41
97
 
42
- def publish_all(instances)
43
- instances.each(&:cache_json)
98
+ class_methods do
99
+ def cache_all
100
+ all.each(&:cache_json)
101
+ end
44
102
 
45
- subscriptions.each do |id, config_json|
46
- Jason::Subscription.new(id: id).update(self.name.underscore)
47
- end
103
+ def has_jason?
104
+ true
48
105
  end
49
106
 
50
107
  def flush_cache
51
- $redis_jason.del("jason:#{self.name.underscore}:cache")
108
+ $redis_jason.del("jason:cache:#{self.name.underscore}")
52
109
  end
53
110
 
54
111
  def setup_json
112
+ self.before_save -> {
113
+ @was_a_new_record = new_record?
114
+ }
55
115
  self.after_initialize -> {
56
116
  @api_model = Jason::ApiModel.new(self.class.name.underscore)
57
117
  }
58
118
  self.after_commit :publish_json_if_changed
59
-
60
- include_models = Jason::ApiModel.new(self.name.underscore).include_models
61
-
62
- include_models.map do |assoc|
63
- puts assoc
64
- reflection = self.reflect_on_association(assoc.to_sym)
65
- reflection.klass.after_commit -> {
66
- subscribed_fields = Jason::ApiModel.new(self.class.name.underscore).subscribed_fields
67
- puts subscribed_fields.inspect
68
-
69
- if (self.previous_changes.keys.map(&:to_sym) & subscribed_fields).present?
70
- self.send(reflection.inverse_of.name)&.publish_json
71
- end
72
- }
73
- end
74
119
  end
75
120
 
76
121
  def find_or_create_by_id(params)
@@ -0,0 +1,112 @@
1
+ module Jason::PublisherOld
2
+ extend ActiveSupport::Concern
3
+
4
+ def cache_json
5
+ as_json_config = api_model.as_json_config
6
+ scope = api_model.scope
7
+
8
+ if self.persisted? && (scope.blank? || self.class.unscoped.send(scope).exists?(self.id))
9
+ payload = self.reload.as_json(as_json_config)
10
+ $redis_jason.hset("jason:#{self.class.name.underscore}:cache", self.id, payload.to_json)
11
+ else
12
+ $redis_jason.hdel("jason:#{self.class.name.underscore}:cache", self.id)
13
+ end
14
+ end
15
+
16
+ def publish_json
17
+ cache_json
18
+ return if skip_publish_json
19
+
20
+ self.class.jason_subscriptions.each do |id, config_json|
21
+ config = JSON.parse(config_json)
22
+
23
+ if (config['conditions'] || {}).all? { |field, value| self.send(field) == value }
24
+ Jason::Subscription.new(id: id).update(self.class.name.underscore)
25
+ end
26
+ end
27
+ end
28
+
29
+ def publish_json_if_changed
30
+ subscribed_fields = api_model.subscribed_fields
31
+ publish_json if (self.previous_changes.keys.map(&:to_sym) & subscribed_fields).present? || !self.persisted?
32
+ end
33
+
34
+ class_methods do
35
+ def subscriptions
36
+ $redis_jason.hgetall("jason:#{self.name.underscore}:subscriptions")
37
+ end
38
+
39
+ def jason_subscriptions
40
+ $redis_jason.hgetall("jason:#{self.name.underscore}:subscriptions")
41
+ end
42
+
43
+ def publish_all(instances)
44
+ instances.each(&:cache_json)
45
+
46
+ subscriptions.each do |id, config_json|
47
+ Jason::Subscription.new(id: id).update(self.name.underscore)
48
+ end
49
+ end
50
+
51
+ def flush_cache
52
+ $redis_jason.del("jason:#{self.name.underscore}:cache")
53
+ end
54
+
55
+ def setup_json
56
+ self.after_initialize -> {
57
+ @api_model = Jason::ApiModel.new(self.class.name.underscore)
58
+ }
59
+ self.after_commit :publish_json_if_changed
60
+
61
+ include_models = Jason::ApiModel.new(self.name.underscore).include_models
62
+
63
+ include_models.map do |assoc|
64
+ puts assoc
65
+ reflection = self.reflect_on_association(assoc.to_sym)
66
+ reflection.klass.after_commit -> {
67
+ subscribed_fields = Jason::ApiModel.new(self.class.name.underscore).subscribed_fields
68
+ puts subscribed_fields.inspect
69
+
70
+ if (self.previous_changes.keys.map(&:to_sym) & subscribed_fields).present?
71
+ self.send(reflection.inverse_of.name)&.publish_json
72
+ end
73
+ }
74
+ end
75
+ end
76
+
77
+ def find_or_create_by_id(params)
78
+ object = find_by(id: params[:id])
79
+
80
+ if object
81
+ object.update(params)
82
+ elsif params[:hidden]
83
+ return false ## If an object is passed with hidden = true but didn't already exist, it's safe to never create it
84
+ else
85
+ object = create!(params)
86
+ end
87
+
88
+ object
89
+ end
90
+
91
+ def find_or_create_by_id!(params)
92
+ object = find_by(id: params[:id])
93
+
94
+ if object
95
+ object.update!(params)
96
+ elsif params[:hidden]
97
+ ## TODO: We're diverging from semantics of the Rails bang! methods here, which would normally either raise or return an object. Find a way to make this better.
98
+ return false ## If an object is passed with hidden = true but didn't already exist, it's safe to never create it
99
+ else
100
+ object = create!(params)
101
+ end
102
+
103
+ object
104
+ end
105
+ end
106
+
107
+ included do
108
+ attr_accessor :skip_publish_json, :api_model
109
+
110
+ setup_json
111
+ end
112
+ end
@@ -1,31 +1,317 @@
1
1
  class Jason::Subscription
2
2
  attr_accessor :id, :config
3
+ attr_reader :includes_helper, :graph_helper
3
4
 
4
5
  def initialize(id: nil, config: nil)
5
6
  if id
6
7
  @id = id
7
- raw_config = $redis_jason.hgetall("jason:subscriptions:#{id}").map { |k,v| [k, JSON.parse(v)] }.to_h.with_indifferent_access
8
+ raw_config = $redis_jason.hgetall("jason:subscriptions:#{id}").map { |k,v| [k, JSON.parse(v)] }.to_h
9
+ raise "Subscription ID #{id} does not exist" if raw_config.blank?
8
10
  set_config(raw_config)
9
11
  else
10
- @id = Digest::MD5.hexdigest(config.to_json)
12
+ @id = Digest::MD5.hexdigest(config.sort_by { |key| key }.to_h.to_json)
11
13
  configure(config)
12
14
  end
15
+ @includes_helper = Jason::IncludesHelper.new({ model => self.config['includes'] })
16
+ @graph_helper = Jason::GraphHelper.new(self.id, @includes_helper)
17
+
18
+ check_for_missing_keys
19
+ end
20
+
21
+ def broadcaster
22
+ @broadcaster ||= Jason::Broadcaster.new(channel)
23
+ end
24
+
25
+ def check_for_missing_keys
26
+ missing_keys = includes_helper.all_models - Jason.schema.keys.map(&:to_s)
27
+ if missing_keys.present?
28
+ raise "#{missing_keys.inspect} are not in the schema. Only models in the Jason schema can be subscribed."
29
+ end
30
+ end
31
+
32
+ def self.upsert_by_config(model, conditions: {}, includes: {})
33
+ self.new(config: {
34
+ model: model,
35
+ conditions: conditions || {},
36
+ includes: includes || {}
37
+ })
38
+ end
39
+
40
+ def self.find_by_id(id)
41
+ self.new(id: id)
42
+ end
43
+
44
+ def self.for_instance_with_child(model_name, id, child_model_name, include_all = true)
45
+ sub_ids = for_instance(model_name, id, include_all = true)
46
+ sub_ids.select do |sub_id|
47
+ find_by_id(sub_id).includes_helper.in_sub(model_name, child_model_name)
48
+ end
49
+ end
50
+
51
+ def self.for_instance(model_name, id, include_all = true)
52
+ subs = $redis_jason.smembers("jason:models:#{model_name}:#{id}:subscriptions")
53
+ if include_all
54
+ subs += $redis_jason.smembers("jason:models:#{model_name}:all:subscriptions")
55
+ end
56
+
57
+ subs
58
+ end
59
+
60
+ def self.for_model(model_name)
61
+
62
+ end
63
+
64
+ # Find and update subscriptions affected by a model changing foreign key
65
+ # comment, comment_id, post, old_post_id, new_post_id
66
+ def self.update_ids(changed_model_name, changed_model_id, foreign_model_name, old_foreign_id, new_foreign_id)
67
+ # There are 4 cases to consider.
68
+ # changed_instance ---/--- foreign_instance
69
+ # \--+--- new_foreign_instance
70
+ #
71
+ # foreign instance can either be parent or child for a given subscription
72
+ # 1. Child swap/add: foreign is child
73
+ # 2. Stay in the family: foreign is parent + both old and new foreign instances are part of the sub
74
+ # 3. Join the family: foreign is parent + only new foreign instance are part of the sub
75
+ # 4. Leave the family: foreign is parent + only the old foreign instance is part of the sub
76
+
77
+ #########
78
+ # Subs where changed is parent
79
+ sub_ids = for_instance_with_child(changed_model_name, changed_model_id, foreign_model_name, true)
80
+ sub_ids.each do |sub_id|
81
+ subscription = find_by_id(sub_id)
82
+
83
+ # If foreign key has been nulled, nothing to add
84
+ add = new_foreign_id.present? ? [
85
+ {
86
+ model_names: [changed_model_name, foreign_model_name],
87
+ instance_ids: [[changed_model_id, new_foreign_id]]
88
+ },
89
+ # Add IDs of child models
90
+ subscription.load_ids_for_sub_models(foreign_model_name, new_foreign_id)
91
+ ] : nil
92
+
93
+ id_changeset = subscription.graph_helper.apply_update({
94
+ remove: [{
95
+ model_names: [changed_model_name, foreign_model_name],
96
+ instance_ids: [[changed_model_id, old_foreign_id]]
97
+ }],
98
+ add: add
99
+ })
100
+
101
+ subscription.apply_id_changeset(id_changeset)
102
+ subscription.broadcast_id_changeset(id_changeset)
103
+ end
104
+
105
+ old_sub_ids = for_instance_with_child(foreign_model_name, old_foreign_id, changed_model_name, true)
106
+ new_sub_ids = for_instance_with_child(foreign_model_name, new_foreign_id, changed_model_name, true)
107
+
108
+ #########
109
+ # Subs where changed is child
110
+ # + parent in both old + new
111
+ # this is simple, only the edges need to change - no IDs can be changed
112
+ (old_sub_ids & new_sub_ids).each do |sub_id|
113
+ subscription = find_by_id(sub_id)
114
+ subscription.graph_helper.apply_update({
115
+ remove: [{
116
+ model_names: [changed_model_name, foreign_model_name],
117
+ instance_ids: [[changed_model_id, old_foreign_id]]
118
+ }],
119
+ add: [{
120
+ model_names: [changed_model_name, foreign_model_name],
121
+ instance_ids: [[changed_model_id, new_foreign_id]]
122
+ }]
123
+ })
124
+ end
125
+
126
+ #########
127
+ # Subs where changed is child
128
+ # + old parent wasn't in the sub, but new parent is
129
+ # IE the changed instance is joining the sub
130
+ # No edges are removed, just added
131
+ (new_sub_ids - old_sub_ids).each do |sub_id|
132
+ subscription = find_by_id(sub_id)
133
+ id_changeset = subscription.graph_helper.apply_update({
134
+ add: [
135
+ {
136
+ model_names: [changed_model_name, foreign_model_name],
137
+ instance_ids: [[changed_model_id, new_foreign_id]]
138
+ },
139
+ # Add IDs of child models
140
+ subscription.load_ids_for_sub_models(changed_model_name, changed_model_id)
141
+ ]
142
+ })
143
+
144
+ subscription.apply_id_changeset(id_changeset)
145
+ subscription.broadcast_id_changeset(id_changeset)
146
+ end
147
+
148
+ #########
149
+ # --> Leaving the family
150
+ # Subs where changed is child
151
+ # + old parent was in the sub, but new parent isn't
152
+ # Just need to remove the link, orphan detection will do the rest
153
+ (old_sub_ids - new_sub_ids).each do |sub_id|
154
+ subscription = find_by_id(sub_id)
155
+ id_changeset = subscription.graph_helper.apply_update({
156
+ remove: [
157
+ {
158
+ model_names: [changed_model_name, foreign_model_name],
159
+ instance_ids: [[changed_model_id, old_foreign_id]]
160
+ }
161
+ ]
162
+ })
163
+ subscription.apply_id_changeset(id_changeset)
164
+ subscription.broadcast_id_changeset(id_changeset)
165
+ end
166
+ end
167
+
168
+ def self.remove_ids(model_name, ids)
169
+ # td: finish this
170
+ ids.each do |instance_id|
171
+ for_instance(model_name, instance_id, false).each do |sub_id|
172
+ subscription = find_by_id(sub_id)
173
+
174
+ id_changeset = subscription.graph_helper.apply_remove_node("#{model_name}:#{instance_id}")
175
+ subscription.apply_id_changeset(id_changeset)
176
+ subscription.broadcast_id_changeset(id_changeset)
177
+ end
178
+ end
179
+ end
180
+
181
+ # Add ID to any _all_ subscriptions
182
+ def self.add_id(model_name, id)
183
+
184
+ end
185
+
186
+ def self.all
187
+ $redis_jason.smembers('jason:subscriptions').map { |id| Jason::Subscription.find_by_id(id) }
13
188
  end
14
189
 
15
190
  def set_config(raw_config)
16
- @config = raw_config.with_indifferent_access.map { |k,v| [k.underscore.to_s, v] }.to_h
191
+ @config = raw_config.deep_stringify_keys.deep_transform_values { |v| v.is_a?(Symbol) ? v.to_s : v }
192
+ end
193
+
194
+ def clear_id(model_name, id, parent_model_name)
195
+ remove_ids(model_name, [id])
196
+ end
197
+
198
+ # Add IDs that aren't present
199
+ def commit_ids(model_name, ids)
200
+ $redis_jason.sadd("jason:subscriptions:#{id}:ids:#{model_name}", ids)
201
+ ids.each do |instance_id|
202
+ $redis_jason.sadd("jason:models:#{model_name}:#{instance_id}:subscriptions", id)
203
+ end
204
+ end
205
+
206
+ def remove_ids(model_name, ids)
207
+ $redis_jason.srem("jason:subscriptions:#{id}:ids:#{model_name}", ids)
208
+ ids.each do |instance_id|
209
+ $redis_jason.srem("jason:models:#{model_name}:#{instance_id}:subscriptions", id)
210
+ end
211
+ end
212
+
213
+ def apply_id_changeset(changeset)
214
+ changeset[:ids_to_add].each do |model_name, ids|
215
+ commit_ids(model_name, ids)
216
+ end
217
+
218
+ changeset[:ids_to_remove].each do |model_name, ids|
219
+ remove_ids(model_name, ids)
220
+ end
221
+ end
222
+
223
+ def broadcast_id_changeset(changeset)
224
+ changeset[:ids_to_add].each do |model_name, ids|
225
+ ids.each { |id| add(model_name, id) }
226
+ end
227
+
228
+ changeset[:ids_to_remove].each do |model_name, ids|
229
+ ids.each { |id| destroy(model_name, id) }
230
+ end
231
+ end
232
+
233
+ # Take a model name and IDs and return an edge set of all the models that appear and
234
+ # their instance IDs
235
+ def load_ids_for_sub_models(model_name, ids)
236
+ # Limitation: Same association can't appear twice
237
+ includes_tree = includes_helper.get_tree_for(model_name)
238
+ all_models = includes_helper.all_models(model_name)
239
+
240
+ relation = model_name.classify.constantize.all.eager_load(includes_tree)
241
+
242
+ if model_name == model
243
+ if conditions.blank?
244
+ $redis_jason.sadd("jason:models:#{model_name}:all:subscriptions", id)
245
+ all_models -= [model_name]
246
+ else
247
+ relation = relation.where(conditions)
248
+ end
249
+ else
250
+ raise "Must supply IDs for sub models" if ids.nil?
251
+ return if ids.blank?
252
+ relation = relation.where(id: ids)
253
+ end
254
+
255
+ pluck_args = all_models.map { |m| "#{m.pluralize}.id" }
256
+ instance_ids = relation.pluck(*pluck_args)
257
+
258
+ # pluck returns only a 1D array if only 1 arg passed
259
+ if all_models.size == 1
260
+ instance_ids = [instance_ids]
261
+ end
262
+
263
+ return { model_names: all_models, instance_ids: instance_ids }
264
+ end
265
+
266
+ # 'posts', [post#1, post#2,...]
267
+ def set_ids_for_sub_models(model_name = model, ids = nil, enforce: false)
268
+ edge_set = load_ids_for_sub_models(model_name, ids)
269
+
270
+ # Build the tree
271
+ id_changeset = graph_helper.apply_update({
272
+ add: [edge_set]
273
+ })
274
+ apply_id_changeset(id_changeset)
275
+ end
276
+
277
+ def clear_all_ids
278
+ includes_helper.all_models.each do |model_name|
279
+ if model_name == model && conditions.blank?
280
+ $redis_jason.srem("jason:models:#{model_name}:all:subscriptions", id)
281
+ end
282
+
283
+ ids = $redis_jason.smembers("jason:subscriptions:#{id}:ids:#{model_name}")
284
+ ids.each do |instance_id|
285
+ $redis_jason.srem("jason:models:#{model_name}:#{instance_id}:subscriptions", id)
286
+ end
287
+ $redis_jason.del("jason:subscriptions:#{id}:ids:#{model_name}")
288
+ end
289
+ end
290
+
291
+ def ids(model_name = model)
292
+ $redis_jason.smembers("jason:subscriptions:#{id}:ids:#{model_name}")
293
+ end
294
+
295
+ def model
296
+ @config['model']
297
+ end
298
+
299
+ def model_klass(model_name)
300
+ model_name.to_s.classify.constantize
301
+ end
302
+
303
+ def conditions
304
+ @config['conditions']
17
305
  end
18
306
 
19
307
  def configure(raw_config)
20
308
  set_config(raw_config)
21
- $redis_jason.hmset("jason:subscriptions:#{id}", *config.map { |k,v| [k, v.to_json]}.flatten)
309
+ $redis_jason.sadd("jason:subscriptions", id)
310
+ $redis_jason.hmset("jason:subscriptions:#{id}", *config.map { |k,v| [k, v.to_json] }.flatten)
22
311
  end
23
312
 
24
313
  def destroy
25
- config.each do |model, value|
26
- $redis_jason.srem("jason:#{model.to_s.underscore}:subscriptions", id)
27
- end
28
- $redis_jason.del("jason:subscriptions:#{id}")
314
+ raise
29
315
  end
30
316
 
31
317
  def add_consumer(consumer_id)
@@ -33,8 +319,9 @@ class Jason::Subscription
33
319
  $redis_jason.sadd("jason:subscriptions:#{id}:consumers", consumer_id)
34
320
  $redis_jason.hset("jason:consumers", consumer_id, Time.now.utc)
35
321
 
36
- add_subscriptions
37
- publish_all
322
+ if before_consumer_count == 0
323
+ set_ids_for_sub_models
324
+ end
38
325
  end
39
326
 
40
327
  def remove_consumer(consumer_id)
@@ -42,7 +329,7 @@ class Jason::Subscription
42
329
  $redis_jason.hdel("jason:consumers", consumer_id)
43
330
 
44
331
  if consumer_count == 0
45
- remove_subscriptions
332
+ clear_all_ids
46
333
  end
47
334
  end
48
335
 
@@ -51,119 +338,84 @@ class Jason::Subscription
51
338
  end
52
339
 
53
340
  def channel
54
- "jason:#{id}"
341
+ "jason-#{id}"
55
342
  end
56
343
 
57
- def publish_all
58
- config.each do |model, model_config|
59
- klass = model.to_s.classify.constantize
60
- conditions = model_config['conditions'] || {}
61
- klass.where(conditions).find_each(&:cache_json)
62
- update(model)
63
- end
344
+ def user_can_access?(user)
345
+ # td: implement the authorization logic here
346
+ return true if Jason.authorization_service.blank?
347
+ Jason.authorization_service.call(user, model, conditions, includes_helper.all_models - [model])
64
348
  end
65
349
 
66
- def add_subscriptions
67
- config.each do |model, value|
68
- $redis_jason.hset("jason:#{model.to_s.underscore}:subscriptions", id, value.to_json)
69
- update(model)
70
- end
350
+ def get
351
+ includes_helper.all_models.map { |model_name| [model_name, get_for_model(model_name)] }.to_h
71
352
  end
72
353
 
73
- def remove_subscriptions
74
- config.each do |model, _|
75
- $redis_jason.hdel("jason:#{model.to_s.underscore}:subscriptions", id)
354
+ def get_for_model(model_name)
355
+ if $redis_jason.sismember("jason:models:#{model_name}:all:subscriptions", id)
356
+ instance_jsons_hash, idx = $redis_jason.multi do |r|
357
+ r.hgetall("jason:cache:#{model_name}")
358
+ r.get("jason:subscription:#{id}:#{model_name}:idx")
359
+ end
360
+ instance_jsons = instance_jsons_hash.values
361
+ else
362
+ instance_jsons, idx = Jason::LuaGenerator.new.get_payload(model_name, id)
76
363
  end
77
- end
78
364
 
79
- def self.publish_all
80
- JASON_API_MODEL.each do |model, _v|
81
- klass = model.to_s.classify.constantize
82
- klass.publish_all(klass.all) if klass.respond_to?(:publish_all)
365
+ payload = instance_jsons.map do |instance_json|
366
+ instance_json ? JSON.parse(instance_json) : {}
83
367
  end
84
- end
85
-
86
- def get(model)
87
- value = JSON.parse($redis_jason.get("#{channel}:#{model}:value") || '[]')
88
- idx = $redis_jason.get("#{channel}:#{model}:idx").to_i
89
368
 
90
369
  {
91
370
  type: 'payload',
371
+ model: model_name,
372
+ payload: payload,
92
373
  md5Hash: id,
93
- model: model,
94
- value: value,
95
- idx: idx
374
+ idx: idx.to_i
96
375
  }
97
376
  end
98
377
 
99
- def get_diff(old_value, value)
100
- JsonDiff.generate(old_value, value)
101
- end
378
+ def add(model_name, instance_id)
379
+ idx = $redis_jason.incr("jason:subscription:#{id}:#{model_name}:idx")
380
+ payload = JSON.parse($redis_jason.hget("jason:cache:#{model_name}", instance_id) || '{}')
102
381
 
103
- def deep_stringify(value)
104
- if value.is_a?(Hash)
105
- value.deep_stringify_keys
106
- elsif value.is_a?(Array)
107
- value.map { |x| x.deep_stringify_keys }
108
- end
109
- end
382
+ payload = {
383
+ id: instance_id,
384
+ model: model_name,
385
+ payload: payload,
386
+ md5Hash: id,
387
+ idx: idx.to_i
388
+ }
110
389
 
111
- def get_throttle
112
- if !$throttle_rate || !$throttle_timeout || Time.now.utc > $throttle_timeout
113
- $throttle_timeout = Time.now.utc + 5.seconds
114
- $throttle_rate = ($redis_jason.get('global_throttle_rate') || 0).to_i
115
- else
116
- $throttle_rate
117
- end
390
+ broadcaster.broadcast(payload)
118
391
  end
119
392
 
120
- # Atomically update and return patch
121
- def update(model)
122
- start_time = Time.now.utc
123
- conditions = config[model]['conditions']
124
-
125
- value = $redis_jason.hgetall("jason:#{model}:cache")
126
- .values.map { |v| JSON.parse(v) }
127
- .select { |v| (conditions || {}).all? { |field, value| v[field] == value } }
128
- .sort_by { |v| v['id'] }
129
-
130
- # lfsa = last finished, started at
131
- # If another job that started after this one, finished before this one, skip sending this state update
132
- if Time.parse($redis_jason.get("jason:#{channel}:lfsa") || '1970-01-01 00:00:00 UTC') < start_time
133
- $redis_jason.set("jason:#{channel}:lfsa", start_time)
134
- else
135
- return
136
- end
137
-
138
- value = deep_stringify(value)
393
+ def update(model_name, instance_id, payload, gidx)
394
+ idx = Jason::LuaGenerator.new.get_subscription(model_name, instance_id, id, gidx)
395
+ return if idx.blank?
139
396
 
140
- # If value has changed, return old value and new idx. Otherwise do nothing.
141
- cmd = <<~LUA
142
- local old_val=redis.call('get', ARGV[1] .. ':value')
143
- if old_val ~= ARGV[2] then
144
- redis.call('set', ARGV[1] .. ':value', ARGV[2])
145
- local new_idx = redis.call('incr', ARGV[1] .. ':idx')
146
- return { new_idx, old_val }
147
- end
148
- LUA
149
-
150
- result = $redis_jason.eval cmd, [], ["#{channel}:#{model}", value.to_json]
151
- return if result.blank?
397
+ payload = {
398
+ id: instance_id,
399
+ model: model_name,
400
+ payload: payload,
401
+ md5Hash: id,
402
+ idx: idx.to_i
403
+ }
152
404
 
153
- idx = result[0]
154
- old_value = JSON.parse(result[1] || '[]')
155
- diff = get_diff(old_value, value)
405
+ broadcaster.broadcast(payload)
406
+ end
156
407
 
157
- end_time = Time.now.utc
408
+ def destroy(model_name, instance_id)
409
+ idx = $redis_jason.incr("jason:subscription:#{id}:#{model_name}:idx")
158
410
 
159
411
  payload = {
160
- model: model,
412
+ id: instance_id,
413
+ model: model_name,
414
+ destroy: true,
161
415
  md5Hash: id,
162
- diff: diff,
163
- idx: idx.to_i,
164
- latency: ((end_time - start_time)*1000).round
416
+ idx: idx.to_i
165
417
  }
166
418
 
167
- ActionCable.server.broadcast("jason:#{id}", payload)
419
+ broadcaster.broadcast(payload)
168
420
  end
169
421
  end