jason-rails 0.3.0 → 0.6.0

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.
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