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 +7 -0
- data/.rspec +2 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +137 -0
- data/Rakefile +8 -0
- data/graphql-anycable_postgresql-store.gemspec +41 -0
- data/lib/generators/graphql/anycable/postgresql_store/install_generator.rb +51 -0
- data/lib/generators/graphql/anycable/postgresql_store/templates/create_graphql_anycable_postgresql_store_tables.rb +48 -0
- data/lib/graphql/anycable/postgresql_store/config.rb +19 -0
- data/lib/graphql/anycable/postgresql_store/railtie.rb +12 -0
- data/lib/graphql/anycable/postgresql_store/store.rb +266 -0
- data/lib/graphql/anycable/postgresql_store/version.rb +9 -0
- data/lib/graphql/anycable/postgresql_store.rb +36 -0
- data/lib/graphql-anycable_postgresql-store.rb +3 -0
- metadata +148 -0
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
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
|
+
[](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,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,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,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
|
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: []
|