active_record_pgnats 1.0.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: 1d170a81eb308b003ad5a6c3eed99da1101d584bf46249cf66a458c6057a8641
4
+ data.tar.gz: e16809da5ed462c0af895d6fcc1fba5215f1ff0f8947646290c47ca24ff321bc
5
+ SHA512:
6
+ metadata.gz: '009a011ba3566c2a05af7f10a6494eebc3f3213cb55d1dba0beb75129d154b6c08bef87c3f9cfbf092db92ef672581d19704cc0cbe351d92cca4a87311e07c8e'
7
+ data.tar.gz: b37d38071d69ff6dbd8dac221e74b301b86edccdf30bf422f5b0ec231d06fdd203368321d590bed3ba3e5a53bf6ac7452b86a229a8b5d2605a9d5adbb71f3d3a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan K
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,201 @@
1
+ # active_record_pgnats
2
+
3
+ Active Record migrations for [pgnats](https://github.com/luxms/pgnats), the
4
+ Postgres extension that publishes to and subscribes from NATS.
5
+
6
+ Modelled on [F(x)](https://github.com/teoljungberg/fx) and
7
+ [pg_cron-rails](https://github.com/n-at-han-k/pg_cron-rails): migration
8
+ statements, reversibility through the command recorder, and a schema dumper so
9
+ `schema.rb` carries what the extension holds.
10
+
11
+ Two gems in this repository, sharing one `lib/` tree:
12
+
13
+ | gem | what it is |
14
+ |---|---|
15
+ | `active_record_pgnats` | the statements, the adapter and the dumper. Active Record only — no `railties`, no `Rails.root`, no railtie |
16
+ | `active_record_pgnats-rails` | a railtie installing the hooks at the `on_load :active_record` moment, and generators for the migrations |
17
+
18
+ ```ruby
19
+ # Gemfile -- Active Record on its own
20
+ gem "active_record_pgnats"
21
+ ```
22
+
23
+ ```ruby
24
+ require "active_record_pgnats" # installs the statements, recorder and dumper
25
+ ```
26
+
27
+ ```ruby
28
+ # Gemfile -- Rails
29
+ gem "active_record_pgnats-rails"
30
+ ```
31
+
32
+ Under Rails the core defers: requiring it does nothing until the railtie runs
33
+ the install, because Active Record may not be loaded when the bundle is. Use the
34
+ `-rails` gem there rather than the core on its own.
35
+
36
+ ```sh
37
+ rails generate active_record_pgnats:server nats --host=127.0.0.1 --port=4222
38
+ rails generate active_record_pgnats:subscription events.user.created handle_user_created
39
+ rails generate active_record_pgnats:publish_trigger maps_devices --subject="devices.%s.%s"
40
+ ```
41
+
42
+ pgnats itself has to be loaded by the server (`shared_preload_libraries =
43
+ 'pgnats'`) and enabled in the database. That part is Active Record's own:
44
+
45
+ ```ruby
46
+ enable_extension "pgnats"
47
+ ```
48
+
49
+ Every statement below is a no-op when the extension is not installed, so a
50
+ migration still runs against a database without pgnats instead of needing its
51
+ own guard.
52
+
53
+ ## The server
54
+
55
+ pgnats keeps its entire configuration on a foreign server, and allows exactly
56
+ one per database — its own event trigger raises on a second.
57
+
58
+ ```ruby
59
+ class ConfigureNats < ActiveRecord::Migration[7.2]
60
+ def change
61
+ create_nats_server :nats, host: "127.0.0.1", port: 4222
62
+ end
63
+ end
64
+ ```
65
+
66
+ Options: `host`, `port`, `capacity`, `tls_ca_path`, `tls_cert_path`,
67
+ `tls_key_path`, `notify_subject`, `patroni_url`. Anything else raises — the
68
+ wrapper's validator accepts a typo that pgnats then ignores, which is a bad way
69
+ to find out. Values are cast to text, because every FDW option is text.
70
+
71
+ Changing them is declarative: the hash is the desired state, and an option left
72
+ out is dropped.
73
+
74
+ ```ruby
75
+ update_nats_server :nats,
76
+ host: "127.0.0.1", capacity: 256,
77
+ revert_to: {host: "127.0.0.1"}
78
+ ```
79
+
80
+ The adapter diffs against the catalog to pick `SET` or `ADD` per option, since
81
+ `ALTER SERVER` rejects the wrong one, and follows every change with
82
+ `pgnats_reload_conf()` so the running background worker picks it up instead of
83
+ waiting for its next connect.
84
+
85
+ `revert_to:` is what makes `update_nats_server` and `drop_nats_server`
86
+ reversible. Without it a rollback raises `IrreversibleMigration` rather than
87
+ leaving the database unconfigured.
88
+
89
+ ## Subscriptions
90
+
91
+ ```ruby
92
+ create_nats_subscription "events.user.created", function: "handle_user_created"
93
+ ```
94
+
95
+ The function has to exist already and must take a single `bytea` — the message
96
+ payload. Create it in an earlier migration; F(x) coexists with this gem and is
97
+ the obvious way. A subscription needs no `revert_to`: the subject and the
98
+ function are the whole of it.
99
+
100
+ Note what this buys you: a core NATS subscription, so no replay and no acks.
101
+ Messages published while the database is down are gone.
102
+
103
+ ## Publishing row changes
104
+
105
+ ```ruby
106
+ create_nats_publish_trigger :maps_devices, subject: "pg_changes.%s.%s"
107
+ ```
108
+
109
+ Writes a trigger function and an `AFTER INSERT OR UPDATE OR DELETE` trigger,
110
+ both named `nats_publish_<table>`. `subject` is a format string given the table
111
+ name and the lowercased operation, so one definition covers all three. The
112
+ payload is `{table, op, at, row}`, with `row` the new tuple (the old one on a
113
+ delete).
114
+
115
+ The publish is wrapped in its own `BEGIN … EXCEPTION WHEN OTHERS THEN NULL`, so
116
+ a NATS problem can never roll back the write that fired the trigger. Put another
117
+ way: this is best-effort, and pgnats' JetStream publish does not wait for an
118
+ ack, so a subject no stream covers is dropped silently. Create the stream first.
119
+
120
+ `drop_nats_publish_trigger :maps_devices, revert_to_subject: "pg_changes.%s.%s"`
121
+ to go the other way.
122
+
123
+ ## schema.rb
124
+
125
+ The dumper appends the server and its subscriptions after the tables:
126
+
127
+ ```ruby
128
+ create_nats_server :nats, host: "127.0.0.1", port: "4222"
129
+
130
+ create_nats_subscription "events.user.created", function: "handle_user_created"
131
+ ```
132
+
133
+ It hooks `#tables` rather than `#extensions`, which is F(x)'s choice and is
134
+ load-bearing: `#extensions` is private and is redefined by the PostgreSQL
135
+ dumper, so a prepended module never intercepts it and the section silently never
136
+ appears.
137
+
138
+ Publish triggers are **not** dumped here. Once created they are an ordinary
139
+ function and trigger, which F(x) dumps as `create_function` / `create_trigger`
140
+ and `structure.sql` carries anyway; emitting them twice would make a schema load
141
+ fail.
142
+
143
+ ## Using another connection
144
+
145
+ ```ruby
146
+ ActiveRecordPgnats.configure do |config|
147
+ config.adapter = ActiveRecordPgnats::Adapters::Postgres.new(SomeOtherModel)
148
+ end
149
+ ```
150
+
151
+ It defaults to `ActiveRecord::Base`, which is almost always right: the foreign
152
+ server and `pgnats.subscriptions` live in the application's own database.
153
+
154
+ ## Alongside F(x)
155
+
156
+ Safe to load together. Every helper here, private ones included, is prefixed
157
+ `nats_` / `_nats_` on purpose — both modules are included into
158
+ `ActiveRecord::ConnectionAdapters::AbstractAdapter`, and a shared private name
159
+ shadows F(x)'s copy for every migration in the application. pg_cron-rails
160
+ learned this the hard way: its `resolve_sql_definition` took one argument fewer
161
+ than F(x)'s, so every `create_function` in the host app died with
162
+ `ArgumentError: wrong number of arguments (given 4, expected 3)`.
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ nix develop # or direnv allow
168
+ bin/setup # install dependencies
169
+ bin/test # run the tests
170
+ bin/console # interactive prompt
171
+ ```
172
+
173
+ ```sh
174
+ bin/test # no database needed
175
+ DATABASE_URL=postgres:///my_db bin/test test/integration # against real pgnats
176
+ ```
177
+
178
+ The unit suite runs against a fake adapter. The integration spec skips itself
179
+ unless `DATABASE_URL` is set and pgnats is installed there.
180
+
181
+ One Gemfile covers the whole repository. Neither of its own gems is in the
182
+ bundle — the devshell puts `lib/` on `RUBYLIB` — so no lockfile records a
183
+ version and a bump has nothing to keep in step. After touching the Gemfile,
184
+ `nix develop --command bundix -l`.
185
+
186
+ Each gem carries its own version, changelog (`CHANGELOG-<gem>.md`) and tag
187
+ (`<gem>-v1.2.3`), and is released on its own schedule with
188
+ [gem_kit-release](https://github.com/n-at-han-k/gem_kit):
189
+
190
+ ```bash
191
+ gem kit bump patch --gem active_record_pgnats-rails
192
+ gem kit changelog --write --gem active_record_pgnats-rails
193
+ gem kit release --gem active_record_pgnats-rails
194
+ ```
195
+
196
+ `active_record_pgnats-rails` derives its dependency on the core from the core's
197
+ own version, so a bump there does not have to be written out twice.
198
+
199
+ ## License
200
+
201
+ [MIT](LICENSE)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module ActiveRecordPgnats
6
+ module Adapters
7
+ class Postgres
8
+ # Answers whether pgnats is installed in this database.
9
+ #
10
+ # @api private
11
+ class Connection < SimpleDelegator
12
+ # Whether the extension is there to be used.
13
+ #
14
+ # @returns [Boolean] false until CREATE EXTENSION has run, which additionally needs pgnats in shared_preload_libraries and a restart. Every caller checks first, so a migration or a dump against a database without it is a no-op rather than an error.
15
+ def pgnats_enabled?
16
+ extension_enabled?("pgnats")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ module Adapters
5
+ class Postgres
6
+ # Runs a query and maps its rows to domain objects.
7
+ # @api private
8
+ class QueryExecutor
9
+ def self.call(...)
10
+ new(...).call
11
+ end
12
+
13
+ def initialize(connection:, query:, model_class:)
14
+ @connection = connection
15
+ @query = query
16
+ @model_class = model_class
17
+ end
18
+
19
+ def call
20
+ connection.execute(query).map { |row| model_class.new(row) }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :connection, :query, :model_class
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_pgnats/server"
4
+ require "active_record_pgnats/adapters/postgres/query_executor"
5
+
6
+ module ActiveRecordPgnats
7
+ module Adapters
8
+ class Postgres
9
+ # Fetches pgnats' foreign server from the connection.
10
+ #
11
+ # @api private
12
+ class Servers
13
+ # Selected by wrapper, not by name: the name is the migration's to choose, and pgnats itself joins pg_foreign_data_wrapper on fdwname to find it.
14
+ SERVERS_QUERY = <<~SQL.freeze
15
+ SELECT
16
+ s.srvname AS name,
17
+ s.srvoptions AS options
18
+ FROM pg_foreign_server s
19
+ JOIN pg_foreign_data_wrapper w
20
+ ON w.oid = s.srvfdw
21
+ WHERE w.fdwname = 'pgnats_fdw'
22
+ ORDER BY s.srvname;
23
+ SQL
24
+
25
+ def self.all(connection)
26
+ QueryExecutor.call(connection: connection, query: SERVERS_QUERY, model_class: Server)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_pgnats/subscription"
4
+ require "active_record_pgnats/adapters/postgres/query_executor"
5
+
6
+ module ActiveRecordPgnats
7
+ module Adapters
8
+ class Postgres
9
+ # Fetches subscriptions from pgnats' own table.
10
+ #
11
+ # @api private
12
+ class Subscriptions
13
+ # pgnats.subscriptions is the durable state its worker reads on start, so there is no catalog view to read instead.
14
+ SUBSCRIPTIONS_QUERY = <<~SQL.freeze
15
+ SELECT subject, callback
16
+ FROM pgnats.subscriptions
17
+ ORDER BY subject, callback;
18
+ SQL
19
+
20
+ def self.all(connection)
21
+ QueryExecutor.call(connection: connection, query: SUBSCRIPTIONS_QUERY, model_class: Subscription)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_pgnats/adapters/postgres/connection"
4
+ require "active_record_pgnats/adapters/postgres/servers"
5
+ require "active_record_pgnats/adapters/postgres/subscriptions"
6
+ require "active_record_pgnats/publish_trigger"
7
+
8
+ module ActiveRecordPgnats
9
+ module Adapters
10
+ # The Postgres adapter, the only engine pgnats has.
11
+ class Postgres
12
+ def initialize(connectable = ActiveRecord::Base)
13
+ @connectable = connectable
14
+ end
15
+
16
+ def pgnats_enabled?
17
+ connection.pgnats_enabled?
18
+ end
19
+
20
+ # @return [Array<ActiveRecordPgnats::Server>]
21
+ def servers
22
+ if pgnats_enabled?
23
+ Servers.all(connection)
24
+ else
25
+ []
26
+ end
27
+ end
28
+
29
+ # @return [Array<ActiveRecordPgnats::Subscription>]
30
+ def subscriptions
31
+ if pgnats_enabled?
32
+ Subscriptions.all(connection)
33
+ else
34
+ []
35
+ end
36
+ end
37
+
38
+ def create_server(name, options)
39
+ execute(<<~SQL)
40
+ CREATE SERVER #{quote_name(name)} FOREIGN DATA WRAPPER pgnats_fdw
41
+ OPTIONS (#{options.map { |key, value| "#{key} #{quote(value)}" }.join(", ")});
42
+ SQL
43
+
44
+ reload_conf
45
+ end
46
+
47
+ # Moves the server's options to the given set.
48
+ #
49
+ # @parameter name [String | Symbol] the server to alter.
50
+ # @parameter options [Hash] the desired state, not a patch: anything absent is dropped.
51
+ # @returns [void]
52
+ def update_server(name, options)
53
+ # ALTER SERVER takes SET for an option already present and ADD for one that is not, and rejects the wrong verb, so the current options are read back and diffed.
54
+ current = server_options(name)
55
+
56
+ changes = options.map { |key, value| "#{current.key?(key) ? "SET" : "ADD"} #{key} #{quote(value)}" }
57
+ changes += (current.keys - options.keys).map { |key| "DROP #{key}" }
58
+
59
+ if changes.empty?
60
+ nil
61
+ else
62
+ execute("ALTER SERVER #{quote_name(name)} OPTIONS (#{changes.join(", ")});")
63
+ reload_conf
64
+ end
65
+ end
66
+
67
+ def drop_server(name)
68
+ execute("DROP SERVER #{quote_name(name)};")
69
+ reload_conf
70
+ end
71
+
72
+ def create_subscription(subject, function)
73
+ execute("SELECT nats_subscribe(#{quote(subject)}, #{quote(function)}::regproc);")
74
+ end
75
+
76
+ def drop_subscription(subject, function)
77
+ execute("SELECT nats_unsubscribe(#{quote(subject)}, #{quote(function)}::regproc);")
78
+ end
79
+
80
+ def create_publish_trigger(table, subject)
81
+ execute(PublishTrigger.sql(table: table, subject: subject))
82
+ end
83
+
84
+ def drop_publish_trigger(table)
85
+ execute(PublishTrigger.drop_sql(table: table))
86
+ end
87
+
88
+ # Tells the background worker to re-read the options.
89
+ #
90
+ # @returns [void] without this the new configuration only takes effect on the next connect.
91
+ def reload_conf
92
+ execute("SELECT pgnats_reload_conf();")
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :connectable
98
+
99
+ def connection
100
+ Connection.new(connectable.connection)
101
+ end
102
+
103
+ def execute(sql)
104
+ connection.execute(sql)
105
+ end
106
+
107
+ def server_options(name)
108
+ servers.find { |server| server.name == name.to_s }&.options || {}
109
+ end
110
+
111
+ def quote(value)
112
+ connection.quote(value.to_s)
113
+ end
114
+
115
+ def quote_name(name)
116
+ connection.quote_table_name(name)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ # Makes the statements reversible.
5
+ module CommandRecorder
6
+ %i[
7
+ create_nats_server update_nats_server drop_nats_server
8
+ create_nats_subscription drop_nats_subscription
9
+ create_nats_publish_trigger drop_nats_publish_trigger
10
+ ].each do |statement|
11
+ define_method(statement) do |*args, &block|
12
+ record(statement, args, &block)
13
+ end
14
+ ruby2_keywords statement
15
+ end
16
+
17
+ # Inverts a server creation.
18
+ #
19
+ # @parameter args [Array] the recorded arguments.
20
+ # @returns [Array] the statement to run instead, and its arguments. A subscription inverts on its own, subject plus function being the whole of it; the server does not, its options being the definition, so a migration that drops or rewrites them has to say what to go back to.
21
+ def invert_create_nats_server(args)
22
+ [:drop_nats_server, args]
23
+ end
24
+
25
+ def invert_drop_nats_server(args)
26
+ _nats_invert_server(:create_nats_server, args)
27
+ end
28
+
29
+ def invert_update_nats_server(args)
30
+ _nats_invert_server(:update_nats_server, args)
31
+ end
32
+
33
+ def invert_create_nats_subscription(args)
34
+ [:drop_nats_subscription, args]
35
+ end
36
+
37
+ def invert_drop_nats_subscription(args)
38
+ [:create_nats_subscription, args]
39
+ end
40
+
41
+ def invert_create_nats_publish_trigger(args)
42
+ [:drop_nats_publish_trigger, [args.first]]
43
+ end
44
+
45
+ def invert_drop_nats_publish_trigger(args)
46
+ name, options = args
47
+ subject = (options || {})[:revert_to_subject]
48
+
49
+ unless subject
50
+ raise ActiveRecord::IrreversibleMigration,
51
+ "`drop_nats_publish_trigger` is reversible only if given a `revert_to_subject`"
52
+ end
53
+
54
+ [:create_nats_publish_trigger, [name, Hash.ruby2_keywords_hash({subject: subject})]]
55
+ end
56
+
57
+ private
58
+
59
+ def _nats_invert_server(method, args)
60
+ name, options = args
61
+ revert_to = (options || {})[:revert_to]
62
+
63
+ unless revert_to
64
+ raise ActiveRecord::IrreversibleMigration,
65
+ "`#{method}` is reversible only if given a `revert_to`"
66
+ end
67
+
68
+ [method, [name, Hash.ruby2_keywords_hash(revert_to.to_h)]]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ class Configuration
5
+ # The adapter statements run through. Defaults to the Postgres adapter over ActiveRecord::Base's own connection: pgnats' foreign server and its subscriptions table live in the application's database, so that is the connection that can see them.
6
+ attr_accessor :adapter
7
+
8
+ def initialize(adapter: Adapters::Postgres.new)
9
+ @adapter = adapter
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ # Builds the SQL for a publishing trigger.
5
+ #
6
+ # @api private
7
+ module PublishTrigger
8
+ module_function
9
+
10
+ def name_for(table)
11
+ "nats_publish_#{table}"
12
+ end
13
+
14
+ def sql(table:, subject:, name: nil)
15
+ name ||= name_for(table)
16
+
17
+ <<~SQL
18
+ CREATE OR REPLACE FUNCTION #{name}() RETURNS trigger
19
+ LANGUAGE plpgsql AS $pgnats$
20
+ DECLARE
21
+ payload jsonb;
22
+ BEGIN
23
+ IF TG_OP = 'DELETE' THEN
24
+ payload := to_jsonb(OLD);
25
+ ELSE
26
+ payload := to_jsonb(NEW);
27
+ END IF;
28
+
29
+ -- A NATS problem must never roll back the write that fired the trigger.
30
+ BEGIN
31
+ PERFORM nats_publish_json_stream(
32
+ format(#{quote(subject)}, TG_TABLE_NAME, lower(TG_OP)),
33
+ json_build_object('table', TG_TABLE_NAME, 'op', TG_OP, 'at', now(), 'row', payload)
34
+ );
35
+ EXCEPTION WHEN OTHERS THEN
36
+ NULL;
37
+ END;
38
+
39
+ RETURN NULL;
40
+ END
41
+ $pgnats$;
42
+
43
+ DROP TRIGGER IF EXISTS #{name} ON #{table};
44
+
45
+ CREATE TRIGGER #{name}
46
+ AFTER INSERT OR UPDATE OR DELETE ON #{table}
47
+ FOR EACH ROW EXECUTE FUNCTION #{name}();
48
+ SQL
49
+ end
50
+
51
+ def drop_sql(table:, name: nil)
52
+ name ||= name_for(table)
53
+
54
+ <<~SQL
55
+ DROP TRIGGER IF EXISTS #{name} ON #{table};
56
+ DROP FUNCTION IF EXISTS #{name}();
57
+ SQL
58
+ end
59
+
60
+ def quote(value)
61
+ "'#{value.to_s.gsub("'", "''")}'"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ # Writes the server and its subscriptions into schema.rb.
5
+ #
6
+ # @api private
7
+ module SchemaDumper
8
+ # Appends the pgnats section to the schema.
9
+ #
10
+ # @parameter stream [IO] the schema being written.
11
+ # @returns [void] #tables is the hook, as it is in F(x) and for the same reason: #extensions is private and is redefined by the PostgreSQL-specific dumper, so a prepended module never intercepts it and the dump comes out silently missing its pgnats section.
12
+ def tables(stream)
13
+ # After super, because a subscription names a function that a statement above it creates.
14
+ super
15
+
16
+ nats_servers(stream)
17
+ nats_subscriptions(stream)
18
+ end
19
+
20
+ private
21
+
22
+ def nats_servers(stream)
23
+ ActiveRecordPgnats.database.servers.each do |server|
24
+ stream.puts
25
+ stream.puts(server.to_schema)
26
+ end
27
+ end
28
+
29
+ def nats_subscriptions(stream)
30
+ ActiveRecordPgnats.database.subscriptions.each do |subscription|
31
+ stream.puts
32
+ stream.puts(subscription.to_schema)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ # pgnats' foreign server, as pg_foreign_server holds it.
5
+ #
6
+ # @api private
7
+ class Server
8
+ include Comparable
9
+
10
+ attr_reader :name, :options
11
+
12
+ def initialize(row)
13
+ @name = row.fetch("name")
14
+ @options = self.class.parse_options(row.fetch("options"))
15
+ end
16
+
17
+ # Parses srvoptions into a hash.
18
+ #
19
+ # @parameter value [Array | String] a Postgres text[] of "key=value" items, which the pg adapter hands over either as an Array or as the raw "{a=b,c=d}" literal.
20
+ # @returns [Hash] option name => value.
21
+ def self.parse_options(value)
22
+ if value.is_a?(Array)
23
+ items = value
24
+ else
25
+ items = value.to_s.delete_prefix("{").delete_suffix("}").split(",")
26
+ end
27
+
28
+ items.to_h do |item|
29
+ key, _, rest = item.to_s.partition("=")
30
+ [key, rest]
31
+ end
32
+ end
33
+
34
+ def <=>(other)
35
+ name <=> other.name
36
+ end
37
+
38
+ def ==(other)
39
+ name == other.name && options == other.options
40
+ end
41
+
42
+ def to_schema
43
+ <<~SCHEMA.then { |schema| schema.gsub(/^(?=.)/, " ") }
44
+ create_nats_server :#{name}, #{options.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")}
45
+ SCHEMA
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ # The migration DSL for pgnats.
5
+ module Statements
6
+ # The options pgnats reads off its foreign server.
7
+ SERVER_OPTIONS = %i[
8
+ host port capacity tls_ca_path tls_cert_path tls_key_path notify_subject patroni_url
9
+ ].freeze
10
+
11
+ # Creates pgnats' foreign server, its whole configuration.
12
+ #
13
+ # @example
14
+ # create_nats_server :nats, host: "127.0.0.1", port: 4222
15
+ #
16
+ # @parameter name [String | Symbol] the server's name, the migration's to choose.
17
+ # @parameter revert_to [Hash] unused here; the command recorder reads it when inverting.
18
+ # @parameter options [Hash] any of {SERVER_OPTIONS}.
19
+ # @returns [void] nothing happens at all when pgnats is not installed in this database.
20
+ def create_nats_server(name, revert_to: nil, **options)
21
+ # pgnats permits exactly one server on the pgnats_fdw wrapper per database -- its own event trigger raises on a second -- so later changes go through #update_nats_server.
22
+ if ActiveRecordPgnats.database.pgnats_enabled?
23
+ ActiveRecordPgnats.database.create_server(name, _nats_validated_options(options))
24
+ end
25
+ end
26
+
27
+ # Changes the server's options to exactly those given.
28
+ #
29
+ # @parameter name [String | Symbol] the server to alter.
30
+ # @parameter revert_to [Hash] the options to restore on rollback; without it the migration is irreversible.
31
+ # @parameter options [Hash] the options the server should end up with. Declarative, not additive: anything left out is dropped.
32
+ # @returns [void]
33
+ def update_nats_server(name, revert_to: nil, **options)
34
+ if ActiveRecordPgnats.database.pgnats_enabled?
35
+ ActiveRecordPgnats.database.update_server(name, _nats_validated_options(options))
36
+ end
37
+ end
38
+
39
+ # Drops the server, leaving pgnats unconfigured.
40
+ #
41
+ # @parameter name [String | Symbol] the server to drop.
42
+ # @parameter revert_to [Hash] the options to recreate it with on rollback; without it the migration is irreversible.
43
+ # @returns [void]
44
+ def drop_nats_server(name, revert_to: nil)
45
+ if ActiveRecordPgnats.database.pgnats_enabled?
46
+ ActiveRecordPgnats.database.drop_server(name)
47
+ end
48
+ end
49
+
50
+ # Subscribes a Postgres function to a NATS subject.
51
+ #
52
+ # @example
53
+ # create_nats_subscription "events.user.created", function: "handle_user_created"
54
+ #
55
+ # @parameter subject [String] the NATS subject to subscribe to.
56
+ # @parameter function [String | Symbol] the function the background worker calls. It must exist already and take a single bytea, the message payload.
57
+ # @returns [void]
58
+ def create_nats_subscription(subject, function:)
59
+ # nats_subscribe resolves the name to an OID, so create the function in an earlier migration -- F(x) is the obvious way -- or this fails.
60
+ if ActiveRecordPgnats.database.pgnats_enabled?
61
+ ActiveRecordPgnats.database.create_subscription(subject, function)
62
+ end
63
+ end
64
+
65
+ # Unsubscribes the function from the subject.
66
+ #
67
+ # @parameter subject [String] the subject it was subscribed to.
68
+ # @parameter function [String | Symbol] the function to stop calling.
69
+ # @returns [void]
70
+ def drop_nats_subscription(subject, function:)
71
+ if ActiveRecordPgnats.database.pgnats_enabled?
72
+ ActiveRecordPgnats.database.drop_subscription(subject, function)
73
+ end
74
+ end
75
+
76
+ # Publishes every row change on a table.
77
+ #
78
+ # @example
79
+ # create_nats_publish_trigger :maps_devices, subject: "pg_changes.%s.%s"
80
+ #
81
+ # @parameter table [String | Symbol] the table to watch.
82
+ # @parameter subject [String] a format string given the table name and the lowercased operation, so one definition covers insert, update and delete.
83
+ # @parameter revert_to_subject [String] unused here; the command recorder reads it when inverting a drop.
84
+ # @returns [void]
85
+ def create_nats_publish_trigger(table, subject:, revert_to_subject: nil)
86
+ # The function and the trigger are ordinary ones once created, so the schema dumper leaves them alone: F(x) dumps them, and structure.sql carries them either way.
87
+ if ActiveRecordPgnats.database.pgnats_enabled?
88
+ ActiveRecordPgnats.database.create_publish_trigger(table, subject)
89
+ end
90
+ end
91
+
92
+ # Drops the publish trigger and its function.
93
+ #
94
+ # @parameter table [String | Symbol] the table it watches.
95
+ # @parameter revert_to_subject [String] the subject to recreate it with on rollback; without it the migration is irreversible.
96
+ # @returns [void]
97
+ def drop_nats_publish_trigger(table, revert_to_subject: nil)
98
+ if ActiveRecordPgnats.database.pgnats_enabled?
99
+ ActiveRecordPgnats.database.drop_publish_trigger(table)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Rejects unknown options and stringifies the rest.
106
+ #
107
+ # @parameter options [Hash] the options as the migration wrote them.
108
+ # @returns [Hash] the same options with keys and values as strings, because every FDW option is text.
109
+ # @raises [ArgumentError] when an option is not one pgnats reads: the wrapper's validator accepts a typo that pgnats then ignores.
110
+ def _nats_validated_options(options)
111
+ unknown = options.keys.map(&:to_sym) - SERVER_OPTIONS
112
+
113
+ unless unknown.empty?
114
+ message = "unknown pgnats server option(s): #{unknown.join(", ")}. Known options: #{SERVER_OPTIONS.join(", ")}"
115
+
116
+ raise ArgumentError, message, caller
117
+ end
118
+
119
+ options.to_h { |key, value| [key.to_s, value.to_s] }
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ # A row of pgnats.subscriptions: a subject and its function.
5
+ #
6
+ # @api private
7
+ class Subscription
8
+ include Comparable
9
+
10
+ attr_reader :subject, :function
11
+
12
+ def initialize(row)
13
+ @subject = row.fetch("subject")
14
+ @function = row.fetch("callback")
15
+ end
16
+
17
+ def <=>(other)
18
+ [subject, function] <=> [other.subject, other.function]
19
+ end
20
+
21
+ def ==(other)
22
+ subject == other.subject && function == other.function
23
+ end
24
+
25
+ def to_schema
26
+ <<~SCHEMA.then { |schema| schema.gsub(/^(?=.)/, " ") }
27
+ create_nats_subscription #{subject.inspect}, function: #{function.inspect}
28
+ SCHEMA
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPgnats
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ require "active_record_pgnats/version"
6
+ require "active_record_pgnats/adapters/postgres"
7
+ require "active_record_pgnats/command_recorder"
8
+ require "active_record_pgnats/configuration"
9
+ require "active_record_pgnats/publish_trigger"
10
+ require "active_record_pgnats/schema_dumper"
11
+ require "active_record_pgnats/server"
12
+ require "active_record_pgnats/statements"
13
+ require "active_record_pgnats/subscription"
14
+
15
+ # Migration methods for pgnats, Active Record only.
16
+ module ActiveRecordPgnats
17
+ class << self
18
+ # Installs the statements, the recorder and the dumper.
19
+ #
20
+ # @returns [Boolean] true the first time, false once the hooks are in place.
21
+ def load
22
+ if @loaded
23
+ false
24
+ else
25
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.include(Statements)
26
+ ActiveRecord::Migration::CommandRecorder.include(CommandRecorder)
27
+ ActiveRecord::SchemaDumper.prepend(SchemaDumper)
28
+ @loaded = true
29
+ end
30
+ end
31
+
32
+ def configuration
33
+ @configuration ||= Configuration.new
34
+ end
35
+
36
+ attr_writer :configuration
37
+
38
+ def configure
39
+ yield configuration
40
+ end
41
+
42
+ def database
43
+ configuration.adapter
44
+ end
45
+ end
46
+ end
47
+
48
+ unless defined?(::Rails::Railtie)
49
+ ActiveRecordPgnats.load
50
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_record_pgnats
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan K
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pg
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.13'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.13'
68
+ description: |
69
+ Adds methods to ActiveRecord::Migration to manage pgnats: its foreign server,
70
+ its subject subscriptions, and triggers that publish row changes to NATS.
71
+ Active Record only -- Rails support lives in the sibling
72
+ active_record_pgnats-rails gem.
73
+ email:
74
+ - nathankidd@hey.com
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - LICENSE
80
+ - README.md
81
+ - lib/active_record_pgnats.rb
82
+ - lib/active_record_pgnats/adapters/postgres.rb
83
+ - lib/active_record_pgnats/adapters/postgres/connection.rb
84
+ - lib/active_record_pgnats/adapters/postgres/query_executor.rb
85
+ - lib/active_record_pgnats/adapters/postgres/servers.rb
86
+ - lib/active_record_pgnats/adapters/postgres/subscriptions.rb
87
+ - lib/active_record_pgnats/command_recorder.rb
88
+ - lib/active_record_pgnats/configuration.rb
89
+ - lib/active_record_pgnats/publish_trigger.rb
90
+ - lib/active_record_pgnats/schema_dumper.rb
91
+ - lib/active_record_pgnats/server.rb
92
+ - lib/active_record_pgnats/statements.rb
93
+ - lib/active_record_pgnats/subscription.rb
94
+ - lib/active_record_pgnats/version.rb
95
+ homepage: https://github.com/n-at-han-k/active-record-pgnats
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/n-at-han-k/active-record-pgnats
100
+ source_code_uri: https://github.com/n-at-han-k/active-record-pgnats
101
+ changelog_uri: https://github.com/n-at-han-k/active-record-pgnats/blob/main/CHANGELOG-active_record_pgnats.md
102
+ rubygems_mfa_required: 'true'
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 3.1.0
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.7.2
118
+ specification_version: 4
119
+ summary: Active Record migrations for the pgnats extension.
120
+ test_files: []