graphql-anycable_postgresql-store 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98dc409019bc7dcd4b3cf768c48b0c849fa51b2a8fe13e752485cf37dbc6d347
4
- data.tar.gz: fd1f0469d8f228257092cd4e6a8dcacfca9306139fb6e624c0c8ddd71a408bf3
3
+ metadata.gz: 7f4a6948b58b9ff40992a1a02f9381919c29e25862a62eedfeb1ffd3b80ee5d1
4
+ data.tar.gz: db167a2158578f0d025dd0ef48dbcb1924dfeace2347d3e863fe45b8abdb4e74
5
5
  SHA512:
6
- metadata.gz: ee6d8b98417a98906635103a89c4c7683bc931f1da3f627a6e17aabbb8f62b41ea662f68e2d7c5f3950f878c2cdc41836ca2fd20cf0ad7fb849f319e4f4bf7b4
7
- data.tar.gz: 935c1c06e7115264b065c57320fe1917bfc46fd12ae67cf58ebf6b32a5b9a44fafbdbdd07c29b7d73e6713e965f83c53105adad2c090716f7e81874739e84796
6
+ metadata.gz: 6b5abf158a18a24b2be97bb0659b875eaa9ef2726bddf32721a0f5f1495a936429833d42e0b2548bac41ea486cadac4c32dafc7b7850b3a0428db1ac5e0bd562
7
+ data.tar.gz: 796480eb81e8fd6733d296ad445b86820bc8fb45d9d4f3b8088ef93b16af7d38df909db697e6c12a297e84a8b4898f255cd6ed3639825d8205f2c4ee2b780cdc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-05-28
4
+
5
+ - Add a PostgreSQL store cleaner matching the `graphql-anycable` store cleanup contract.
6
+ - Extract PostgreSQL stats into a `Store::Stats` object to match the core store design.
7
+
3
8
  ## 0.1.0 - 2026-05-27
4
9
 
5
10
  - Add PostgreSQL subscription store for `graphql-anycable`.
data/README.md CHANGED
@@ -6,7 +6,7 @@ PostgreSQL subscription store for [`graphql-anycable`](https://github.com/anycab
6
6
 
7
7
  This gem stores GraphQL subscription state in PostgreSQL. It does not deliver AnyCable broadcasts itself; delivery still goes through the AnyCable broadcast adapter configured by the application.
8
8
 
9
- This gem requires a `graphql-anycable` version that supports custom subscription stores.
9
+ This gem requires a `graphql-anycable` version that supports custom subscription stores and store-backed cleanup.
10
10
 
11
11
  ## Installation
12
12
 
@@ -106,6 +106,13 @@ subscriptions, topics, fingerprints, and channels with SQL aggregate queries;
106
106
  `scan_count` is accepted for graphql-anycable interface compatibility and is not
107
107
  used by PostgreSQL.
108
108
 
109
+ ## Cleanup
110
+
111
+ `GraphQL::AnyCable::Cleaner` delegates to this store when `subscription_store`
112
+ is configured as `:postgresql` or `:postgres`. The cleaner removes expired
113
+ subscription rows; PostgreSQL foreign keys with `ON DELETE CASCADE` remove
114
+ associated event and channel rows.
115
+
109
116
  ## Development
110
117
 
111
118
  Install dependencies and run tests:
@@ -130,8 +137,8 @@ bundle exec rake build
130
137
  Publish the same versioned gem artifact to RubyGems and GitHub Releases:
131
138
 
132
139
  ```sh
133
- gem push pkg/graphql-anycable_postgresql-store-0.1.0.gem
134
- gh release create v0.1.0 pkg/graphql-anycable_postgresql-store-0.1.0.gem \
135
- --title "v0.1.0" \
140
+ gem push pkg/graphql-anycable_postgresql-store-0.2.0.gem
141
+ gh release create v0.2.0 pkg/graphql-anycable_postgresql-store-0.2.0.gem \
142
+ --title "v0.2.0" \
136
143
  --notes-file CHANGELOG.md
137
144
  ```
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module AnyCable
5
+ module PostgreSQLStore
6
+ class Store
7
+ class Cleaner
8
+ def initialize(connection_provider:, subscriptions_table:, events_table:, channels_table:)
9
+ @connection_provider = connection_provider
10
+ @subscriptions_table = subscriptions_table
11
+ @events_table = events_table
12
+ @channels_table = channels_table
13
+ end
14
+
15
+ def clean
16
+ clean_subscriptions
17
+ clean_fingerprint_subscriptions
18
+ clean_channels
19
+ clean_topic_fingerprints
20
+ end
21
+
22
+ def clean_channels
23
+ with_connection do |conn|
24
+ conn.exec_params(<<~SQL)
25
+ DELETE FROM #{channels_table} channels
26
+ WHERE NOT EXISTS (
27
+ SELECT 1
28
+ FROM #{subscriptions_table} subscriptions
29
+ WHERE subscriptions.id = channels.subscription_id
30
+ )
31
+ SQL
32
+ end
33
+ end
34
+
35
+ def clean_subscriptions
36
+ with_connection do |conn|
37
+ conn.exec_params(<<~SQL)
38
+ DELETE FROM #{subscriptions_table}
39
+ WHERE expires_at IS NOT NULL
40
+ AND expires_at <= CURRENT_TIMESTAMP
41
+ SQL
42
+ end
43
+ end
44
+
45
+ def clean_fingerprint_subscriptions
46
+ with_connection do |conn|
47
+ conn.exec_params(<<~SQL)
48
+ DELETE FROM #{events_table} events
49
+ WHERE NOT EXISTS (
50
+ SELECT 1
51
+ FROM #{subscriptions_table} subscriptions
52
+ WHERE subscriptions.id = events.subscription_id
53
+ )
54
+ SQL
55
+ end
56
+ end
57
+
58
+ def clean_topic_fingerprints
59
+ nil
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :channels_table, :connection_provider, :events_table, :subscriptions_table
65
+
66
+ def with_connection(&block)
67
+ connection_provider.call(&block)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module AnyCable
5
+ module PostgreSQLStore
6
+ class Store
7
+ class Stats
8
+ def initialize(connection_provider:, subscriptions_table:, events_table:, channels_table:, scan_count:, include_subscriptions:)
9
+ @connection_provider = connection_provider
10
+ @subscriptions_table = subscriptions_table
11
+ @events_table = events_table
12
+ @channels_table = channels_table
13
+ @scan_count = scan_count
14
+ @include_subscriptions = include_subscriptions
15
+ end
16
+
17
+ def collect
18
+ # PostgreSQL uses aggregate queries rather than key scans; scan_count
19
+ # is accepted to match graphql-anycable's store stats interface.
20
+ raise ArgumentError, "scan_count must be positive" if scan_count.to_i <= 0
21
+
22
+ with_connection do |conn|
23
+ result = {total: total_stats(conn)}
24
+ result[:subscriptions] = subscription_stats(conn) if include_subscriptions
25
+ result
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :channels_table, :connection_provider, :events_table, :include_subscriptions, :scan_count, :subscriptions_table
32
+
33
+ def with_connection(&block)
34
+ connection_provider.call(&block)
35
+ end
36
+
37
+ def total_stats(conn)
38
+ conn.exec_params(<<~SQL).first.transform_values(&:to_i).transform_keys(&:to_sym)
39
+ SELECT
40
+ (
41
+ SELECT COUNT(*)
42
+ FROM #{subscriptions_table}
43
+ WHERE #{active_subscription_sql}
44
+ ) AS subscription,
45
+ (
46
+ SELECT COUNT(DISTINCT events.topic)
47
+ FROM #{events_table} events
48
+ INNER JOIN #{subscriptions_table} subscriptions
49
+ ON subscriptions.id = events.subscription_id
50
+ WHERE #{active_subscription_sql("subscriptions")}
51
+ ) AS fingerprints,
52
+ (
53
+ SELECT COUNT(DISTINCT events.fingerprint)
54
+ FROM #{events_table} events
55
+ INNER JOIN #{subscriptions_table} subscriptions
56
+ ON subscriptions.id = events.subscription_id
57
+ WHERE #{active_subscription_sql("subscriptions")}
58
+ ) AS subscriptions,
59
+ (
60
+ SELECT COUNT(DISTINCT channels.channel_id)
61
+ FROM #{channels_table} channels
62
+ INNER JOIN #{subscriptions_table} subscriptions
63
+ ON subscriptions.id = channels.subscription_id
64
+ WHERE #{active_subscription_sql("subscriptions")}
65
+ ) AS channel
66
+ SQL
67
+ end
68
+
69
+ def subscription_stats(conn)
70
+ conn.exec_params(<<~SQL).to_h { |row| [row.fetch("topic"), row.fetch("subscriptions").to_i] }
71
+ SELECT events.topic, COUNT(DISTINCT events.subscription_id) AS subscriptions
72
+ FROM #{events_table} events
73
+ INNER JOIN #{subscriptions_table} subscriptions
74
+ ON subscriptions.id = events.subscription_id
75
+ WHERE #{active_subscription_sql("subscriptions")}
76
+ GROUP BY events.topic
77
+ ORDER BY events.topic ASC
78
+ SQL
79
+ end
80
+
81
+ def active_subscription_sql(table_name = nil)
82
+ prefix = table_name ? "#{table_name}." : ""
83
+ "(#{prefix}expires_at IS NULL OR #{prefix}expires_at > CURRENT_TIMESTAMP)"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "store/cleaner"
5
+ require_relative "store/stats"
4
6
 
5
7
  module GraphQL
6
8
  module AnyCable
@@ -156,15 +158,23 @@ module GraphQL
156
158
  end
157
159
 
158
160
  def stats(scan_count:, include_subscriptions: false)
159
- # PostgreSQL uses aggregate queries rather than key scans; scan_count
160
- # is accepted to match graphql-anycable's store stats interface.
161
- raise ArgumentError, "scan_count must be positive" if scan_count.to_i <= 0
161
+ Stats.new(
162
+ connection_provider: ->(&block) { with_connection(&block) },
163
+ subscriptions_table: subscriptions_table,
164
+ events_table: events_table,
165
+ channels_table: channels_table,
166
+ scan_count: scan_count,
167
+ include_subscriptions: include_subscriptions
168
+ ).collect
169
+ end
162
170
 
163
- with_connection do |conn|
164
- result = {total: total_stats(conn)}
165
- result[:subscriptions] = subscription_stats(conn) if include_subscriptions
166
- result
167
- end
171
+ def cleaner
172
+ @cleaner ||= Cleaner.new(
173
+ connection_provider: ->(&block) { with_connection(&block) },
174
+ subscriptions_table: subscriptions_table,
175
+ events_table: events_table,
176
+ channels_table: channels_table
177
+ )
168
178
  end
169
179
 
170
180
  private
@@ -204,55 +214,6 @@ module GraphQL
204
214
  Array.new(count) { |index| "$#{index + 1}" }.join(", ")
205
215
  end
206
216
 
207
- def total_stats(conn)
208
- conn.exec_params(<<~SQL).first.transform_values(&:to_i).transform_keys(&:to_sym)
209
- SELECT
210
- (
211
- SELECT COUNT(*)
212
- FROM #{subscriptions_table}
213
- WHERE #{active_subscription_sql}
214
- ) AS subscription,
215
- (
216
- SELECT COUNT(DISTINCT events.topic)
217
- FROM #{events_table} events
218
- INNER JOIN #{subscriptions_table} subscriptions
219
- ON subscriptions.id = events.subscription_id
220
- WHERE #{active_subscription_sql("subscriptions")}
221
- ) AS fingerprints,
222
- (
223
- SELECT COUNT(DISTINCT events.fingerprint)
224
- FROM #{events_table} events
225
- INNER JOIN #{subscriptions_table} subscriptions
226
- ON subscriptions.id = events.subscription_id
227
- WHERE #{active_subscription_sql("subscriptions")}
228
- ) AS subscriptions,
229
- (
230
- SELECT COUNT(DISTINCT channels.channel_id)
231
- FROM #{channels_table} channels
232
- INNER JOIN #{subscriptions_table} subscriptions
233
- ON subscriptions.id = channels.subscription_id
234
- WHERE #{active_subscription_sql("subscriptions")}
235
- ) AS channel
236
- SQL
237
- end
238
-
239
- def subscription_stats(conn)
240
- conn.exec_params(<<~SQL).to_h { |row| [row.fetch("topic"), row.fetch("subscriptions").to_i] }
241
- SELECT events.topic, COUNT(DISTINCT events.subscription_id) AS subscriptions
242
- FROM #{events_table} events
243
- INNER JOIN #{subscriptions_table} subscriptions
244
- ON subscriptions.id = events.subscription_id
245
- WHERE #{active_subscription_sql("subscriptions")}
246
- GROUP BY events.topic
247
- ORDER BY events.topic ASC
248
- SQL
249
- end
250
-
251
- def active_subscription_sql(table_name = nil)
252
- prefix = table_name ? "#{table_name}." : ""
253
- "(#{prefix}expires_at IS NULL OR #{prefix}expires_at > CURRENT_TIMESTAMP)"
254
- end
255
-
256
217
  def quote_table_name(name)
257
218
  parts = name.to_s.split(".")
258
219
  raise ArgumentError, "PostgreSQL table name cannot be empty" if parts.empty? || parts.any?(&:empty?)
@@ -3,7 +3,7 @@
3
3
  module GraphQL
4
4
  module AnyCable
5
5
  module PostgreSQLStore
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-anycable_postgresql-store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TikiTDO
@@ -117,6 +117,8 @@ files:
117
117
  - lib/graphql/anycable/postgresql_store/config.rb
118
118
  - lib/graphql/anycable/postgresql_store/railtie.rb
119
119
  - lib/graphql/anycable/postgresql_store/store.rb
120
+ - lib/graphql/anycable/postgresql_store/store/cleaner.rb
121
+ - lib/graphql/anycable/postgresql_store/store/stats.rb
120
122
  - lib/graphql/anycable/postgresql_store/version.rb
121
123
  homepage: https://github.com/TikiTDO/graphql-anycable_postgresql-store
122
124
  licenses:
@@ -142,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
144
  - !ruby/object:Gem::Version
143
145
  version: '0'
144
146
  requirements: []
145
- rubygems_version: 4.0.3
147
+ rubygems_version: 4.0.10
146
148
  specification_version: 4
147
149
  summary: PostgreSQL subscription store for graphql-anycable.
148
150
  test_files: []