jason-rails 0.3.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -1
  3. data/.ruby-version +1 -0
  4. data/Gemfile.lock +184 -0
  5. data/README.md +118 -10
  6. data/app/controllers/jason/api/pusher_controller.rb +15 -0
  7. data/app/controllers/jason/api_controller.rb +78 -0
  8. data/client/babel.config.js +13 -0
  9. data/client/lib/JasonContext.d.ts +6 -1
  10. data/client/lib/JasonProvider.d.ts +6 -5
  11. data/client/lib/JasonProvider.js +5 -97
  12. data/client/lib/actionFactory.js +1 -1
  13. data/client/lib/createActions.d.ts +1 -1
  14. data/client/lib/createActions.js +2 -27
  15. data/client/lib/createJasonReducers.js +49 -3
  16. data/client/lib/createOptDis.d.ts +1 -0
  17. data/client/lib/createOptDis.js +43 -0
  18. data/client/lib/createPayloadHandler.d.ts +9 -1
  19. data/client/lib/createPayloadHandler.js +52 -43
  20. data/client/lib/createServerActionQueue.d.ts +10 -0
  21. data/client/lib/createServerActionQueue.js +48 -0
  22. data/client/lib/createServerActionQueue.test.d.ts +1 -0
  23. data/client/lib/createServerActionQueue.test.js +37 -0
  24. data/client/lib/createTransportAdapter.d.ts +5 -0
  25. data/client/lib/createTransportAdapter.js +20 -0
  26. data/client/lib/deepCamelizeKeys.d.ts +1 -0
  27. data/client/lib/deepCamelizeKeys.js +23 -0
  28. data/client/lib/deepCamelizeKeys.test.d.ts +1 -0
  29. data/client/lib/deepCamelizeKeys.test.js +106 -0
  30. data/client/lib/index.d.ts +6 -5
  31. data/client/lib/pruneIdsMiddleware.d.ts +2 -0
  32. data/client/lib/pruneIdsMiddleware.js +24 -0
  33. data/client/lib/restClient.d.ts +2 -0
  34. data/client/lib/restClient.js +17 -0
  35. data/client/lib/transportAdapters/actionCableAdapter.d.ts +5 -0
  36. data/client/lib/transportAdapters/actionCableAdapter.js +35 -0
  37. data/client/lib/transportAdapters/pusherAdapter.d.ts +5 -0
  38. data/client/lib/transportAdapters/pusherAdapter.js +68 -0
  39. data/client/lib/useJason.d.ts +5 -0
  40. data/client/lib/useJason.js +94 -0
  41. data/client/lib/useJason.test.d.ts +1 -0
  42. data/client/lib/useJason.test.js +85 -0
  43. data/client/lib/useSub.d.ts +1 -1
  44. data/client/lib/useSub.js +6 -3
  45. data/client/package.json +19 -4
  46. data/client/src/JasonProvider.tsx +6 -96
  47. data/client/src/actionFactory.ts +1 -1
  48. data/client/src/createActions.ts +2 -33
  49. data/client/src/createJasonReducers.ts +57 -3
  50. data/client/src/createOptDis.ts +45 -0
  51. data/client/src/createPayloadHandler.ts +58 -47
  52. data/client/src/createServerActionQueue.test.ts +42 -0
  53. data/client/src/createServerActionQueue.ts +47 -0
  54. data/client/src/createTransportAdapter.ts +13 -0
  55. data/client/src/deepCamelizeKeys.test.ts +113 -0
  56. data/client/src/deepCamelizeKeys.ts +17 -0
  57. data/client/src/pruneIdsMiddleware.ts +24 -0
  58. data/client/src/restClient.ts +14 -0
  59. data/client/src/transportAdapters/actionCableAdapter.ts +38 -0
  60. data/client/src/transportAdapters/pusherAdapter.ts +72 -0
  61. data/client/src/useJason.test.ts +87 -0
  62. data/client/src/useJason.ts +110 -0
  63. data/client/src/useSub.ts +6 -3
  64. data/client/yarn.lock +4607 -81
  65. data/config/routes.rb +8 -0
  66. data/jason-rails.gemspec +9 -0
  67. data/lib/jason.rb +40 -1
  68. data/lib/jason/api_model.rb +15 -9
  69. data/lib/jason/broadcaster.rb +19 -0
  70. data/lib/jason/channel.rb +50 -21
  71. data/lib/jason/engine.rb +5 -0
  72. data/lib/jason/graph_helper.rb +165 -0
  73. data/lib/jason/includes_helper.rb +108 -0
  74. data/lib/jason/lua_generator.rb +71 -0
  75. data/lib/jason/publisher.rb +103 -30
  76. data/lib/jason/publisher_old.rb +112 -0
  77. data/lib/jason/subscription.rb +352 -101
  78. data/lib/jason/subscription_old.rb +171 -0
  79. data/lib/jason/version.rb +1 -1
  80. metadata +151 -4
@@ -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,73 +1,146 @@
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.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.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
- subscriptions = $redis.hgetall("jason:#{self.class.name.underscore}:subscriptions")
20
- subscriptions.each do |id, config_json|
21
- 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
22
43
 
23
- if (config['conditions'] || {}).all? { |field, value| self.send(field) == value }
24
- Jason::Subscription.new(id: id).update(self.class.name.underscore)
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
72
+
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)
25
81
  end
26
82
  end
27
83
  end
28
84
 
29
85
  def publish_json_if_changed
30
86
  subscribed_fields = api_model.subscribed_fields
31
- 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?
88
+ end
89
+
90
+ def jason_subscriptions
91
+ Jason::Subscription.for_instance(self.class.name.underscore, id)
32
92
  end
33
93
 
34
94
  class_methods do
35
- def subscriptions
36
- $redis.hgetall("jason:#{self.name.underscore}:subscriptions")
95
+ def cache_all
96
+ all.each(&:cache_json)
37
97
  end
38
98
 
39
- def publish_all(instances)
40
- instances.each(&:cache_json)
41
-
42
- subscriptions.each do |id, config_json|
43
- Jason::Subscription.new(id: id).update(self.name.underscore)
44
- end
99
+ def has_jason?
100
+ true
45
101
  end
46
102
 
47
103
  def flush_cache
48
- $redis.del("jason:#{self.name.underscore}:cache")
104
+ $redis_jason.del("jason:cache:#{self.name.underscore}")
49
105
  end
50
106
 
51
107
  def setup_json
108
+ self.before_save -> {
109
+ @was_a_new_record = new_record?
110
+ }
52
111
  self.after_initialize -> {
53
112
  @api_model = Jason::ApiModel.new(self.class.name.underscore)
54
113
  }
55
114
  self.after_commit :publish_json_if_changed
115
+ end
116
+
117
+ def find_or_create_by_id(params)
118
+ object = find_by(id: params[:id])
119
+
120
+ if object
121
+ object.update(params)
122
+ elsif params[:hidden]
123
+ return false ## If an object is passed with hidden = true but didn't already exist, it's safe to never create it
124
+ else
125
+ object = create!(params)
126
+ end
56
127
 
57
- include_models = Jason::ApiModel.new(self.name.underscore).include_models
128
+ object
129
+ end
58
130
 
59
- include_models.map do |assoc|
60
- puts assoc
61
- reflection = self.reflect_on_association(assoc.to_sym)
62
- reflection.klass.after_commit -> {
63
- subscribed_fields = Jason::ApiModel.new(self.class.name.underscore).subscribed_fields
64
- puts subscribed_fields.inspect
131
+ def find_or_create_by_id!(params)
132
+ object = find_by(id: params[:id])
65
133
 
66
- if (self.previous_changes.keys.map(&:to_sym) & subscribed_fields).present?
67
- self.send(reflection.inverse_of.name)&.publish_json
68
- end
69
- }
134
+ if object
135
+ object.update!(params)
136
+ elsif params[:hidden]
137
+ ## 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.
138
+ return false ## If an object is passed with hidden = true but didn't already exist, it's safe to never create it
139
+ else
140
+ object = create!(params)
70
141
  end
142
+
143
+ object
71
144
  end
72
145
  end
73
146
 
@@ -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,172 +1,423 @@
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.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
13
19
  end
14
20
 
15
- def set_config(raw_config)
16
- @config = raw_config.with_indifferent_access.map { |k,v| [k.underscore.to_s, v] }.to_h
21
+ def broadcaster
22
+ @broadcaster ||= Jason::Broadcaster.new(channel)
17
23
  end
18
24
 
19
- def configure(raw_config)
20
- set_config(raw_config)
21
- $redis.hmset("jason:subscriptions:#{id}", *config.map { |k,v| [k, v.to_json]}.flatten)
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
22
30
  end
23
31
 
24
- def destroy
25
- config.each do |model, value|
26
- $redis.srem("jason:#{model.to_s.underscore}:subscriptions", id)
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)
27
48
  end
28
- $redis.del("jason:subscriptions:#{id}")
29
49
  end
30
50
 
31
- def add_consumer(consumer_id)
32
- before_consumer_count = consumer_count
33
- $redis.sadd("jason:subscriptions:#{id}:consumers", consumer_id)
34
- $redis.hset("jason:consumers", consumer_id, Time.now.utc)
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
35
56
 
36
- if before_consumer_count == 0
37
- add_subscriptions
38
- publish_all
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)
39
165
  end
40
166
  end
41
167
 
42
- def remove_consumer(consumer_id)
43
- $redis.srem("jason:subscriptions:#{id}:consumers", consumer_id)
44
- $redis.hdel("jason:consumers", consumer_id)
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)
45
173
 
46
- if consumer_count == 0
47
- remove_subscriptions
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
48
178
  end
49
179
  end
50
180
 
51
- def consumer_count
52
- $redis.scard("jason:subscriptions:#{id}:consumers")
181
+ # Add ID to any _all_ subscriptions
182
+ def self.add_id(model_name, id)
183
+
53
184
  end
54
185
 
55
- def channel
56
- "jason:#{id}"
186
+ def self.all
187
+ $redis_jason.smembers('jason:subscriptions').map { |id| Jason::Subscription.find_by_id(id) }
57
188
  end
58
189
 
59
- def publish_all
60
- config.each do |model, model_config|
61
- klass = model.to_s.classify.constantize
62
- conditions = model_config['conditions'] || {}
63
- klass.where(conditions).find_each(&:cache_json)
64
- update(model)
190
+ def set_config(raw_config)
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)
65
203
  end
66
204
  end
67
205
 
68
- def add_subscriptions
69
- config.each do |model, value|
70
- $redis.hset("jason:#{model.to_s.underscore}:subscriptions", id, value.to_json)
71
- update(model)
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)
72
210
  end
73
211
  end
74
212
 
75
- def remove_subscriptions
76
- config.each do |model, _|
77
- $redis.hdel("jason:#{model.to_s.underscore}:subscriptions", id)
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)
78
220
  end
79
221
  end
80
222
 
81
- def self.publish_all
82
- JASON_API_MODEL.each do |model, _v|
83
- klass = model.to_s.classify.constantize
84
- klass.publish_all(klass.all) if klass.respond_to?(:publish_all)
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) }
85
230
  end
86
231
  end
87
232
 
88
- def get(model)
89
- value = JSON.parse($redis.get("#{channel}:#{model}:value") || '{}')
90
- idx = $redis.get("#{channel}:#{model}:idx").to_i
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)
91
239
 
92
- {
93
- type: 'payload',
94
- md5Hash: id,
95
- model: model,
96
- value: value,
97
- idx: idx
98
- }
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 }
99
264
  end
100
265
 
101
- def get_diff(old_value, value)
102
- JsonDiff.generate(old_value, value)
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)
103
275
  end
104
276
 
105
- def deep_stringify(value)
106
- if value.is_a?(Hash)
107
- value.deep_stringify_keys
108
- elsif value.is_a?(Array)
109
- value.map { |x| x.deep_stringify_keys }
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}")
110
288
  end
111
289
  end
112
290
 
113
- def get_throttle
114
- if !$throttle_rate || !$throttle_timeout || Time.now.utc > $throttle_timeout
115
- $throttle_timeout = Time.now.utc + 5.seconds
116
- $throttle_rate = (Sidekiq.redis { |r| r.get 'global_throttle_rate' } || 0).to_i
117
- else
118
- $throttle_rate
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']
305
+ end
306
+
307
+ def configure(raw_config)
308
+ set_config(raw_config)
309
+ $redis_jason.sadd("jason:subscriptions", id)
310
+ $redis_jason.hmset("jason:subscriptions:#{id}", *config.map { |k,v| [k, v.to_json] }.flatten)
311
+ end
312
+
313
+ def destroy
314
+ raise
315
+ end
316
+
317
+ def add_consumer(consumer_id)
318
+ before_consumer_count = consumer_count
319
+ $redis_jason.sadd("jason:subscriptions:#{id}:consumers", consumer_id)
320
+ $redis_jason.hset("jason:consumers", consumer_id, Time.now.utc)
321
+
322
+ if before_consumer_count == 0
323
+ set_ids_for_sub_models
119
324
  end
120
325
  end
121
326
 
122
- # Atomically update and return patch
123
- def update(model)
124
- start_time = Time.now.utc
125
- conditions = config[model]['conditions']
327
+ def remove_consumer(consumer_id)
328
+ $redis_jason.srem("jason:subscriptions:#{id}:consumers", consumer_id)
329
+ $redis_jason.hdel("jason:consumers", consumer_id)
126
330
 
127
- value = $redis.hgetall("jason:#{model}:cache")
128
- .values.map { |v| JSON.parse(v) }
129
- .select { |v| (conditions || {}).all? { |field, value| v[field] == value } }
130
- .sort_by { |v| v['id'] }
331
+ if consumer_count == 0
332
+ clear_all_ids
333
+ end
334
+ end
131
335
 
132
- # lfsa = last finished, started at
133
- # If another job that started after this one, finished before this one, skip sending this state update
134
- if Time.parse(Sidekiq.redis { |r| r.get("jason:#{channel}:lfsa") || '1970-01-01 00:00:00 UTC' } ) < start_time
135
- Sidekiq.redis { |r| r.set("jason:#{channel}:lfsa", start_time) }
336
+ def consumer_count
337
+ $redis_jason.scard("jason:subscriptions:#{id}:consumers")
338
+ end
339
+
340
+ def channel
341
+ "jason-#{id}"
342
+ end
343
+
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])
348
+ end
349
+
350
+ def get
351
+ includes_helper.all_models.map { |model_name| [model_name, get_for_model(model_name)] }.to_h
352
+ end
353
+
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
136
361
  else
137
- return
362
+ instance_jsons, idx = Jason::LuaGenerator.new.get_payload(model_name, id)
138
363
  end
139
364
 
140
- value = deep_stringify(value)
365
+ return if instance_jsons.blank?
141
366
 
142
- # If value has changed, return old value and new idx. Otherwise do nothing.
143
- cmd = <<~LUA
144
- local old_val=redis.call('get', ARGV[1] .. ':value')
145
- if old_val ~= ARGV[2] then
146
- redis.call('set', ARGV[1] .. ':value', ARGV[2])
147
- local new_idx = redis.call('incr', ARGV[1] .. ':idx')
148
- return { new_idx, old_val }
149
- end
150
- LUA
367
+ payload = instance_jsons.map do |instance_json|
368
+ instance_json ? JSON.parse(instance_json) : {}
369
+ end
151
370
 
152
- result = $redis.eval cmd, [], ["#{channel}:#{model}", value.to_json]
153
- return if result.blank?
371
+ {
372
+ type: 'payload',
373
+ model: model_name,
374
+ payload: payload,
375
+ md5Hash: id,
376
+ idx: idx.to_i
377
+ }
378
+ end
154
379
 
155
- idx = result[0]
156
- old_value = JSON.parse(result[1] || '{}')
380
+ def add(model_name, instance_id)
381
+ idx = $redis_jason.incr("jason:subscription:#{id}:#{model_name}:idx")
382
+ payload = JSON.parse($redis_jason.hget("jason:cache:#{model_name}", instance_id) || '{}')
157
383
 
158
- diff = get_diff(old_value, value)
384
+ payload = {
385
+ id: instance_id,
386
+ model: model_name,
387
+ payload: payload,
388
+ md5Hash: id,
389
+ idx: idx.to_i
390
+ }
159
391
 
160
- end_time = Time.now.utc
392
+ broadcaster.broadcast(payload)
393
+ end
394
+
395
+ def update(model_name, instance_id, payload, gidx)
396
+ idx = Jason::LuaGenerator.new.get_subscription(model_name, instance_id, id, gidx)
397
+ return if idx.blank?
161
398
 
162
399
  payload = {
163
- model: model,
400
+ id: instance_id,
401
+ model: model_name,
402
+ payload: payload,
403
+ md5Hash: id,
404
+ idx: idx.to_i
405
+ }
406
+
407
+ broadcaster.broadcast(payload)
408
+ end
409
+
410
+ def destroy(model_name, instance_id)
411
+ idx = $redis_jason.incr("jason:subscription:#{id}:#{model_name}:idx")
412
+
413
+ payload = {
414
+ id: instance_id,
415
+ model: model_name,
416
+ destroy: true,
164
417
  md5Hash: id,
165
- diff: diff,
166
- idx: idx.to_i,
167
- latency: ((end_time - start_time)*1000).round
418
+ idx: idx.to_i
168
419
  }
169
420
 
170
- ActionCable.server.broadcast("jason:#{id}", payload)
421
+ broadcaster.broadcast(payload)
171
422
  end
172
423
  end