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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +11 -4
- data/lib/graphql/anycable/postgresql_store/store/cleaner.rb +73 -0
- data/lib/graphql/anycable/postgresql_store/store/stats.rb +89 -0
- data/lib/graphql/anycable/postgresql_store/store.rb +18 -57
- data/lib/graphql/anycable/postgresql_store/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f4a6948b58b9ff40992a1a02f9381919c29e25862a62eedfeb1ffd3b80ee5d1
|
|
4
|
+
data.tar.gz: db167a2158578f0d025dd0ef48dbcb1924dfeace2347d3e863fe45b8abdb4e74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
134
|
-
gh release create v0.
|
|
135
|
-
--title "v0.
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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?)
|
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.
|
|
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.
|
|
147
|
+
rubygems_version: 4.0.10
|
|
146
148
|
specification_version: 4
|
|
147
149
|
summary: PostgreSQL subscription store for graphql-anycable.
|
|
148
150
|
test_files: []
|