graphql-anycable_postgresql-store 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 98dc409019bc7dcd4b3cf768c48b0c849fa51b2a8fe13e752485cf37dbc6d347
4
+ data.tar.gz: fd1f0469d8f228257092cd4e6a8dcacfca9306139fb6e624c0c8ddd71a408bf3
5
+ SHA512:
6
+ metadata.gz: ee6d8b98417a98906635103a89c4c7683bc931f1da3f627a6e17aabbb8f62b41ea662f68e2d7c5f3950f878c2cdc41836ca2fd20cf0ad7fb849f319e4f4bf7b4
7
+ data.tar.gz: 935c1c06e7115264b065c57320fe1917bfc46fd12ae67cf58ebf6b32a5b9a44fafbdbdd07c29b7d73e6713e965f83c53105adad2c090716f7e81874739e84796
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format progress
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-05-27
4
+
5
+ - Add PostgreSQL subscription store for `graphql-anycable`.
6
+ - Register `:postgresql` and `:postgres` subscription store aliases.
7
+ - Add Rails install generator for the PostgreSQL store tables.
8
+ - Add store-backed stats support for `GraphQL::AnyCable.stats`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TikiTDO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # GraphQL AnyCable PostgreSQL Store
2
+
3
+ [![Tests](https://github.com/TikiTDO/graphql-anycable_postgresql-store/actions/workflows/test.yml/badge.svg)](https://github.com/TikiTDO/graphql-anycable_postgresql-store/actions/workflows/test.yml)
4
+
5
+ PostgreSQL subscription store for [`graphql-anycable`](https://github.com/anycable/graphql-anycable).
6
+
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
+
9
+ This gem requires a `graphql-anycable` version that supports custom subscription stores.
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem "graphql-anycable_postgresql-store"
17
+ ```
18
+
19
+ Then configure `graphql-anycable` to use the store:
20
+
21
+ ```ruby
22
+ GraphQL::AnyCable.configure do |config|
23
+ config.subscription_store = :postgresql
24
+ end
25
+ ```
26
+
27
+ The gem also registers `:postgres` as an alias for `:postgresql`.
28
+
29
+ ## Configuration
30
+
31
+ Configure the PostgreSQL connection and table names with environment variables:
32
+
33
+ ```.env
34
+ GRAPHQL_ANYCABLE_POSTGRESQL_STORE_POSTGRES_URL=postgres://localhost:5432/postgres
35
+ GRAPHQL_ANYCABLE_POSTGRESQL_STORE_SUBSCRIPTIONS_TABLE=graphql_anycable_subscriptions
36
+ GRAPHQL_ANYCABLE_POSTGRESQL_STORE_SUBSCRIPTION_EVENTS_TABLE=graphql_anycable_subscription_events
37
+ GRAPHQL_ANYCABLE_POSTGRESQL_STORE_CHANNEL_SUBSCRIPTIONS_TABLE=graphql_anycable_channel_subscriptions
38
+ ```
39
+
40
+ Or configure the gem from application code:
41
+
42
+ ```ruby
43
+ GraphQL::AnyCable::PostgreSQLStore.configure do |config|
44
+ config.postgres_url = ENV["DATABASE_URL"]
45
+ config.subscriptions_table = "graphql_anycable_subscriptions"
46
+ config.subscription_events_table = "graphql_anycable_subscription_events"
47
+ config.channel_subscriptions_table = "graphql_anycable_channel_subscriptions"
48
+ end
49
+ ```
50
+
51
+ If `postgres_url` is not configured, the store falls back to `AnyCable.config.postgres_url` when available, then to `DATABASE_URL`. If none are set, `PG.connect` uses libpq defaults.
52
+
53
+ ## Database schema
54
+
55
+ Rails applications with ActiveRecord can install the migration:
56
+
57
+ ```sh
58
+ bin/rails generate graphql:anycable:postgresql_store:install
59
+ bin/rails db:migrate
60
+ ```
61
+
62
+ The generator skips migration creation when ActiveRecord generator support is not available.
63
+
64
+ Applications can also create the tables directly:
65
+
66
+ ```sql
67
+ CREATE TABLE graphql_anycable_subscriptions (
68
+ id text PRIMARY KEY,
69
+ query_string text NOT NULL,
70
+ variables text NOT NULL,
71
+ context text NOT NULL,
72
+ operation_name text NOT NULL,
73
+ events jsonb NOT NULL DEFAULT '{}',
74
+ expires_at timestamptz,
75
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
76
+ updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
77
+ );
78
+
79
+ CREATE TABLE graphql_anycable_subscription_events (
80
+ subscription_id text NOT NULL REFERENCES graphql_anycable_subscriptions(id) ON DELETE CASCADE,
81
+ topic text NOT NULL,
82
+ fingerprint text NOT NULL,
83
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
84
+ PRIMARY KEY (subscription_id, topic, fingerprint)
85
+ );
86
+
87
+ CREATE INDEX index_graphql_anycable_subscription_events_topic_fingerprint
88
+ ON graphql_anycable_subscription_events (topic, fingerprint);
89
+
90
+ CREATE INDEX index_graphql_anycable_subscription_events_fingerprint
91
+ ON graphql_anycable_subscription_events (fingerprint);
92
+
93
+ CREATE TABLE graphql_anycable_channel_subscriptions (
94
+ channel_id text NOT NULL,
95
+ subscription_id text NOT NULL REFERENCES graphql_anycable_subscriptions(id) ON DELETE CASCADE,
96
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
97
+ PRIMARY KEY (channel_id, subscription_id)
98
+ );
99
+ ```
100
+
101
+ ## Stats
102
+
103
+ `GraphQL::AnyCable.stats` delegates to this store when `subscription_store` is
104
+ configured as `:postgresql` or `:postgres`. The store reports active
105
+ subscriptions, topics, fingerprints, and channels with SQL aggregate queries;
106
+ `scan_count` is accepted for graphql-anycable interface compatibility and is not
107
+ used by PostgreSQL.
108
+
109
+ ## Development
110
+
111
+ Install dependencies and run tests:
112
+
113
+ ```sh
114
+ GRAPHQL_ANYCABLE_PATH=../graphql-anycable bundle exec rspec
115
+ ```
116
+
117
+ Set `POSTGRES_URL` or `DATABASE_URL` to run the store integration spec against PostgreSQL.
118
+
119
+ CI runs the spec suite against a PostgreSQL service and checks out the `graphql-anycable` interface branch until the custom store API is released.
120
+
121
+ ## Release
122
+
123
+ Release notes are kept in `CHANGELOG.md` and published through GitHub Releases.
124
+ Build the gem with:
125
+
126
+ ```sh
127
+ bundle exec rake build
128
+ ```
129
+
130
+ Publish the same versioned gem artifact to RubyGems and GitHub Releases:
131
+
132
+ ```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" \
136
+ --notes-file CHANGELOG.md
137
+ ```
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/graphql/anycable/postgresql_store/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "graphql-anycable_postgresql-store"
7
+ spec.version = GraphQL::AnyCable::PostgreSQLStore::VERSION
8
+ spec.authors = ["TikiTDO"]
9
+
10
+ spec.summary = "PostgreSQL subscription store for graphql-anycable."
11
+ spec.description = "Stores graphql-anycable subscription state in PostgreSQL."
12
+ spec.homepage = "https://github.com/TikiTDO/graphql-anycable_postgresql-store"
13
+ spec.license = "MIT"
14
+
15
+ spec.metadata = {
16
+ "bug_tracker_uri" => "https://github.com/TikiTDO/graphql-anycable_postgresql-store/issues",
17
+ "changelog_uri" => "https://github.com/TikiTDO/graphql-anycable_postgresql-store/releases",
18
+ "homepage_uri" => spec.homepage,
19
+ "allowed_push_host" => "https://rubygems.org",
20
+ "rubygems_mfa_required" => "true",
21
+ "source_code_uri" => spec.homepage
22
+ }
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |file|
26
+ file.start_with?("spec/", ".github/", ".git", "Gemfile")
27
+ end
28
+ end
29
+ spec.files = (spec.files + %w[CHANGELOG.md]).uniq
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.required_ruby_version = ">= 3.0.0"
33
+
34
+ spec.add_dependency "anyway_config", ">= 1.3", "< 3"
35
+ spec.add_dependency "graphql-anycable", ">= 1.3.1"
36
+ spec.add_dependency "pg", ">= 1.2"
37
+
38
+ spec.add_development_dependency "bundler", ">= 2.0"
39
+ spec.add_development_dependency "rake", ">= 12.3.3"
40
+ spec.add_development_dependency "rspec", "~> 3.0"
41
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ begin
6
+ require "rails/generators/active_record"
7
+ rescue LoadError
8
+ # ActiveRecord is optional; non-ActiveRecord applications can use the SQL from the README.
9
+ end
10
+
11
+ module GraphQL
12
+ module AnyCable
13
+ module PostgreSQLStore
14
+ class InstallGenerator < Rails::Generators::Base
15
+ namespace "graphql:anycable:postgresql_store:install"
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ include ActiveRecord::Generators::Migration if defined?(ActiveRecord::Generators::Migration)
20
+
21
+ def self.next_migration_number(dirname)
22
+ if defined?(ActiveRecord::Generators::Base)
23
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
24
+ else
25
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
26
+ end
27
+ end
28
+
29
+ def create_migration
30
+ unless respond_to?(:migration_template, true)
31
+ say_status :skip, "ActiveRecord generators are not available", :yellow
32
+ return
33
+ end
34
+
35
+ migration_template(
36
+ "create_graphql_anycable_postgresql_store_tables.rb",
37
+ "db/migrate/create_graphql_anycable_postgresql_store_tables.rb"
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ return "" unless defined?(ActiveRecord::Migration) && ActiveRecord::Migration.respond_to?(:[])
45
+
46
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateGraphqlAnycablePostgresqlStoreTables < ActiveRecord::Migration<%= migration_version %>
4
+ def up
5
+ execute <<~SQL
6
+ CREATE TABLE IF NOT EXISTS graphql_anycable_subscriptions (
7
+ id text PRIMARY KEY,
8
+ query_string text NOT NULL,
9
+ variables text NOT NULL,
10
+ context text NOT NULL,
11
+ operation_name text NOT NULL,
12
+ events jsonb NOT NULL DEFAULT '{}',
13
+ expires_at timestamptz,
14
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+ updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS graphql_anycable_subscription_events (
19
+ subscription_id text NOT NULL REFERENCES graphql_anycable_subscriptions(id) ON DELETE CASCADE,
20
+ topic text NOT NULL,
21
+ fingerprint text NOT NULL,
22
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
23
+ PRIMARY KEY (subscription_id, topic, fingerprint)
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS index_graphql_anycable_subscription_events_topic_fingerprint
27
+ ON graphql_anycable_subscription_events (topic, fingerprint);
28
+
29
+ CREATE INDEX IF NOT EXISTS index_graphql_anycable_subscription_events_fingerprint
30
+ ON graphql_anycable_subscription_events (fingerprint);
31
+
32
+ CREATE TABLE IF NOT EXISTS graphql_anycable_channel_subscriptions (
33
+ channel_id text NOT NULL,
34
+ subscription_id text NOT NULL REFERENCES graphql_anycable_subscriptions(id) ON DELETE CASCADE,
35
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+ PRIMARY KEY (channel_id, subscription_id)
37
+ );
38
+ SQL
39
+ end
40
+
41
+ def down
42
+ execute <<~SQL
43
+ DROP TABLE IF EXISTS graphql_anycable_channel_subscriptions;
44
+ DROP TABLE IF EXISTS graphql_anycable_subscription_events;
45
+ DROP TABLE IF EXISTS graphql_anycable_subscriptions;
46
+ SQL
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway"
4
+
5
+ module GraphQL
6
+ module AnyCable
7
+ module PostgreSQLStore
8
+ class Config < Anyway::Config
9
+ config_name :graphql_anycable_postgresql_store
10
+ env_prefix :graphql_anycable_postgresql_store
11
+
12
+ attr_config postgres_url: nil
13
+ attr_config subscriptions_table: "graphql_anycable_subscriptions"
14
+ attr_config subscription_events_table: "graphql_anycable_subscription_events"
15
+ attr_config channel_subscriptions_table: "graphql_anycable_channel_subscriptions"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module GraphQL
6
+ module AnyCable
7
+ module PostgreSQLStore
8
+ class Railtie < Rails::Railtie
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module GraphQL
6
+ module AnyCable
7
+ module PostgreSQLStore
8
+ class Store
9
+ SUBSCRIPTIONS_PREFIX = "subscriptions:"
10
+
11
+ def initialize(config: PostgreSQLStore.config, graphql_config: GraphQL::AnyCable.config)
12
+ load_pg!
13
+
14
+ @config = config
15
+ @graphql_config = graphql_config
16
+ @mutex = Mutex.new
17
+ @subscriptions_table = quote_table_name(config.subscriptions_table)
18
+ @events_table = quote_table_name(config.subscription_events_table)
19
+ @channels_table = quote_table_name(config.channel_subscriptions_table)
20
+ end
21
+
22
+ def stream_for(fingerprint)
23
+ "#{graphql_config.redis_prefix}-#{SUBSCRIPTIONS_PREFIX}#{fingerprint}"
24
+ end
25
+
26
+ def fingerprints_for_topic(topic)
27
+ with_connection do |conn|
28
+ conn.exec_params(<<~SQL, [topic]).map { |row| row.fetch("fingerprint") }
29
+ SELECT events.fingerprint
30
+ FROM #{events_table} events
31
+ INNER JOIN #{subscriptions_table} subscriptions
32
+ ON subscriptions.id = events.subscription_id
33
+ WHERE events.topic = $1
34
+ AND (subscriptions.expires_at IS NULL OR subscriptions.expires_at > CURRENT_TIMESTAMP)
35
+ GROUP BY events.fingerprint
36
+ ORDER BY COUNT(*) ASC, MIN(events.created_at) ASC
37
+ SQL
38
+ end
39
+ end
40
+
41
+ def subscription_ids_for_fingerprints(fingerprints)
42
+ result = fingerprints.to_h { |fingerprint| [fingerprint, []] }
43
+ return result if fingerprints.empty?
44
+
45
+ with_connection do |conn|
46
+ conn.exec_params(<<~SQL, fingerprints).each do |row|
47
+ SELECT events.fingerprint, events.subscription_id
48
+ FROM #{events_table} events
49
+ INNER JOIN #{subscriptions_table} subscriptions
50
+ ON subscriptions.id = events.subscription_id
51
+ WHERE events.fingerprint IN (#{placeholders(fingerprints.length)})
52
+ AND (subscriptions.expires_at IS NULL OR subscriptions.expires_at > CURRENT_TIMESTAMP)
53
+ ORDER BY events.fingerprint ASC, events.created_at ASC
54
+ SQL
55
+ result[row.fetch("fingerprint")] << row.fetch("subscription_id")
56
+ end
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ def subscription_exists?(subscription_id)
63
+ with_connection do |conn|
64
+ conn.exec_params(<<~SQL, [subscription_id]).getvalue(0, 0) == "t"
65
+ SELECT EXISTS (
66
+ SELECT 1
67
+ FROM #{subscriptions_table}
68
+ WHERE id = $1
69
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
70
+ )
71
+ SQL
72
+ end
73
+ end
74
+
75
+ def write_subscription(subscription_id, channel_id:, data:, events:, expiration_seconds:)
76
+ expires_at = expiration_seconds ? (Time.now.utc + expiration_seconds).iso8601(6) : nil
77
+ subscription_params = [
78
+ subscription_id,
79
+ data.fetch(:query_string),
80
+ data.fetch(:variables),
81
+ data.fetch(:context),
82
+ data.fetch(:operation_name),
83
+ data.fetch(:events),
84
+ expires_at
85
+ ]
86
+
87
+ with_connection do |conn|
88
+ transaction(conn) do
89
+ conn.exec_params(<<~SQL, subscription_params)
90
+ INSERT INTO #{subscriptions_table}
91
+ (id, query_string, variables, context, operation_name, events, expires_at, created_at, updated_at)
92
+ VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
93
+ ON CONFLICT (id) DO UPDATE SET
94
+ query_string = EXCLUDED.query_string,
95
+ variables = EXCLUDED.variables,
96
+ context = EXCLUDED.context,
97
+ operation_name = EXCLUDED.operation_name,
98
+ events = EXCLUDED.events,
99
+ expires_at = EXCLUDED.expires_at,
100
+ updated_at = CURRENT_TIMESTAMP
101
+ SQL
102
+
103
+ conn.exec_params("DELETE FROM #{events_table} WHERE subscription_id = $1", [subscription_id])
104
+ conn.exec_params("DELETE FROM #{channels_table} WHERE subscription_id = $1", [subscription_id])
105
+ conn.exec_params(<<~SQL, [channel_id, subscription_id])
106
+ INSERT INTO #{channels_table} (channel_id, subscription_id, created_at)
107
+ VALUES ($1, $2, CURRENT_TIMESTAMP)
108
+ ON CONFLICT (channel_id, subscription_id) DO NOTHING
109
+ SQL
110
+
111
+ events.each do |event|
112
+ conn.exec_params(<<~SQL, [subscription_id, event.topic, event.fingerprint])
113
+ INSERT INTO #{events_table} (subscription_id, topic, fingerprint, created_at)
114
+ VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
115
+ ON CONFLICT (subscription_id, topic, fingerprint) DO NOTHING
116
+ SQL
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def read_subscription(subscription_id)
123
+ with_connection do |conn|
124
+ result = conn.exec_params(<<~SQL, [subscription_id])
125
+ SELECT query_string, variables, context, operation_name
126
+ FROM #{subscriptions_table}
127
+ WHERE id = $1
128
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
129
+ SQL
130
+ return if result.ntuples.zero?
131
+
132
+ result.first.transform_keys(&:to_sym)
133
+ end
134
+ end
135
+
136
+ def delete_channel_subscriptions(channel_id)
137
+ with_connection do |conn|
138
+ transaction(conn) do
139
+ conn.exec_params(<<~SQL, [channel_id])
140
+ DELETE FROM #{subscriptions_table}
141
+ WHERE id IN (
142
+ SELECT subscription_id
143
+ FROM #{channels_table}
144
+ WHERE channel_id = $1
145
+ )
146
+ SQL
147
+ conn.exec_params("DELETE FROM #{channels_table} WHERE channel_id = $1", [channel_id])
148
+ end
149
+ end
150
+ end
151
+
152
+ def delete_subscription(subscription_id)
153
+ with_connection do |conn|
154
+ conn.exec_params("DELETE FROM #{subscriptions_table} WHERE id = $1", [subscription_id])
155
+ end
156
+ end
157
+
158
+ 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
162
+
163
+ with_connection do |conn|
164
+ result = {total: total_stats(conn)}
165
+ result[:subscriptions] = subscription_stats(conn) if include_subscriptions
166
+ result
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ attr_reader :channels_table, :config, :events_table, :graphql_config, :mutex, :subscriptions_table
173
+
174
+ def load_pg!
175
+ require "pg"
176
+ rescue LoadError
177
+ raise "Please, install the pg gem to use PostgreSQL GraphQL::AnyCable subscriptions"
178
+ end
179
+
180
+ def with_connection
181
+ mutex.synchronize { yield connection }
182
+ end
183
+
184
+ def connection
185
+ @connection ||= ::PG.connect(postgres_url)
186
+ end
187
+
188
+ def postgres_url
189
+ config.postgres_url ||
190
+ (::AnyCable.config.postgres_url if defined?(::AnyCable) && ::AnyCable.config.respond_to?(:postgres_url)) ||
191
+ ENV["DATABASE_URL"]
192
+ end
193
+
194
+ def transaction(conn)
195
+ conn.exec("BEGIN")
196
+ yield
197
+ conn.exec("COMMIT")
198
+ rescue
199
+ conn.exec("ROLLBACK")
200
+ raise
201
+ end
202
+
203
+ def placeholders(count)
204
+ Array.new(count) { |index| "$#{index + 1}" }.join(", ")
205
+ end
206
+
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
+ def quote_table_name(name)
257
+ parts = name.to_s.split(".")
258
+ raise ArgumentError, "PostgreSQL table name cannot be empty" if parts.empty? || parts.any?(&:empty?)
259
+ raise ArgumentError, "PostgreSQL table name must be table or schema.table" if parts.size > 2
260
+
261
+ parts.map { |part| ::PG::Connection.quote_ident(part) }.join(".")
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module AnyCable
5
+ module PostgreSQLStore
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql-anycable"
4
+
5
+ unless GraphQL::AnyCable.respond_to?(:register_subscription_store)
6
+ raise LoadError, "graphql-anycable_postgresql-store requires a graphql-anycable version with custom subscription stores"
7
+ end
8
+
9
+ require_relative "postgresql_store/version"
10
+ require_relative "postgresql_store/config"
11
+ require_relative "postgresql_store/store"
12
+ require_relative "postgresql_store/railtie" if defined?(Rails::Railtie)
13
+
14
+ module GraphQL
15
+ module AnyCable
16
+ module PostgreSQLStore
17
+ class << self
18
+ def config
19
+ @config ||= Config.new
20
+ end
21
+
22
+ def configure
23
+ yield(config) if block_given?
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ GraphQL::AnyCable.register_subscription_store(:postgresql) do
31
+ GraphQL::AnyCable::PostgreSQLStore::Store.new
32
+ end
33
+
34
+ GraphQL::AnyCable.register_subscription_store(:postgres) do
35
+ GraphQL::AnyCable::PostgreSQLStore::Store.new
36
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql/anycable/postgresql_store"
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: graphql-anycable_postgresql-store
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - TikiTDO
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: anyway_config
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.3'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: graphql-anycable
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 1.3.1
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 1.3.1
46
+ - !ruby/object:Gem::Dependency
47
+ name: pg
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.2'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '1.2'
60
+ - !ruby/object:Gem::Dependency
61
+ name: bundler
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '2.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '2.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rake
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 12.3.3
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 12.3.3
88
+ - !ruby/object:Gem::Dependency
89
+ name: rspec
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '3.0'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '3.0'
102
+ description: Stores graphql-anycable subscription state in PostgreSQL.
103
+ executables: []
104
+ extensions: []
105
+ extra_rdoc_files: []
106
+ files:
107
+ - ".rspec"
108
+ - CHANGELOG.md
109
+ - LICENSE.txt
110
+ - README.md
111
+ - Rakefile
112
+ - graphql-anycable_postgresql-store.gemspec
113
+ - lib/generators/graphql/anycable/postgresql_store/install_generator.rb
114
+ - lib/generators/graphql/anycable/postgresql_store/templates/create_graphql_anycable_postgresql_store_tables.rb
115
+ - lib/graphql-anycable_postgresql-store.rb
116
+ - lib/graphql/anycable/postgresql_store.rb
117
+ - lib/graphql/anycable/postgresql_store/config.rb
118
+ - lib/graphql/anycable/postgresql_store/railtie.rb
119
+ - lib/graphql/anycable/postgresql_store/store.rb
120
+ - lib/graphql/anycable/postgresql_store/version.rb
121
+ homepage: https://github.com/TikiTDO/graphql-anycable_postgresql-store
122
+ licenses:
123
+ - MIT
124
+ metadata:
125
+ bug_tracker_uri: https://github.com/TikiTDO/graphql-anycable_postgresql-store/issues
126
+ changelog_uri: https://github.com/TikiTDO/graphql-anycable_postgresql-store/releases
127
+ homepage_uri: https://github.com/TikiTDO/graphql-anycable_postgresql-store
128
+ allowed_push_host: https://rubygems.org
129
+ rubygems_mfa_required: 'true'
130
+ source_code_uri: https://github.com/TikiTDO/graphql-anycable_postgresql-store
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 3.0.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 4.0.3
146
+ specification_version: 4
147
+ summary: PostgreSQL subscription store for graphql-anycable.
148
+ test_files: []