graphql-anycable 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bafa1a80487eeb78627ed0752751eb488dd33ca9709d38b205d61d310f97d7ae
4
- data.tar.gz: 821cba31fd376a471c9db5757a2da3e51d936f618f372a624fab1515dea5d4e6
3
+ metadata.gz: 7d85da4ea44364a70b75af1157b5dd613e22eddea2b8ca8c0b20fb06041d9618
4
+ data.tar.gz: 915f82f6e2e746b968638454d5bf4adccf8a5c783a1203131e83fec6b0d28f5c
5
5
  SHA512:
6
- metadata.gz: fdad5b3cfcc372ec326ebfa9641ecdc81d5f586aca7fc54edb5bc1985d4e0e8fabd3ba93eb25cf8e9f55556a7d5410a2971596ab52936706199cdb6c6f3faa10
7
- data.tar.gz: f71265d7dc1f0afdb4162e2d6a00d87c92bd53aa484429a5dff138e7f53db754b75a55137d999314f6c600eee3d88d661b6ab91a87b1bdd9f52a0317a7d9e43a
6
+ metadata.gz: 01d1d65ae70213dbc9ce22334174fb2ea0f703da7cfa824f0409d39f24ce79993c360833dd67c1e18471668b83c43bdcf86b1bcbe699547ef5a8e0bfacdffebf
7
+ data.tar.gz: 99c5f3fc3ee7ad597dc3ee870e55cee7ca775df9fa0bb58f3babcbed0097d36ff2857349b3177cf13e3323013ca6b5304cb1a16a7240883847ed7b40a2c93323
@@ -0,0 +1,34 @@
1
+ name: Lint Ruby
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ paths:
8
+ - "gemfiles/*"
9
+ - "Gemfile"
10
+ - "**/*.rb"
11
+ - "**/*.gemspec"
12
+ - ".github/workflows/lint.yml"
13
+ pull_request:
14
+ paths:
15
+ - "gemfiles/*"
16
+ - "Gemfile"
17
+ - "**/*.rb"
18
+ - "**/*.gemspec"
19
+ - ".github/workflows/lint.yml"
20
+
21
+ jobs:
22
+ rubocop:
23
+ runs-on: ubuntu-latest
24
+ env:
25
+ BUNDLE_GEMFILE: "gemfiles/rubocop.gemfile"
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: 3.1
31
+ bundler-cache: true
32
+ - name: Lint Ruby code with RuboCop
33
+ run: |
34
+ bundle exec rubocop
@@ -24,11 +24,15 @@ jobs:
24
24
  redis_version: latest
25
25
  - ruby: "3.2"
26
26
  graphql: '~> 2.2.0'
27
- anycable: '~> 1.4.0'
27
+ anycable: '~> 1.5.0'
28
28
  redis_version: '7.2'
29
29
  - ruby: "3.1"
30
+ graphql: '~> 2.1.0'
31
+ anycable: '~> 1.4.0'
32
+ redis_version: '6.2'
33
+ - ruby: "3.0"
30
34
  graphql: '~> 2.0.0'
31
- anycable: '~> 1.3.0'
35
+ anycable: '~> 1.4.0'
32
36
  redis_version: '6.2'
33
37
  env:
34
38
  CI: true
@@ -0,0 +1,46 @@
1
+ require:
2
+ - rubocop-rspec
3
+
4
+ # Disable all cops by default,
5
+ # only enable those defined explcitly in this configuration file
6
+ RSpec:
7
+ Enabled: false
8
+
9
+ RSpec/Focus:
10
+ Enabled: true
11
+
12
+ RSpec/EmptyExampleGroup:
13
+ Enabled: true
14
+
15
+ RSpec/EmptyLineAfterExampleGroup:
16
+ Enabled: true
17
+
18
+ RSpec/EmptyLineAfterFinalLet:
19
+ Enabled: true
20
+
21
+ RSpec/EmptyLineAfterHook:
22
+ Enabled: true
23
+
24
+ RSpec/EmptyLineAfterSubject:
25
+ Enabled: true
26
+
27
+ RSpec/HookArgument:
28
+ Enabled: true
29
+
30
+ RSpec/HooksBeforeExamples:
31
+ Enabled: true
32
+
33
+ RSpec/ImplicitExpect:
34
+ Enabled: true
35
+
36
+ RSpec/IteratedExpectation:
37
+ Enabled: true
38
+
39
+ RSpec/LetBeforeExamples:
40
+ Enabled: true
41
+
42
+ RSpec/MissingExampleGroupArgument:
43
+ Enabled: true
44
+
45
+ RSpec/ReceiveCounts:
46
+ Enabled: true
@@ -0,0 +1,7 @@
1
+ Lint/Debugger: # don't leave binding.pry
2
+ Enabled: true
3
+ Exclude: []
4
+
5
+ RSpec/Focus: # run ALL tests on CI
6
+ Enabled: true
7
+ Exclude: []
data/.rubocop.yml CHANGED
@@ -1,46 +1,30 @@
1
- ---
2
- require:
3
- - rubocop-rspec
4
-
5
- AllCops:
6
- TargetRubyVersion: 2.3
1
+ inherit_mode:
2
+ merge:
3
+ - Exclude
7
4
 
8
- Metrics/BlockLength:
9
- Exclude:
10
- - "Gemfile"
11
- - "spec/**/*"
12
-
13
- Style/BracesAroundHashParameters:
14
- EnforcedStyle: context_dependent
5
+ require:
6
+ - standard
7
+ - standard-custom
8
+ - standard-performance
9
+ - rubocop-performance
15
10
 
16
- Style/StringLiterals:
17
- EnforcedStyle: double_quotes
11
+ inherit_gem:
12
+ standard: config/base.yml
13
+ standard-performance: config/base.yml
14
+ standard-custom: config/base.yml
18
15
 
19
- # Allow to use let!
20
- RSpec/LetSetup:
21
- Enabled: false
16
+ inherit_from:
17
+ - .rubocop/rspec.yml
18
+ - .rubocop/strict.yml
22
19
 
23
- RSpec/MultipleExpectations:
24
- Enabled: false
20
+ AllCops:
21
+ NewCops: disable
22
+ SuggestExtensions: false
23
+ TargetRubyVersion: 3.2
25
24
 
26
- Bundler/OrderedGems:
25
+ Style/ArgumentsForwarding:
27
26
  Enabled: false
28
27
 
29
- Style/TrailingCommaInArguments:
30
- Description: 'Checks for trailing comma in argument lists.'
31
- StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-params-comma'
32
- Enabled: true
33
- EnforcedStyleForMultiline: consistent_comma
34
-
35
- Style/TrailingCommaInArrayLiteral:
36
- Description: 'Checks for trailing comma in array literals.'
37
- StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas'
38
- Enabled: true
39
- EnforcedStyleForMultiline: consistent_comma
40
-
41
- Style/TrailingCommaInHashLiteral:
42
- Description: 'Checks for trailing comma in hash literals.'
43
- StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas'
44
- Enabled: true
45
- EnforcedStyleForMultiline: consistent_comma
46
-
28
+ Style/GlobalVars:
29
+ Exclude:
30
+ - "spec/**/*.rb"
data/CHANGELOG.md CHANGED
@@ -7,8 +7,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 1.3.0 - 2024-08-13
11
+
12
+ ### Changed
13
+
14
+ - Redis subscriptions store configuration has been decoupled from AnyCable, so you can use any broadcasting adapter and configure Redis as you like. [@palkan] ([#44](https://github.com/anycable/graphql-anycable/pull/44))
15
+
10
16
  ## 1.2.0 - 2024-05-07
11
17
 
18
+ ### Added
19
+
20
+ - Stats collection about subscriptions, channels, etc via `GraphQL::AnyCable.stats`. [@prog-supdex] ([#37](https://github.com/anycable/graphql-anycable/pull/37))
21
+
22
+ See [Stats](https://github.com/anycable/graphql-anycable?tab=readme-ov-file#stats) section in README for details.
23
+
24
+ - Configuration option `redis_prefix` for namespacing Redis keys. [@prog-supdex] ([#36](https://github.com/anycable/graphql-anycable/pull/36))
25
+
12
26
  ### Changed
13
27
 
14
28
  - Depend on `anycable-core` gem instead of `anycable`.
@@ -197,6 +211,9 @@ Technical release to test publishing via GitHub Actions.
197
211
 
198
212
  Initial version: store subscriptions on redis, re-execute queries in sync. [@Envek]
199
213
 
214
+ [@prog-supdex]: https://github.com/prog-supdex "Igor Platonov"
215
+ [@ilyasgaraev]: https://github.com/ilyasgaraev "Ilyas Garaev"
216
+ [@smasry]: https://github.com/smasry "Samer Masry"
200
217
  [@gsamokovarov]: https://github.com/gsamokovarov "Genadi Samokovarov"
201
218
  [@bibendi]: https://github.com/bibendi "Misha Merkushin"
202
219
  [@FX-HAO]: https://github.com/FX-HAO "Fuxin Hao"
data/Gemfile CHANGED
@@ -7,14 +7,17 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
7
7
  # Specify your gem's dependencies in graphql-anycable.gemspec
8
8
  gemspec
9
9
 
10
- gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~> 2.3")
10
+ gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~> 2.3")
11
11
  gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.5")
12
12
  gem "anycable-rails", ENV.fetch("ANYCABLE_RAILS_VERSION", "~> 1.5")
13
+ gem "rack", "< 3.0" if /1\.4/.match?(ENV.fetch("ANYCABLE_VERSION", "~> 1.5"))
14
+
15
+ gem "ostruct"
13
16
 
14
17
  group :development, :test do
15
- gem "pry"
16
- gem "pry-byebug", platform: :mri
18
+ gem "debug", platforms: [:mri] unless ENV["CI"]
19
+ end
17
20
 
18
- gem "rubocop"
19
- gem "rubocop-rspec"
21
+ group :development do
22
+ eval_gemfile "gemfiles/rubocop.gemfile"
20
23
  end
data/README.md CHANGED
@@ -11,58 +11,52 @@ A (mostly) drop-in replacement for default ActionCable subscriptions adapter shi
11
11
 
12
12
  ## Why?
13
13
 
14
- AnyCable is fast because it does not execute any Ruby code. But default subscription implementation shipped with [graphql gem] requires to do exactly that: re-evaluate GraphQL queries in ActionCable process. AnyCable doesn't support this (it's possible but hard to implement).
14
+ AnyCable is fast because it does not execute any Ruby code. But default subscription implementation shipped with [graphql gem] requires to do exactly that: re-evaluate GraphQL queries in Action Cable process. AnyCable doesn't support this (it's possible but hard to implement).
15
15
 
16
16
  See https://github.com/anycable/anycable-rails/issues/40 for more details and discussion.
17
17
 
18
18
  ## Differences
19
19
 
20
- - Subscription information is stored in Redis database configured to be used by AnyCable. Expiration or data cleanup should be configured separately (see below).
21
- - GraphQL queries for all subscriptions are re-executed in the process that triggers event (it may be web server, async jobs, rake tasks or whatever)
20
+ - Subscription information is stored in a Redis database. By default, we use AnyCable Redis configuration. Expiration or data cleanup should be configured separately (see below).
21
+ - GraphQL queries for all subscriptions are re-executed in the process that triggers event (it may be web server, async jobs, rake tasks or whatever)
22
22
 
23
23
  ## Compatibility
24
24
 
25
- - Should work with ActionCable in development
26
- - Should work without Rails via [LiteCable]
27
-
28
- ## Requirements
29
-
30
- AnyCable must be configured with redis broadcast adapter (this is default).
25
+ - Works with Action Cable (e.g., in development/test)
26
+ - Works without Rails (e.g., via [LiteCable][])
31
27
 
32
28
  ## Installation
33
29
 
34
30
  Add this line to your application's Gemfile:
35
31
 
36
32
  ```ruby
37
- gem 'graphql-anycable', '~> 1.0'
33
+ gem "graphql-anycable", "~> 1.0"
38
34
  ```
39
35
 
40
36
  And then execute:
41
37
 
42
- $ bundle
43
-
44
- Or install it yourself as:
45
-
46
- $ gem install graphql-anycable
38
+ ```sh
39
+ bundle install
40
+ ```
47
41
 
48
42
  ## Usage
49
43
 
50
- 1. Plug it into the schema (replace from ActionCable adapter if you have one):
51
-
44
+ 1. Plug it into the schema (replace the Action Cable adapter if you have one):
45
+
52
46
  ```ruby
53
47
  class MySchema < GraphQL::Schema
54
48
  use GraphQL::AnyCable, broadcast: true
55
-
49
+
56
50
  subscription SubscriptionType
57
51
  end
58
52
  ```
59
-
60
- 2. Execute query in ActionCable/LiteCable channel.
61
-
53
+
54
+ 2. Execute a query within an Action Cable/LiteCable channel.
55
+
62
56
  ```ruby
63
57
  class GraphqlChannel < ApplicationCable::Channel
64
58
  def execute(data)
65
- result =
59
+ result =
66
60
  MySchema.execute(
67
61
  query: data["query"],
68
62
  context: context,
@@ -75,11 +69,11 @@ Or install it yourself as:
75
69
  more: result.subscription?,
76
70
  )
77
71
  end
78
-
72
+
79
73
  def unsubscribed
80
74
  MySchema.subscriptions.delete_channel_subscriptions(self)
81
75
  end
82
-
76
+
83
77
  private
84
78
 
85
79
  def context
@@ -90,29 +84,38 @@ Or install it yourself as:
90
84
  end
91
85
  end
92
86
  ```
93
-
94
- Make sure that you're passing channel instance as `channel` key to the context.
95
-
87
+
88
+ Make sure that you're passing channel instance as `channel` key to the context.
89
+
96
90
  3. Trigger events as usual:
97
-
91
+
98
92
  ```ruby
99
93
  MySchema.subscriptions.trigger(:product_updated, {}, Product.first!, scope: account.id)
100
94
  ```
101
95
 
96
+ 4. (Optional) When using other AnyCable broadcasting adapters than Redis, you MUST configure Redis for graphql-anycable yourself:
97
+
98
+ ```ruby
99
+ GraphQL::AnyCable.redis = Redis.new(url: ENV["REDIS_URL"])
100
+
101
+ # you can also use a Proc (e.g., if you want to use a connection pool)
102
+ redis_pool = ConnectionPool.new(size: 10) { Redis.new(url: ENV["REDIS_URL"]) }
103
+
104
+ GraphQL::AnyCable.redis = ->(&block) { redis_pool.with { |conn| block.call(conn) } }
105
+ ```
106
+
102
107
  ## Broadcasting
103
108
 
104
109
  By default, graphql-anycable evaluates queries and transmits results for every subscription client individually. Of course, it is a waste of resources if you have hundreds or thousands clients subscribed to the same data (and has huge negative impact on performance).
105
110
 
106
111
  Thankfully, GraphQL-Ruby has added [Subscriptions Broadcast](https://graphql-ruby.org/subscriptions/broadcast.html) feature that allows to group exact same subscriptions, execute them and transmit results only once.
107
112
 
108
- To enable this feature, turn on [Interpreter](https://graphql-ruby.org/queries/interpreter.html) and pass `broadcast` option set to `true` to graphql-anycable.
113
+ To enable this feature, pass the `broadcast` option set to `true` to graphql-anycable.
109
114
 
110
115
  By default all fields are marked as _not safe for broadcasting_. If a subscription has at least one non-broadcastable field in its query, GraphQL-Ruby will execute every subscription for every client independently. If you sure that all your fields are safe to be broadcasted, you can pass `default_broadcastable` option set to `true` (but be aware that it can have security impllications!)
111
116
 
112
117
  ```ruby
113
118
  class MySchema < GraphQL::Schema
114
- use GraphQL::Execution::Interpreter # Required for graphql-ruby before 1.12. Remove it when upgrading to 2.0
115
- use GraphQL::Analysis::AST # Required for graphql-ruby before 1.12. Remove it when upgrading to 2.0
116
119
  use GraphQL::AnyCable, broadcast: true, default_broadcastable: true
117
120
 
118
121
  subscription SubscriptionType
@@ -125,7 +128,7 @@ See GraphQL-Ruby [broadcasting docs](https://graphql-ruby.org/subscriptions/broa
125
128
 
126
129
  To avoid filling Redis storage with stale subscription data:
127
130
 
128
- 1. Set `subscription_expiration_seconds` setting to number of seconds (e.g. `604800` for 1 week). See [configuration](#Configuration) section below for details.
131
+ 1. Set `subscription_expiration_seconds` setting to number of seconds (e.g. `604800` for 1 week). See [configuration](#configuration) section below for details.
129
132
 
130
133
  2. Execute `rake graphql:anycable:clean` once in a while to clean up stale subscription data.
131
134
 
@@ -165,39 +168,46 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
165
168
  And any other way provided by [anyway_config]. Check its documentation!
166
169
 
167
170
  ## Emergency actions
168
- In situations when you don't set `subscription_expiration_seconds`, have a lot of inactive subscriptions, and `GraphQL::AnyCable::Cleaner` does`t help in that,
171
+
172
+ In situations when you don't set `subscription_expiration_seconds`, have a lot of inactive subscriptions, and `GraphQL::AnyCable::Cleaner` does`t help in that,
169
173
  you can do the following actions for clearing subscriptions
170
174
 
171
175
  1. Set `config.subscription_expiration_seconds`. After that, the new subscriptions will have `TTL`
176
+
172
177
  2. Run the script
178
+
173
179
  ```ruby
174
- redis = GraphQL::AnyCable.redis
175
- config = GraphQL::AnyCable.config
176
-
177
- # do it for subscriptions
178
- redis.scan_each("graphql-subscription:*") do |key|
179
- redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0
180
- # or you can just remove it immediately
181
- # redis.del(key) if redis.ttl(key) < 0
182
- end
183
-
184
- # do it for channels
185
- redis.scan_each("graphql-channel:*") do |key|
186
- redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0
187
- # or you can just remove it immediately
188
- # redis.del(key) if redis.ttl(key) < 0
189
- end
180
+ config = GraphQL::AnyCable.config
181
+
182
+ GraphQL::AnyCable.with_redis do |redis|
183
+ # do it for subscriptions
184
+ redis.scan_each("graphql-subscription:*") do |key|
185
+ redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0
186
+ # or you can just remove it immediately
187
+ # redis.del(key) if redis.ttl(key) < 0
188
+ end
189
+
190
+ # do it for channels
191
+ redis.scan_each("graphql-channel:*") do |key|
192
+ redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0
193
+ # or you can just remove it immediately
194
+ # redis.del(key) if redis.ttl(key) < 0
195
+ end
196
+ end
190
197
  ```
191
198
 
192
- Or you can change the `redis_prefix` in the `configuration` and then remove all records with the old_prefix
193
- For instance:
199
+ Or you can change the `redis_prefix` in the `configuration` and then remove all records with the old_prefix. For instance:
200
+
201
+ 1. Change the `redis_prefix`. The default `redis_prefix` is `graphql`.
202
+
203
+ 2. Run the ruby script, which remove all records with `old prefix`:
194
204
 
195
- 1. Change the `redis_prefix`. The default `redis_prefix` is `graphql`
196
- 2. Run the ruby script, which remove all records with `old prefix`
197
205
  ```ruby
206
+ GraphQL::AnyCable.with_redis do |redis|
198
207
  redis.scan_each("graphql-*") do |key|
199
208
  redis.del(key)
200
209
  end
210
+ end
201
211
  ```
202
212
 
203
213
  ## Data model
@@ -206,21 +216,21 @@ As in AnyCable there is no place to store subscription data in-memory, it should
206
216
 
207
217
  1. Grouped event subscriptions: `graphql-fingerprints:#{event.topic}` sorted set. Used to find all subscriptions on `GraphQLSchema.subscriptions.trigger`.
208
218
 
209
- ```
219
+ ```sh
210
220
  ZREVRANGE graphql-fingerprints:1:myStats: 0 -1
211
221
  => 1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=
212
222
  ```
213
223
 
214
224
  2. Event subscriptions: `graphql-subscriptions:#{event.fingerptint}` set containing identifiers for all subscriptions for given operation with certain context and arguments (serialized in _topic_). Fingerprints are already scoped by topic.
215
225
 
216
- ```
226
+ ```sh
217
227
  SMEMBERS graphql-subscriptions:1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=
218
228
  => 52ee8d65-275e-4d22-94af-313129116388
219
229
  ```
220
230
 
221
231
  3. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client.
222
232
 
223
- ```
233
+ ```sh
224
234
  HGETALL graphql-subscription:52ee8d65-275e-4d22-94af-313129116388
225
235
  => {
226
236
  context: '{"user_id":1,"user":{"__gid__":"Z2lkOi8vZWJheS1tYWcyL1VzZXIvMQ"}}',
@@ -232,29 +242,29 @@ As in AnyCable there is no place to store subscription data in-memory, it should
232
242
 
233
243
  4. Channel subscriptions: `graphql-channel:#{subscription_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
234
244
 
235
- ```
245
+ ```sh
236
246
  SMEMBERS graphql-channel:17420c6ed9e
237
247
  => 52ee8d65-275e-4d22-94af-313129116388
238
248
  ```
239
249
 
240
250
  ## Stats
241
251
 
242
- You can grab Redis subscription statistics by calling
252
+ You can grab Redis subscription statistics by calling:
243
253
 
244
254
  ```ruby
245
- GraphQL::AnyCable.stats
255
+ GraphQL::AnyCable.stats
246
256
  ```
247
257
 
248
- It will return a total of the amount of the key with the following prefixes
258
+ It will return a total of the amount of the key with the following prefixes:
249
259
 
250
- ```
251
- graphql-subscription
252
- graphql-fingerprints
253
- graphql-subscriptions
254
- graphql-channel
260
+ ```txt
261
+ graphql-subscription
262
+ graphql-fingerprints
263
+ graphql-subscriptions
264
+ graphql-channel
255
265
  ```
256
266
 
257
- The response will look like this
267
+ The response will look like this:
258
268
 
259
269
  ```json
260
270
  {
@@ -267,13 +277,13 @@ The response will look like this
267
277
  }
268
278
  ```
269
279
 
270
- You can also grab the number of subscribers grouped by subscriptions
280
+ You can also grab the number of subscribers grouped by subscriptions:
271
281
 
272
282
  ```ruby
273
- GraphQL::AnyCable.stats(include_subscriptions: true)
283
+ GraphQL::AnyCable.stats(include_subscriptions: true)
274
284
  ```
275
285
 
276
- It will return the response that contains `subscriptions`
286
+ It will return the response that contains `subscriptions`:
277
287
 
278
288
  ```json
279
289
  {
@@ -290,59 +300,58 @@ It will return the response that contains `subscriptions`
290
300
  }
291
301
  ```
292
302
 
293
- Also, you can set another `scan_count`, if needed.
294
- The default value is 1_000
303
+ Also, you can set another `scan_count`, if needed. The default value is 1_000:
295
304
 
296
305
  ```ruby
297
- GraphQL::AnyCable.stats(scan_count: 100)
306
+ GraphQL::AnyCable.stats(scan_count: 100)
298
307
  ```
299
308
 
300
- We can set statistics data to [Yabeda][] for tracking amount of subscriptions
309
+ We can set statistics data to [Yabeda][] for tracking amount of subscriptions:
301
310
 
302
311
  ```ruby
303
- # config/initializers/metrics.rb
304
- Yabeda.configure do
305
- group :graphql_anycable_statistics do
306
- gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
307
- end
312
+ # config/initializers/metrics.rb
313
+ Yabeda.configure do
314
+ group :graphql_anycable_statistics do
315
+ gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
308
316
  end
317
+ end
309
318
  ```
310
319
 
311
320
  ```ruby
312
- # in your app
313
- statistics = GraphQL::AnyCable.stats[:total]
321
+ # in your app
322
+ statistics = GraphQL::AnyCable.stats[:total]
314
323
 
315
- statistics.each do |key , value|
316
- Yabeda.graphql_anycable_statistics.subscriptions_count.set({name: key}, value)
317
- end
324
+ statistics.each do |key , value|
325
+ Yabeda.graphql_anycable_statistics.subscriptions_count.set({name: key}, value)
326
+ end
318
327
  ```
319
328
 
320
- Or you can use `collect`
329
+ Or you can use `collect`:
330
+
321
331
  ```ruby
322
- # config/initializers/metrics.rb
323
- Yabeda.configure do
324
- group :graphql_anycable_statistics do
325
- gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
326
- end
327
-
328
- collect do
329
- statistics = GraphQL::AnyCable.stats[:total]
332
+ # config/initializers/metrics.rb
333
+ Yabeda.configure do
334
+ group :graphql_anycable_statistics do
335
+ gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
336
+ end
330
337
 
331
- statistics.each do |redis_prefix, value|
332
- graphql_anycable_statistics.subscriptions_count.set({name: redis_prefix}, value)
333
- end
338
+ collect do
339
+ statistics = GraphQL::AnyCable.stats[:total]
340
+
341
+ statistics.each do |redis_prefix, value|
342
+ graphql_anycable_statistics.subscriptions_count.set({name: redis_prefix}, value)
334
343
  end
335
344
  end
345
+ end
336
346
  ```
337
347
 
338
-
339
348
  ## Testing applications which use `graphql-anycable`
340
349
 
341
350
  You can pass custom redis-server URL to AnyCable using ENV variable.
342
351
 
343
- ```bash
344
- REDIS_URL=redis://localhost:6379/5 bundle exec rspec
345
- ```
352
+ ```bash
353
+ REDIS_URL=redis://localhost:6379/5 bundle exec rspec
354
+ ```
346
355
 
347
356
  ## Development
348
357
 
@@ -382,7 +391,6 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
382
391
 
383
392
  7. GitHub Actions will create a new release, build and push gem into [rubygems.org](https://rubygems.org)! You're done!
384
393
 
385
-
386
394
  ## Contributing
387
395
 
388
396
  Bug reports and pull requests are welcome on GitHub at https://github.com/Envek/graphql-anycable.
data/Rakefile CHANGED
@@ -5,4 +5,11 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- task default: :spec
8
+ begin
9
+ require "rubocop/rake_task"
10
+ RuboCop::RakeTask.new
11
+ rescue LoadError
12
+ task(:rubocop) {}
13
+ end
14
+
15
+ task default: [:rubocop, :spec]
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org" do
2
+ gem "standard", "~> 1.28"
3
+
4
+ gem "rubocop-rspec"
5
+ end
@@ -5,31 +5,33 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
  require "graphql/anycable/version"
6
6
 
7
7
  Gem::Specification.new do |spec|
8
- spec.name = "graphql-anycable"
9
- spec.version = GraphQL::AnyCable::VERSION
10
- spec.authors = ["Andrey Novikov"]
11
- spec.email = ["envek@envek.name"]
8
+ spec.name = "graphql-anycable"
9
+ spec.version = GraphQL::AnyCable::VERSION
10
+ spec.authors = ["Andrey Novikov"]
11
+ spec.email = ["envek@envek.name"]
12
12
 
13
- spec.summary = <<~SUMMARY
13
+ spec.summary = <<~SUMMARY
14
14
  A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.
15
15
  SUMMARY
16
16
 
17
- spec.homepage = "https://github.com/Envek/graphql-anycable"
18
- spec.license = "MIT"
17
+ spec.homepage = "https://github.com/Envek/graphql-anycable"
18
+ spec.license = "MIT"
19
19
 
20
20
  # Specify which files should be added to the gem when it is released.
21
21
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
23
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
24
  end
25
- spec.bindir = "exe"
26
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
27
  spec.require_paths = ["lib"]
28
28
 
29
+ spec.required_ruby_version = ">= 3.0.0"
30
+
29
31
  spec.add_dependency "anycable-core", "~> 1.1"
30
32
  spec.add_dependency "anyway_config", ">= 1.3", "< 3"
31
- spec.add_dependency "graphql", ">= 1.11", "< 3"
32
- spec.add_dependency "redis", ">= 4.2.0"
33
+ spec.add_dependency "graphql", ">= 1.11", "< 3"
34
+ spec.add_dependency "redis", ">= 4.2.0"
33
35
 
34
36
  spec.add_development_dependency "anycable-rails"
35
37
  spec.add_development_dependency "bundler", "~> 2.0"
@@ -48,7 +48,7 @@ module GraphQL
48
48
 
49
49
  def clean_topic_fingerprints
50
50
  redis.scan_each(match: "#{redis_key(adapter::FINGERPRINTS_PREFIX)}*") do |key|
51
- redis.zremrangebyscore(key, '-inf', '0')
51
+ redis.zremrangebyscore(key, "-inf", "0")
52
52
  redis.zrange(key, 0, -1).each do |fingerprint|
53
53
  next if redis.exists?(redis_key(adapter::SUBSCRIPTIONS_PREFIX) + fingerprint)
54
54
 
@@ -6,7 +6,7 @@ module GraphQL
6
6
  module AnyCable
7
7
  class Config < Anyway::Config
8
8
  config_name :graphql_anycable
9
- env_prefix :graphql_anycable
9
+ env_prefix :graphql_anycable
10
10
 
11
11
  attr_config subscription_expiration_seconds: nil
12
12
  attr_config use_redis_object_on_cleanup: true
@@ -18,12 +18,14 @@ module GraphQL
18
18
  def collect
19
19
  total_subscriptions_result = {total: {}}
20
20
 
21
- list_prefixes_keys.each do |name, prefix|
22
- total_subscriptions_result[:total][name] = count_by_scan(match: "#{prefix}*")
23
- end
21
+ AnyCable.with_redis do |redis|
22
+ list_prefixes_keys.each do |name, prefix|
23
+ total_subscriptions_result[:total][name] = count_by_scan(redis, match: "#{prefix}*")
24
+ end
24
25
 
25
- if include_subscriptions
26
- total_subscriptions_result[:subscriptions] = group_subscription_stats
26
+ if include_subscriptions
27
+ total_subscriptions_result[:subscriptions] = group_subscription_stats(redis)
28
+ end
27
29
  end
28
30
 
29
31
  total_subscriptions_result
@@ -32,22 +34,22 @@ module GraphQL
32
34
  private
33
35
 
34
36
  # Counting all keys, that match the pattern with iterating by count
35
- def count_by_scan(match:)
37
+ def count_by_scan(redis, match:)
36
38
  sb_amount = 0
37
- cursor = '0'
39
+ cursor = "0"
38
40
 
39
41
  loop do
40
42
  cursor, result = redis.scan(cursor, match: match, count: scan_count)
41
43
  sb_amount += result.count
42
44
 
43
- break if cursor == '0'
45
+ break if cursor == "0"
44
46
  end
45
47
 
46
48
  sb_amount
47
49
  end
48
50
 
49
51
  # Calculate subscribes, grouped by subscriptions
50
- def group_subscription_stats
52
+ def group_subscription_stats(redis)
51
53
  subscription_groups = {}
52
54
 
53
55
  redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: scan_count) do |fingerprint_key|
@@ -79,10 +81,6 @@ module GraphQL
79
81
  GraphQL::Subscriptions::AnyCableSubscriptions
80
82
  end
81
83
 
82
- def redis
83
- GraphQL::AnyCable.redis
84
- end
85
-
86
84
  def config
87
85
  GraphQL::AnyCable.config
88
86
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module AnyCable
5
- VERSION = "1.2.0"
5
+ VERSION = "1.3.0"
6
6
  end
7
7
  end
@@ -54,12 +54,13 @@ module GraphQL
54
54
  class AnyCableSubscriptions < GraphQL::Subscriptions
55
55
  extend Forwardable
56
56
 
57
- def_delegators :"GraphQL::AnyCable", :redis, :config
57
+ def_delegators :"GraphQL::AnyCable", :with_redis, :config
58
+ def_delegators :"::AnyCable", :broadcast
58
59
 
59
- SUBSCRIPTION_PREFIX = "subscription:" # HASH: Stores subscription data: query, context, …
60
- FINGERPRINTS_PREFIX = "fingerprints:" # ZSET: To get fingerprints by topic
60
+ SUBSCRIPTION_PREFIX = "subscription:" # HASH: Stores subscription data: query, context, …
61
+ FINGERPRINTS_PREFIX = "fingerprints:" # ZSET: To get fingerprints by topic
61
62
  SUBSCRIPTIONS_PREFIX = "subscriptions:" # SET: To get subscriptions by fingerprint
62
- CHANNEL_PREFIX = "channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup
63
+ CHANNEL_PREFIX = "channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup
63
64
 
64
65
  # @param serializer [<#dump(obj), #load(string)] Used for serializing messages before handing them to `.broadcast(msg)`
65
66
  def initialize(serializer: Serialize, **rest)
@@ -70,23 +71,25 @@ module GraphQL
70
71
  # An event was triggered.
71
72
  # Re-evaluate all subscribed queries and push the data over ActionCable.
72
73
  def execute_all(event, object)
73
- fingerprints = redis.zrange(redis_key(FINGERPRINTS_PREFIX) + event.topic, 0, -1)
74
+ fingerprints = with_redis { |redis| redis.zrange(redis_key(FINGERPRINTS_PREFIX) + event.topic, 0, -1) }
74
75
  return if fingerprints.empty?
75
76
 
76
- fingerprint_subscription_ids = Hash[fingerprints.zip(
77
- redis.pipelined do |pipeline|
78
- fingerprints.map do |fingerprint|
79
- pipeline.smembers(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint)
77
+ fingerprint_subscription_ids = with_redis do |redis|
78
+ fingerprints.zip(
79
+ redis.pipelined do |pipeline|
80
+ fingerprints.map do |fingerprint|
81
+ pipeline.smembers(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint)
82
+ end
80
83
  end
81
- end
82
- )]
84
+ ).to_h
85
+ end
83
86
 
84
87
  fingerprint_subscription_ids.each do |fingerprint, subscription_ids|
85
88
  execute_grouped(fingerprint, subscription_ids, event, object)
86
89
  end
87
90
 
88
91
  # Call to +trigger+ returns this. Convenient for playing in console
89
- Hash[fingerprint_subscription_ids.map { |k,v| [k, v.size] }]
92
+ fingerprint_subscription_ids.map { |k, v| [k, v.size] }.to_h
90
93
  end
91
94
 
92
95
  # The fingerprint has told us that this response should be shared by all subscribers,
@@ -94,7 +97,7 @@ module GraphQL
94
97
  def execute_grouped(fingerprint, subscription_ids, event, object)
95
98
  return if subscription_ids.empty?
96
99
 
97
- subscription_id = subscription_ids.find { |sid| redis.exists?(redis_key(SUBSCRIPTION_PREFIX) + sid) }
100
+ subscription_id = with_redis { |redis| subscription_ids.find { |sid| redis.exists?(redis_key(SUBSCRIPTION_PREFIX) + sid) } }
98
101
  return unless subscription_id # All subscriptions has expired but haven't cleaned up yet
99
102
 
100
103
  result = execute_update(subscription_id, event, object)
@@ -114,8 +117,8 @@ module GraphQL
114
117
  # @param strean_key [String]
115
118
  # @param result [#to_h] result to send to clients
116
119
  def deliver(stream_key, result)
117
- payload = { result: result.to_h, more: true }.to_json
118
- anycable.broadcast(stream_key, payload)
120
+ payload = {result: result.to_h, more: true}.to_json
121
+ broadcast(stream_key, payload)
119
122
  end
120
123
 
121
124
  # Save query to "storage" (in redis)
@@ -138,37 +141,58 @@ module GraphQL
138
141
  variables: query.provided_variables.to_json,
139
142
  context: @serializer.dump(context.to_h),
140
143
  operation_name: query.operation_name.to_s,
141
- events: events.map { |e| [e.topic, e.fingerprint] }.to_h.to_json,
144
+ events: events.map { |e| [e.topic, e.fingerprint] }.to_h.to_json
142
145
  }
143
146
 
144
- redis.multi do |pipeline|
145
- pipeline.sadd(redis_key(CHANNEL_PREFIX) + subscription_id, [subscription_id])
146
- pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data)
147
- events.each do |event|
148
- pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
149
- pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
147
+ with_redis do |redis|
148
+ redis.multi do |pipeline|
149
+ pipeline.sadd(redis_key(CHANNEL_PREFIX) + subscription_id, [subscription_id])
150
+ pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data)
151
+ events.each do |event|
152
+ pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
153
+ pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
154
+ end
155
+ next unless config.subscription_expiration_seconds
156
+ pipeline.expire(redis_key(CHANNEL_PREFIX) + subscription_id, config.subscription_expiration_seconds)
157
+ pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds)
150
158
  end
151
- next unless config.subscription_expiration_seconds
152
- pipeline.expire(redis_key(CHANNEL_PREFIX) + subscription_id, config.subscription_expiration_seconds)
153
- pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds)
154
159
  end
155
160
  end
156
161
 
157
162
  # Return the query from "storage" (in redis)
158
163
  def read_subscription(subscription_id)
159
- redis.mapped_hmget(
160
- "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}",
161
- :query_string, :variables, :context, :operation_name
162
- ).tap do |subscription|
163
- return if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key
164
-
165
- subscription[:context] = @serializer.load(subscription[:context])
166
- subscription[:variables] = JSON.parse(subscription[:variables])
167
- subscription[:operation_name] = nil if subscription[:operation_name].strip == ""
164
+ with_redis do |redis|
165
+ redis.mapped_hmget(
166
+ "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}",
167
+ :query_string, :variables, :context, :operation_name
168
+ ).tap do |subscription|
169
+ next if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key
170
+
171
+ subscription[:context] = @serializer.load(subscription[:context])
172
+ subscription[:variables] = JSON.parse(subscription[:variables])
173
+ subscription[:operation_name] = nil if subscription[:operation_name].strip == ""
174
+ end
168
175
  end
169
176
  end
170
177
 
171
- def delete_subscription(subscription_id)
178
+ # The channel was closed, forget about it and its subscriptions
179
+ def delete_channel_subscriptions(channel)
180
+ raise(ArgumentError, "Please pass channel instance to #{__method__} in your #unsubscribed method") if channel.is_a?(String)
181
+
182
+ channel_id = read_subscription_id(channel)
183
+
184
+ # Missing in case disconnect happens before #execute
185
+ return unless channel_id
186
+
187
+ with_redis do |redis|
188
+ redis.smembers(redis_key(CHANNEL_PREFIX) + channel_id).each do |subscription_id|
189
+ delete_subscription(subscription_id, redis: redis)
190
+ end
191
+ redis.del(redis_key(CHANNEL_PREFIX) + channel_id)
192
+ end
193
+ end
194
+
195
+ def delete_subscription(subscription_id, redis: AnyCable.redis)
172
196
  events = redis.hget(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, :events)
173
197
  events = events ? JSON.parse(events) : {}
174
198
  fingerprint_subscriptions = {}
@@ -184,32 +208,13 @@ module GraphQL
184
208
  # Clean up fingerprints that doesn't have any subscriptions left
185
209
  redis.pipelined do |pipeline|
186
210
  fingerprint_subscriptions.each do |key, score|
187
- pipeline.zremrangebyscore(key, '-inf', '0') if score.value.zero?
211
+ pipeline.zremrangebyscore(key, "-inf", "0") if score.value.zero?
188
212
  end
189
213
  end
190
214
  end
191
215
 
192
- # The channel was closed, forget about it and its subscriptions
193
- def delete_channel_subscriptions(channel)
194
- raise(ArgumentError, "Please pass channel instance to #{__method__} in your #unsubscribed method") if channel.is_a?(String)
195
-
196
- channel_id = read_subscription_id(channel)
197
-
198
- # Missing in case disconnect happens before #execute
199
- return unless channel_id
200
-
201
- redis.smembers(redis_key(CHANNEL_PREFIX) + channel_id).each do |subscription_id|
202
- delete_subscription(subscription_id)
203
- end
204
- redis.del(redis_key(CHANNEL_PREFIX) + channel_id)
205
- end
206
-
207
216
  private
208
217
 
209
- def anycable
210
- @anycable ||= ::AnyCable.broadcast_adapter
211
- end
212
-
213
218
  def read_subscription_id(channel)
214
219
  return channel.instance_variable_get(:@__sid__) if channel.instance_variable_defined?(:@__sid__)
215
220
 
@@ -11,33 +11,55 @@ require_relative "graphql/subscriptions/anycable_subscriptions"
11
11
 
12
12
  module GraphQL
13
13
  module AnyCable
14
- def self.use(schema, **options)
15
- schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options
16
- end
14
+ class << self
15
+ def use(schema, **opts)
16
+ schema.use(GraphQL::Subscriptions::AnyCableSubscriptions, **opts)
17
+ end
17
18
 
18
- def self.stats(**options)
19
- Stats.new(**options).collect
20
- end
19
+ def stats(**opts)
20
+ Stats.new(**opts).collect
21
+ end
22
+
23
+ def redis
24
+ warn "Usage of `GraphQL::AnyCable.redis` is deprecated. Instead of `GraphQL::AnyCable.redis.whatever` use `GraphQL::AnyCable.with_redis { |redis| redis.whatever }`"
25
+ @redis ||= with_redis { |conn| conn }
26
+ end
27
+
28
+ def redis=(connector)
29
+ @redis_connector = if connector.is_a?(::Proc)
30
+ connector
31
+ else
32
+ ->(&block) { block.call connector }
33
+ end
34
+ end
35
+
36
+ def with_redis(&block)
37
+ @redis_connector || default_redis_connector
38
+ @redis_connector.call(&block)
39
+ end
40
+
41
+ def config
42
+ @config ||= Config.new
43
+ end
44
+
45
+ def configure
46
+ yield(config) if block_given?
47
+ end
21
48
 
22
- module_function
49
+ private
23
50
 
24
- def redis
25
- @redis ||= begin
51
+ def default_redis_connector
26
52
  adapter = ::AnyCable.broadcast_adapter
27
53
  unless adapter.is_a?(::AnyCable::BroadcastAdapters::Redis)
28
54
  raise "Unsupported AnyCable adapter: #{adapter.class}. " \
29
- "graphql-anycable works only with redis broadcast adapter."
55
+ "Please, configure Redis connector manually:\n\n" \
56
+ " GraphQL::AnyCable.configure do |config|\n" \
57
+ " config.redis = Redis.new(url: 'redis://localhost:6379/0')\n" \
58
+ " end\n"
30
59
  end
31
- ::AnyCable.broadcast_adapter.redis_conn
32
- end
33
- end
34
60
 
35
- def config
36
- @config ||= GraphQL::AnyCable::Config.new
37
- end
38
-
39
- def configure
40
- yield(config) if block_given?
61
+ self.redis = ::AnyCable.broadcast_adapter.redis_conn
62
+ end
41
63
  end
42
64
  end
43
65
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-anycable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Novikov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-05-07 00:00:00.000000000 Z
11
+ date: 2024-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: anycable-core
@@ -171,11 +171,14 @@ extra_rdoc_files: []
171
171
  files:
172
172
  - ".github/ISSUE_TEMPLATE/bug_report.md"
173
173
  - ".github/ISSUE_TEMPLATE/feature_request.md"
174
+ - ".github/workflows/lint.yml"
174
175
  - ".github/workflows/release.yml"
175
176
  - ".github/workflows/test.yml"
176
177
  - ".gitignore"
177
178
  - ".rspec"
178
179
  - ".rubocop.yml"
180
+ - ".rubocop/rspec.yml"
181
+ - ".rubocop/strict.yml"
179
182
  - CHANGELOG.md
180
183
  - Gemfile
181
184
  - LICENSE.txt
@@ -183,6 +186,7 @@ files:
183
186
  - Rakefile
184
187
  - bin/console
185
188
  - bin/setup
189
+ - gemfiles/rubocop.gemfile
186
190
  - graphql-anycable.gemspec
187
191
  - lib/Rakefile
188
192
  - lib/graphql-anycable.rb
@@ -207,14 +211,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
207
211
  requirements:
208
212
  - - ">="
209
213
  - !ruby/object:Gem::Version
210
- version: '0'
214
+ version: 3.0.0
211
215
  required_rubygems_version: !ruby/object:Gem::Requirement
212
216
  requirements:
213
217
  - - ">="
214
218
  - !ruby/object:Gem::Version
215
219
  version: '0'
216
220
  requirements: []
217
- rubygems_version: 3.5.9
221
+ rubygems_version: 3.5.11
218
222
  signing_key:
219
223
  specification_version: 4
220
224
  summary: A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.