wal 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec +1 -0
- data/README.md +49 -0
- data/Rakefile +9 -1
- data/exe/wal +64 -0
- data/lib/wal/active_record_context_extension.rb +15 -0
- data/lib/wal/noop_watcher.rb +12 -0
- data/lib/wal/record_watcher.rb +389 -0
- data/lib/wal/replicator.rb +205 -0
- data/lib/wal/streaming_watcher.rb +74 -0
- data/lib/wal/version.rb +2 -1
- data/lib/wal/watcher.rb +95 -0
- data/lib/wal.rb +128 -1
- data/rbi/wal.rbi +295 -0
- data/sig/wal.rbs +184 -2
- data/sorbet/config +7 -0
- data/sorbet/rbi/annotations/.gitattributes +1 -0
- data/sorbet/rbi/annotations/activemodel.rbi +89 -0
- data/sorbet/rbi/annotations/activerecord.rbi +98 -0
- data/sorbet/rbi/annotations/activesupport.rbi +463 -0
- data/sorbet/rbi/annotations/minitest.rbi +119 -0
- data/sorbet/rbi/annotations/rainbow.rbi +269 -0
- data/sorbet/rbi/gems/.gitattributes +1 -0
- data/sorbet/rbi/gems/actioncable@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionmailbox@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionmailer@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionpack@8.0.2.rbi +21122 -0
- data/sorbet/rbi/gems/actiontext@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionview@8.0.2.rbi +16423 -0
- data/sorbet/rbi/gems/activejob@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/activemodel@8.0.2.rbi +6866 -0
- data/sorbet/rbi/gems/activerecord@8.0.2.rbi +43227 -0
- data/sorbet/rbi/gems/activestorage@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/activesupport@8.0.2.rbi +21110 -0
- data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
- data/sorbet/rbi/gems/base64@0.3.0.rbi +545 -0
- data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
- data/sorbet/rbi/gems/bigdecimal@3.2.2.rbi +78 -0
- data/sorbet/rbi/gems/builder@3.3.0.rbi +9 -0
- data/sorbet/rbi/gems/commander@5.0.0.rbi +9 -0
- data/sorbet/rbi/gems/concurrent-ruby@1.3.5.rbi +11657 -0
- data/sorbet/rbi/gems/connection_pool@2.5.3.rbi +9 -0
- data/sorbet/rbi/gems/crass@1.0.6.rbi +623 -0
- data/sorbet/rbi/gems/date@3.4.1.rbi +75 -0
- data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
- data/sorbet/rbi/gems/docker-api@2.4.0.rbi +1719 -0
- data/sorbet/rbi/gems/docopt@0.6.1.rbi +9 -0
- data/sorbet/rbi/gems/drb@2.2.3.rbi +1661 -0
- data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
- data/sorbet/rbi/gems/excon@1.2.7.rbi +1514 -0
- data/sorbet/rbi/gems/globalid@1.2.1.rbi +9 -0
- data/sorbet/rbi/gems/highline@3.0.1.rbi +9 -0
- data/sorbet/rbi/gems/i18n@1.14.7.rbi +2359 -0
- data/sorbet/rbi/gems/io-console@0.8.0.rbi +9 -0
- data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
- data/sorbet/rbi/gems/loofah@2.24.1.rbi +1105 -0
- data/sorbet/rbi/gems/mail@2.8.1.rbi +9 -0
- data/sorbet/rbi/gems/marcel@1.0.4.rbi +9 -0
- data/sorbet/rbi/gems/mini_mime@1.1.5.rbi +9 -0
- data/sorbet/rbi/gems/minitest@5.25.5.rbi +1704 -0
- data/sorbet/rbi/gems/multi_json@1.15.0.rbi +268 -0
- data/sorbet/rbi/gems/net-imap@0.5.9.rbi +9 -0
- data/sorbet/rbi/gems/net-pop@0.1.2.rbi +9 -0
- data/sorbet/rbi/gems/net-protocol@0.2.2.rbi +292 -0
- data/sorbet/rbi/gems/net-smtp@0.5.1.rbi +9 -0
- data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
- data/sorbet/rbi/gems/nio4r@2.7.4.rbi +9 -0
- data/sorbet/rbi/gems/nokogiri@1.18.8.rbi +8206 -0
- data/sorbet/rbi/gems/ostruct@0.6.2.rbi +354 -0
- data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
- data/sorbet/rbi/gems/parlour@9.1.1.rbi +3071 -0
- data/sorbet/rbi/gems/parser@3.3.8.0.rbi +7338 -0
- data/sorbet/rbi/gems/pg-replication-protocol@0.0.7.rbi +633 -0
- data/sorbet/rbi/gems/pg@1.5.9.rbi +2806 -0
- data/sorbet/rbi/gems/pp@0.6.2.rbi +368 -0
- data/sorbet/rbi/gems/prettyprint@0.2.0.rbi +477 -0
- data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
- data/sorbet/rbi/gems/psych@5.2.3.rbi +2435 -0
- data/sorbet/rbi/gems/racc@1.8.1.rbi +160 -0
- data/sorbet/rbi/gems/rack-session@2.1.1.rbi +727 -0
- data/sorbet/rbi/gems/rack-test@2.2.0.rbi +734 -0
- data/sorbet/rbi/gems/rack@3.1.16.rbi +4940 -0
- data/sorbet/rbi/gems/rackup@2.2.1.rbi +230 -0
- data/sorbet/rbi/gems/rails-dom-testing@2.3.0.rbi +858 -0
- data/sorbet/rbi/gems/rails-html-sanitizer@1.6.2.rbi +785 -0
- data/sorbet/rbi/gems/rails@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/railties@8.0.2.rbi +3865 -0
- data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
- data/sorbet/rbi/gems/rake@13.2.1.rbi +3120 -0
- data/sorbet/rbi/gems/rbi@0.3.6.rbi +6893 -0
- data/sorbet/rbi/gems/rbs@3.9.4.rbi +6978 -0
- data/sorbet/rbi/gems/rdoc@6.12.0.rbi +12760 -0
- data/sorbet/rbi/gems/reline@0.6.0.rbi +2451 -0
- data/sorbet/rbi/gems/rexml@3.4.1.rbi +5240 -0
- data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11348 -0
- data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
- data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
- data/sorbet/rbi/gems/rspec-sorbet@1.9.2.rbi +164 -0
- data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
- data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
- data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
- data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
- data/sorbet/rbi/gems/stringio@3.1.5.rbi +9 -0
- data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
- data/sorbet/rbi/gems/testcontainers-core@0.2.0.rbi +1005 -0
- data/sorbet/rbi/gems/testcontainers-postgres@0.2.0.rbi +145 -0
- data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
- data/sorbet/rbi/gems/timeout@0.4.3.rbi +157 -0
- data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +5918 -0
- data/sorbet/rbi/gems/uri@1.0.3.rbi +2349 -0
- data/sorbet/rbi/gems/useragent@0.16.11.rbi +9 -0
- data/sorbet/rbi/gems/websocket-driver@0.8.0.rbi +9 -0
- data/sorbet/rbi/gems/websocket-extensions@0.1.5.rbi +9 -0
- data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
- data/sorbet/rbi/gems/yard@0.9.37.rbi +18379 -0
- data/sorbet/rbi/gems/zeitwerk@2.7.3.rbi +9 -0
- data/sorbet/tapioca/config.yml +5 -0
- data/sorbet/tapioca/require.rb +12 -0
- metadata +231 -6
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 4bb4ac6fb7d48a2eb780f26f4f454369c83d16428833f57dfcba55b22b125376
         | 
| 4 | 
            +
              data.tar.gz: 96c9ef5e37b344478db683c66209ca84c68ded6016878180dce5cc92d43274c9
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: a0ada4fd59799ef8beb0d4c57d9e87a2df1073b407023931b511a6a3458db29a471af0745884b5ff7e338da4dd3bf3c8af95d319eab7538d744a8f399c8ad5f1
         | 
| 7 | 
            +
              data.tar.gz: 858fa64c003c1c5b75630c30ab7a05d3376dd2a347b182aeb90f9af2d87f975833f202a9dccebffbed8d78d3db6b24ee3d524b0a2115fb52a0c39b0dff016e8a
         | 
    
        data/.rspec
    ADDED
    
    | @@ -0,0 +1 @@ | |
| 1 | 
            +
            --require spec_helper
         | 
    
        data/README.md
    CHANGED
    
    | @@ -0,0 +1,49 @@ | |
| 1 | 
            +
            # Wal
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            Easily hook into Postgres WAL event log from your Rails app.
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            Proper documentation TBD
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            ## Examples
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            ### Watch for model changes using the RecordWatcher DSL
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            ```ruby
         | 
| 12 | 
            +
            class ProductAvailabilityWatcher < Wal::RecordWatcher
         | 
| 13 | 
            +
              on_save Product, changed: %w[price] do |event|
         | 
| 14 | 
            +
                recalculate_inventory_price(event.primary_key, event.new["price"])
         | 
| 15 | 
            +
              end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              on_destroy Product do |event|
         | 
| 18 | 
            +
                clear_product_inventory(event.primary_key)
         | 
| 19 | 
            +
              end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              on_save Sales, changed: %w[status] do |event|
         | 
| 22 | 
            +
                recalculate_inventory_quantity(event.primary_key)
         | 
| 23 | 
            +
              end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
              def recalculate_inventory_price(product_id, new_price)
         | 
| 26 | 
            +
                # ...
         | 
| 27 | 
            +
              end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
              def clear_product_inventory(product_id)
         | 
| 30 | 
            +
                # ...
         | 
| 31 | 
            +
              end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
              def recalculate_inventory_quantity(sales_id)
         | 
| 34 | 
            +
                # ...
         | 
| 35 | 
            +
              end
         | 
| 36 | 
            +
            end
         | 
| 37 | 
            +
            ```
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            ### Basic watcher implementation
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            ```ruby
         | 
| 42 | 
            +
            class LogWatcher
         | 
| 43 | 
            +
              include Wal::Watcher
         | 
| 44 | 
            +
             | 
| 45 | 
            +
              def on_event(event)
         | 
| 46 | 
            +
                puts "Wal event received #{event}"
         | 
| 47 | 
            +
              end
         | 
| 48 | 
            +
            end
         | 
| 49 | 
            +
            ```
         | 
    
        data/Rakefile
    CHANGED
    
    | @@ -1,4 +1,12 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 3 | 
             
            require "bundler/gem_tasks"
         | 
| 4 | 
            -
             | 
| 4 | 
            +
             | 
| 5 | 
            +
            task(:test) { sh "bundle exec rspec" }
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            task default: %i[build]
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            task("sig/wal.rbi") { sh "bundle exec parlour" }
         | 
| 10 | 
            +
            task("rbi/wal.rbs") { sh "rbs prototype rbi rbi/wal.rbi > sig/wal.rbs" }
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            Rake::Task["build"].enhance(["sig/wal.rbi", "rbi/wal.rbs"])
         | 
    
        data/exe/wal
    ADDED
    
    | @@ -0,0 +1,64 @@ | |
| 1 | 
            +
            #!/usr/bin/env ruby
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "docopt"
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            begin
         | 
| 6 | 
            +
              cli = Docopt.docopt(<<~DOCOPT)
         | 
| 7 | 
            +
                Usage:
         | 
| 8 | 
            +
                  wal watch --watcher <watcher-class> (--slot <replication-slot> | --tmp-slot) [--publication=<publication>...] [--with-timings]
         | 
| 9 | 
            +
                  wal start <config-file>
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                Options:
         | 
| 12 | 
            +
                  -h --help                         Show this screen.
         | 
| 13 | 
            +
                  --watcher=<watcher-class>         The watcher class to be used to listen for WAL changes.
         | 
| 14 | 
            +
                  --slot=<replication-slot>         The replication slot that will be used.
         | 
| 15 | 
            +
                  --tmp-slot                        Use a temporary replication slot.
         | 
| 16 | 
            +
                  [--publication=<publication>...]  Force using the informed Postgres publications.
         | 
| 17 | 
            +
                  [--with-timings]                  Add timing logs to the output.
         | 
| 18 | 
            +
              DOCOPT
         | 
| 19 | 
            +
            rescue Docopt::Exit => err
         | 
| 20 | 
            +
              puts err.message
         | 
| 21 | 
            +
              exit
         | 
| 22 | 
            +
            end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            require "./config/environment"
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            if cli["watch"]
         | 
| 27 | 
            +
              watcher = cli["--watcher"].constantize.new
         | 
| 28 | 
            +
              watcher = Wal::MonitoringWatcher.new(watcher) if cli["--with-timings"]
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              use_temporary_slot = cli["--tmp-slot"] || false
         | 
| 31 | 
            +
              replication_slot = cli["--slot"]
         | 
| 32 | 
            +
              replication_slot = replication_slot.presence || "wal_watcher_#{SecureRandom.alphanumeric(4)}" if use_temporary_slot
         | 
| 33 | 
            +
             | 
| 34 | 
            +
              Wal::Replicator
         | 
| 35 | 
            +
                .new(replication_slot:, use_temporary_slot:)
         | 
| 36 | 
            +
                .replicate_forever(watcher, publications: cli["--publication"])
         | 
| 37 | 
            +
             | 
| 38 | 
            +
            elsif cli["start"]
         | 
| 39 | 
            +
              workers = YAML.load_file(cli["<config-file>"])["slots"].map do |slot, config|
         | 
| 40 | 
            +
                watcher = config["watcher"].constantize.new
         | 
| 41 | 
            +
                watcher = Wal::MonitoringWatcher.new(watcher) if config["log_execution_time"]
         | 
| 42 | 
            +
                temporary = config["temporary"] || false
         | 
| 43 | 
            +
                publications = config["publications"] || []
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                Thread.new(slot, watcher, temporary, publications) do |replication_slot, watcher, use_temporary_slot, publications|
         | 
| 46 | 
            +
                  replication_slot = "#{replication_slot}_#{SecureRandom.alphanumeric(4)}" if use_temporary_slot
         | 
| 47 | 
            +
                  puts "Watcher started for #{replication_slot} slot (#{publications.join(", ")})"
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                  Wal::Replicator
         | 
| 50 | 
            +
                    .new(replication_slot:, use_temporary_slot:)
         | 
| 51 | 
            +
                    .replicate_forever(watcher, publications:)
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                  puts "Watcher finished for #{replication_slot}"
         | 
| 54 | 
            +
                end
         | 
| 55 | 
            +
              end
         | 
| 56 | 
            +
             | 
| 57 | 
            +
              Signal.trap("INT") do
         | 
| 58 | 
            +
                puts "Stopping WAL workers..."
         | 
| 59 | 
            +
                workers.each(&:kill)
         | 
| 60 | 
            +
                puts "WAL workers stopped"
         | 
| 61 | 
            +
              end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
              workers.each(&:join)
         | 
| 64 | 
            +
            end
         | 
| @@ -0,0 +1,15 @@ | |
| 1 | 
            +
            require "active_record/connection_adapters/postgresql_adapter"
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Wal
         | 
| 4 | 
            +
              module ActiveRecordContextExtension
         | 
| 5 | 
            +
                def set_wal_watcher_context(context, prefix: "")
         | 
| 6 | 
            +
                  execute "SELECT pg_logical_emit_message(true, #{quote(prefix)}, #{quote(context.to_json)})" if transaction_open?
         | 
| 7 | 
            +
                end
         | 
| 8 | 
            +
              end
         | 
| 9 | 
            +
            end
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            if defined? ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
         | 
| 12 | 
            +
              class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
         | 
| 13 | 
            +
                include Wal::ActiveRecordContextExtension
         | 
| 14 | 
            +
              end
         | 
| 15 | 
            +
            end
         | 
| @@ -0,0 +1,12 @@ | |
| 1 | 
            +
            # typed: strict
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Wal
         | 
| 4 | 
            +
              # A watcher that does nothing. Just for performance testing in general. Useful in testing aswell.
         | 
| 5 | 
            +
              class NoopWatcher
         | 
| 6 | 
            +
                extend T::Sig
         | 
| 7 | 
            +
                include Wal::Watcher
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                sig { override.params(event: Event).void }
         | 
| 10 | 
            +
                def on_event(event); end
         | 
| 11 | 
            +
              end
         | 
| 12 | 
            +
            end
         | 
| @@ -0,0 +1,389 @@ | |
| 1 | 
            +
            # typed: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Wal
         | 
| 4 | 
            +
              # Watcher that process records at the end of a transaction, keeping only its final state.
         | 
| 5 | 
            +
              #
         | 
| 6 | 
            +
              # Example:
         | 
| 7 | 
            +
              #
         | 
| 8 | 
            +
              # ```ruby
         | 
| 9 | 
            +
              # class InventoryAvailabilityWatcher < Wal::RecordWatcher
         | 
| 10 | 
            +
              #   on_save Item, changed: %w[weight_unid_id] do |event|
         | 
| 11 | 
            +
              #     recalculate_inventory_availability(event.primary_key)
         | 
| 12 | 
            +
              #   end
         | 
| 13 | 
            +
              #
         | 
| 14 | 
            +
              #   on_save SalesOrder, changed: %w[status] do |event|
         | 
| 15 | 
            +
              #     next unless event.attributes_changes(:status).one? "filled"
         | 
| 16 | 
            +
              #
         | 
| 17 | 
            +
              #     OrderItem
         | 
| 18 | 
            +
              #       .where(sales_order_id: event.primary_key)
         | 
| 19 | 
            +
              #       .pluck(:item_id)
         | 
| 20 | 
            +
              #       .each { |item_id| recalculate_inventory_availability(item_id) }
         | 
| 21 | 
            +
              #   end
         | 
| 22 | 
            +
              #
         | 
| 23 | 
            +
              #   on_save OrderItem, changed: %w[item_id weight_unit weight_unid_id] do |event|
         | 
| 24 | 
            +
              #     if (old_item_id, new_item_id = event.attributes_changes("item_id"))
         | 
| 25 | 
            +
              #       recalculate_inventory_availability(old_item_id)
         | 
| 26 | 
            +
              #       recalculate_inventory_availability(new_item_id)
         | 
| 27 | 
            +
              #     else
         | 
| 28 | 
            +
              #       recalculate_inventory_availability(event.attribute(:item_id))
         | 
| 29 | 
            +
              #     end
         | 
| 30 | 
            +
              #   end
         | 
| 31 | 
            +
              #
         | 
| 32 | 
            +
              #   on_destroy OrderItem do |event|
         | 
| 33 | 
            +
              #     recalculate_inventory_availability(event.attribute(:item_id))
         | 
| 34 | 
            +
              #   end
         | 
| 35 | 
            +
              #
         | 
| 36 | 
            +
              #   def recalculate_inventory_availability(item_id)
         | 
| 37 | 
            +
              #     ...
         | 
| 38 | 
            +
              #   end
         | 
| 39 | 
            +
              # end
         | 
| 40 | 
            +
              # ```
         | 
| 41 | 
            +
              class RecordWatcher
         | 
| 42 | 
            +
                extend T::Sig
         | 
| 43 | 
            +
                extend T::Helpers
         | 
| 44 | 
            +
                include Wal::Watcher
         | 
| 45 | 
            +
                abstract!
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                RecordEvent = T.type_alias { T.any(InsertEvent, UpdateEvent, DeleteEvent) }
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                def self.inherited(subclass)
         | 
| 50 | 
            +
                  super
         | 
| 51 | 
            +
                  @@change_callbacks = Hash.new { |hash, key| hash[key] = [] }
         | 
| 52 | 
            +
                  @@delete_callbacks = Hash.new { |hash, key| hash[key] = [] }
         | 
| 53 | 
            +
                end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                sig do
         | 
| 56 | 
            +
                  params(
         | 
| 57 | 
            +
                    table: T.any(String, T.class_of(::ActiveRecord::Base)),
         | 
| 58 | 
            +
                    block: T.proc.bind(T.attached_class).params(event: InsertEvent).void,
         | 
| 59 | 
            +
                  ).void
         | 
| 60 | 
            +
                end
         | 
| 61 | 
            +
                def self.on_insert(table, &block)
         | 
| 62 | 
            +
                  table = table.is_a?(String) ? table : table.table_name
         | 
| 63 | 
            +
                  @@change_callbacks[table].push(only: [:create], block: block)
         | 
| 64 | 
            +
                end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                sig do
         | 
| 67 | 
            +
                  params(
         | 
| 68 | 
            +
                    table: T.any(String, T.class_of(::ActiveRecord::Base)),
         | 
| 69 | 
            +
                    changed: T.nilable(T::Array[T.any(String, Symbol)]),
         | 
| 70 | 
            +
                    block: T.proc.bind(T.attached_class).params(event: UpdateEvent).void,
         | 
| 71 | 
            +
                  ).void
         | 
| 72 | 
            +
                end
         | 
| 73 | 
            +
                def self.on_update(table, changed: nil, &block)
         | 
| 74 | 
            +
                  table = table.is_a?(String) ? table : table.table_name
         | 
| 75 | 
            +
                  @@change_callbacks[table].push(only: [:update], changed: changed&.map(&:to_s), block: block)
         | 
| 76 | 
            +
                end
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                sig do
         | 
| 79 | 
            +
                  params(
         | 
| 80 | 
            +
                    table: T.any(String, T.class_of(::ActiveRecord::Base)),
         | 
| 81 | 
            +
                    changed: T.nilable(T::Array[T.any(String, Symbol)]),
         | 
| 82 | 
            +
                    block: T.proc.bind(T.attached_class).params(event: T.any(InsertEvent, UpdateEvent)).void,
         | 
| 83 | 
            +
                  ).void
         | 
| 84 | 
            +
                end
         | 
| 85 | 
            +
                def self.on_save(table, changed: nil, &block)
         | 
| 86 | 
            +
                  table = table.is_a?(String) ? table : table.table_name
         | 
| 87 | 
            +
                  @@change_callbacks[table].push(only: [:create, :update], changed: changed&.map(&:to_s), block: block)
         | 
| 88 | 
            +
                end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                sig do
         | 
| 91 | 
            +
                  params(
         | 
| 92 | 
            +
                    table: T.any(String, T.class_of(::ActiveRecord::Base)),
         | 
| 93 | 
            +
                    block: T.proc.bind(T.attached_class).params(event: DeleteEvent).void,
         | 
| 94 | 
            +
                  ).void
         | 
| 95 | 
            +
                end
         | 
| 96 | 
            +
                def self.on_destroy(table, &block)
         | 
| 97 | 
            +
                  table = table.is_a?(String) ? table : table.table_name
         | 
| 98 | 
            +
                  @@delete_callbacks[table].push(block: block)
         | 
| 99 | 
            +
                end
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                sig { params(event: RecordEvent).void }
         | 
| 102 | 
            +
                def on_record_changed(event)
         | 
| 103 | 
            +
                  case event
         | 
| 104 | 
            +
                  when InsertEvent
         | 
| 105 | 
            +
                    @@change_callbacks[event.table]
         | 
| 106 | 
            +
                      .filter { |callback| callback[:only].include? :create }
         | 
| 107 | 
            +
                      .each { |callback| instance_exec(event, &callback[:block]) }
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                  when UpdateEvent
         | 
| 110 | 
            +
                    @@change_callbacks[event.table]
         | 
| 111 | 
            +
                      .filter { |callback| callback[:only].include? :update }
         | 
| 112 | 
            +
                      .each do |callback|
         | 
| 113 | 
            +
                        if (attributes = callback[:changed])
         | 
| 114 | 
            +
                          instance_exec(event, &callback[:block]) unless (event.diff.keys & attributes).empty?
         | 
| 115 | 
            +
                        else
         | 
| 116 | 
            +
                          instance_exec(event, &callback[:block])
         | 
| 117 | 
            +
                        end
         | 
| 118 | 
            +
                      end
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                  when DeleteEvent
         | 
| 121 | 
            +
                    @@delete_callbacks[event.table].each do |callback|
         | 
| 122 | 
            +
                      instance_exec(event, &callback[:block])
         | 
| 123 | 
            +
                    end
         | 
| 124 | 
            +
                  end
         | 
| 125 | 
            +
                end
         | 
| 126 | 
            +
             | 
| 127 | 
            +
                sig { params(table: String).returns(T::Boolean) }
         | 
| 128 | 
            +
                def should_watch_table?(table)
         | 
| 129 | 
            +
                  (@@change_callbacks.keys | @@delete_callbacks.keys).include? table
         | 
| 130 | 
            +
                end
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                # `RecordWatcher` supports two processing strategies:
         | 
| 133 | 
            +
                #
         | 
| 134 | 
            +
                # `:memory`: Stores and aggregates records from a single transaction in memory. This has better performance but uses
         | 
| 135 | 
            +
                #  more memory, as at least one event for each record must be stored in memory until the end of a transaction
         | 
| 136 | 
            +
                #
         | 
| 137 | 
            +
                # `:temporary_table`: Offloads the record aggregation to a temporary table on the database. This is useful when you
         | 
| 138 | 
            +
                # are processing very large transactions that can't fit in memory. The tradeoff is obviously a worse performance.
         | 
| 139 | 
            +
                #
         | 
| 140 | 
            +
                # These strategies can be defined per transaction, and by default it will uses the memory one, and only fallback
         | 
| 141 | 
            +
                # to the temporary table if the transaction size is roughly 2 gigabytes or more.
         | 
| 142 | 
            +
                sig { params(event: BeginTransactionEvent).returns(Symbol) }
         | 
| 143 | 
            +
                def aggregation_strategy(event)
         | 
| 144 | 
            +
                  if event.estimated_size > 1024.pow(3) * 2
         | 
| 145 | 
            +
                    :temporary_table
         | 
| 146 | 
            +
                  else
         | 
| 147 | 
            +
                    :memory
         | 
| 148 | 
            +
                  end
         | 
| 149 | 
            +
                end
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                sig { override.params(event: Event).void }
         | 
| 152 | 
            +
                def on_event(event)
         | 
| 153 | 
            +
                  if event.is_a? BeginTransactionEvent
         | 
| 154 | 
            +
                    @current_record_watcher = case (strategy = aggregation_strategy(event))
         | 
| 155 | 
            +
                    when :memory
         | 
| 156 | 
            +
                      MemoryRecordWatcher.new(self)
         | 
| 157 | 
            +
                    when :temporary_table
         | 
| 158 | 
            +
                      TemporaryTableRecordWatcher.new(self)
         | 
| 159 | 
            +
                    else
         | 
| 160 | 
            +
                      raise "Invalid aggregation strategy: #{strategy}"
         | 
| 161 | 
            +
                    end
         | 
| 162 | 
            +
                  end
         | 
| 163 | 
            +
                  @current_record_watcher.on_event(event)
         | 
| 164 | 
            +
                end
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                class MemoryRecordWatcher
         | 
| 167 | 
            +
                  extend T::Sig
         | 
| 168 | 
            +
                  extend T::Helpers
         | 
| 169 | 
            +
                  include Wal::Watcher
         | 
| 170 | 
            +
                  include Wal::Watcher::SeparatedEvents
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                  # Records indexed by table and primary key
         | 
| 173 | 
            +
                  RecordsStorage = T.type_alias { T::Hash[[String, Integer], T.nilable(RecordEvent)] }
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                  def initialize(watcher)
         | 
| 176 | 
            +
                    @watcher = watcher
         | 
| 177 | 
            +
                  end
         | 
| 178 | 
            +
             | 
| 179 | 
            +
                  sig { params(event: BeginTransactionEvent).void }
         | 
| 180 | 
            +
                  def on_begin(event)
         | 
| 181 | 
            +
                    @records = T.let({}, T.nilable(RecordsStorage))
         | 
| 182 | 
            +
                  end
         | 
| 183 | 
            +
             | 
| 184 | 
            +
                  def on_commit(_event)
         | 
| 185 | 
            +
                    @records
         | 
| 186 | 
            +
                      &.values
         | 
| 187 | 
            +
                      &.lazy
         | 
| 188 | 
            +
                      &.each { |event| @watcher.on_record_changed(event) if event }
         | 
| 189 | 
            +
                  end
         | 
| 190 | 
            +
             | 
| 191 | 
            +
                  sig { params(event: InsertEvent).void }
         | 
| 192 | 
            +
                  def on_insert(event)
         | 
| 193 | 
            +
                    if (id = event.primary_key)
         | 
| 194 | 
            +
                      @records ||= {}
         | 
| 195 | 
            +
                      @records[[event.table, id]] = event
         | 
| 196 | 
            +
                    end
         | 
| 197 | 
            +
                  end
         | 
| 198 | 
            +
             | 
| 199 | 
            +
                  sig { params(event: UpdateEvent).void }
         | 
| 200 | 
            +
                  def on_update(event)
         | 
| 201 | 
            +
                    if (id = event.primary_key)
         | 
| 202 | 
            +
                      @records ||= {}
         | 
| 203 | 
            +
                      @records[[event.table, id]] = case (existing_event = @records[[event.table, id]])
         | 
| 204 | 
            +
                      when InsertEvent
         | 
| 205 | 
            +
                        # A record inserted on this transaction is being updated, which means it should still reflect as a insert
         | 
| 206 | 
            +
                        # event, we just change the information to reflect the most current data that was just updated.
         | 
| 207 | 
            +
                        existing_event.with(new: event.new)
         | 
| 208 | 
            +
             | 
| 209 | 
            +
                      when UpdateEvent
         | 
| 210 | 
            +
                        # We are updating again a event that was already updated on this transaction.
         | 
| 211 | 
            +
                        # Same as the insert, we keep the old data from the previous update and the new data from the new one.
         | 
| 212 | 
            +
                        existing_event.with(new: event.new)
         | 
| 213 | 
            +
             | 
| 214 | 
            +
                      else
         | 
| 215 | 
            +
                        event
         | 
| 216 | 
            +
                      end
         | 
| 217 | 
            +
                    end
         | 
| 218 | 
            +
                  end
         | 
| 219 | 
            +
             | 
| 220 | 
            +
                  sig { params(event: DeleteEvent).void }
         | 
| 221 | 
            +
                  def on_delete(event)
         | 
| 222 | 
            +
                    if (id = event.primary_key)
         | 
| 223 | 
            +
                      @records ||= {}
         | 
| 224 | 
            +
                      @records[[event.table, id]] = case (existing_event = @records[[event.table, id]])
         | 
| 225 | 
            +
                      when InsertEvent
         | 
| 226 | 
            +
                        # We are removing a record that was inserted on this transaction, we should not even report this change, as
         | 
| 227 | 
            +
                        # this record never existed outside this transaction anyways.
         | 
| 228 | 
            +
                        nil
         | 
| 229 | 
            +
             | 
| 230 | 
            +
                      when UpdateEvent
         | 
| 231 | 
            +
                        # Deleting a record that was previously updated by this transaction. Just store the previous data while
         | 
| 232 | 
            +
                        # keeping the record as deleted.
         | 
| 233 | 
            +
                        event.with(old: existing_event.old)
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                      else
         | 
| 236 | 
            +
                        event
         | 
| 237 | 
            +
                      end
         | 
| 238 | 
            +
                    end
         | 
| 239 | 
            +
                  end
         | 
| 240 | 
            +
                end
         | 
| 241 | 
            +
             | 
| 242 | 
            +
                class TemporaryTableRecordWatcher
         | 
| 243 | 
            +
                  extend T::Sig
         | 
| 244 | 
            +
                  extend T::Helpers
         | 
| 245 | 
            +
                  include Wal::Watcher
         | 
| 246 | 
            +
                  include Wal::Watcher::SeparatedEvents
         | 
| 247 | 
            +
             | 
| 248 | 
            +
                  # ActiveRecord base class used to persist the temporary table. Defaults to `ActiveRecord::Base`, but can be
         | 
| 249 | 
            +
                  # changed if you want, for example, to use a different database for Wal processing.
         | 
| 250 | 
            +
                  # Note that the class specified here must be a `abstract_class`.
         | 
| 251 | 
            +
                  mattr_accessor :base_active_record_class
         | 
| 252 | 
            +
             | 
| 253 | 
            +
                  def initialize(watcher, batch_size: 5_000)
         | 
| 254 | 
            +
                    @watcher = watcher
         | 
| 255 | 
            +
                    @batch_size = 5_000
         | 
| 256 | 
            +
                  end
         | 
| 257 | 
            +
             | 
| 258 | 
            +
                  sig { params(event: BeginTransactionEvent).void }
         | 
| 259 | 
            +
                  def on_begin(event)
         | 
| 260 | 
            +
                    @table = begin
         | 
| 261 | 
            +
                      table_name = "temp_record_watcher_#{SecureRandom.alphanumeric(10).downcase}"
         | 
| 262 | 
            +
             | 
| 263 | 
            +
                      base_class.connection.create_table(table_name, temporary: true) do |t|
         | 
| 264 | 
            +
                        t.bigint :transaction_id, null: false
         | 
| 265 | 
            +
                        t.bigint :lsn, null: false
         | 
| 266 | 
            +
                        t.column :action, :string, null: false
         | 
| 267 | 
            +
                        t.string :table_name, null: false
         | 
| 268 | 
            +
                        t.bigint :primary_key
         | 
| 269 | 
            +
                        t.jsonb :old, null: false, default: {}
         | 
| 270 | 
            +
                        t.jsonb :new, null: false, default: {}
         | 
| 271 | 
            +
                        t.jsonb :context, null: false, default: {}
         | 
| 272 | 
            +
                      end
         | 
| 273 | 
            +
             | 
| 274 | 
            +
                      unique_index = %i[table_name primary_key]
         | 
| 275 | 
            +
             | 
| 276 | 
            +
                      base_class.connection.add_index table_name, unique_index, unique: true
         | 
| 277 | 
            +
             | 
| 278 | 
            +
                      Class.new(base_class) do
         | 
| 279 | 
            +
                        # Using cast here since Sorbet bugs when we don't pass a explicit class to `Class.new`
         | 
| 280 | 
            +
                        T.cast(self, T.class_of(::ActiveRecord::Base)).table_name = table_name
         | 
| 281 | 
            +
             | 
| 282 | 
            +
                        # All this sh$#1t was necessary because AR schema cache doesn't work with temporary tables...
         | 
| 283 | 
            +
                        insert_all_class = Class.new(::ActiveRecord::InsertAll) do
         | 
| 284 | 
            +
                          unique_index_definition = ::ActiveRecord::ConnectionAdapters::IndexDefinition.new(
         | 
| 285 | 
            +
                            table_name, "unique_index", true, unique_index
         | 
| 286 | 
            +
                          )
         | 
| 287 | 
            +
                          define_method(:find_unique_index_for) { |_| unique_index_definition }
         | 
| 288 | 
            +
                        end
         | 
| 289 | 
            +
             | 
| 290 | 
            +
                        define_singleton_method(:upsert) do |attributes, update_only: nil|
         | 
| 291 | 
            +
                          insert_all_class
         | 
| 292 | 
            +
                            .new(
         | 
| 293 | 
            +
                              T.cast(self, T.class_of(::ActiveRecord::Base)).none,
         | 
| 294 | 
            +
                              T.cast(self, T.class_of(::ActiveRecord::Base)).connection,
         | 
| 295 | 
            +
                              [attributes],
         | 
| 296 | 
            +
                              on_duplicate: :update,
         | 
| 297 | 
            +
                              unique_by: unique_index,
         | 
| 298 | 
            +
                              update_only:,
         | 
| 299 | 
            +
                              returning: nil,
         | 
| 300 | 
            +
                              record_timestamps: nil,
         | 
| 301 | 
            +
                            )
         | 
| 302 | 
            +
                            .execute
         | 
| 303 | 
            +
                        end
         | 
| 304 | 
            +
                      end
         | 
| 305 | 
            +
                    end
         | 
| 306 | 
            +
                  end
         | 
| 307 | 
            +
             | 
| 308 | 
            +
                  def on_commit(_event)
         | 
| 309 | 
            +
                    @table
         | 
| 310 | 
            +
                      .in_batches(of: @batch_size)
         | 
| 311 | 
            +
                      .each_record
         | 
| 312 | 
            +
                      .lazy
         | 
| 313 | 
            +
                      .filter_map { |persisted_event| deserialize(persisted_event) }
         | 
| 314 | 
            +
                      .each { |event| @watcher.on_record_changed(event) }
         | 
| 315 | 
            +
             | 
| 316 | 
            +
                    base_class.connection.drop_table @table.table_name
         | 
| 317 | 
            +
                  end
         | 
| 318 | 
            +
             | 
| 319 | 
            +
                  sig { params(event: InsertEvent).void }
         | 
| 320 | 
            +
                  def on_insert(event)
         | 
| 321 | 
            +
                    @table.upsert(serialize(event))
         | 
| 322 | 
            +
                  end
         | 
| 323 | 
            +
             | 
| 324 | 
            +
                  sig { params(event: UpdateEvent).void }
         | 
| 325 | 
            +
                  def on_update(event)
         | 
| 326 | 
            +
                    @table.upsert(serialize(event), update_only: %w[new])
         | 
| 327 | 
            +
                  end
         | 
| 328 | 
            +
             | 
| 329 | 
            +
                  sig { params(event: DeleteEvent).void }
         | 
| 330 | 
            +
                  def on_delete(event)
         | 
| 331 | 
            +
                    case @table.where(table_name: event.table, primary_key: event.primary_key).pluck(:action, :old).first
         | 
| 332 | 
            +
                    in ["insert", _]
         | 
| 333 | 
            +
                      @table.where(table_name: event.table, primary_key: event.primary_key).delete_all
         | 
| 334 | 
            +
                    in ["update", old]
         | 
| 335 | 
            +
                      @table.upsert(serialize(event).merge(old:))
         | 
| 336 | 
            +
                    in ["delete", _]
         | 
| 337 | 
            +
                      # We don't need to store another delete
         | 
| 338 | 
            +
                    else
         | 
| 339 | 
            +
                      @table.upsert(serialize(event))
         | 
| 340 | 
            +
                    end
         | 
| 341 | 
            +
                  end
         | 
| 342 | 
            +
             | 
| 343 | 
            +
                  private
         | 
| 344 | 
            +
             | 
| 345 | 
            +
                  sig { returns(T.class_of(::ActiveRecord::Base)) }
         | 
| 346 | 
            +
                  def base_class
         | 
| 347 | 
            +
                    self.class.base_active_record_class || ::ActiveRecord::Base
         | 
| 348 | 
            +
                  end
         | 
| 349 | 
            +
             | 
| 350 | 
            +
                  def serialize(event)
         | 
| 351 | 
            +
                    serialized = {
         | 
| 352 | 
            +
                      transaction_id: event.transaction_id,
         | 
| 353 | 
            +
                      lsn: event.lsn,
         | 
| 354 | 
            +
                      table_name: event.table,
         | 
| 355 | 
            +
                      primary_key: event.primary_key,
         | 
| 356 | 
            +
                      context: event.context,
         | 
| 357 | 
            +
                    }
         | 
| 358 | 
            +
                    case event
         | 
| 359 | 
            +
                    when InsertEvent
         | 
| 360 | 
            +
                      serialized.merge(action: "insert", new: event.new)
         | 
| 361 | 
            +
                    when UpdateEvent
         | 
| 362 | 
            +
                      serialized.merge(action: "update", old: event.old, new: event.new)
         | 
| 363 | 
            +
                    when DeleteEvent
         | 
| 364 | 
            +
                      serialized.merge(action: "delete", old: event.old)
         | 
| 365 | 
            +
                    else
         | 
| 366 | 
            +
                      serialized
         | 
| 367 | 
            +
                    end
         | 
| 368 | 
            +
                  end
         | 
| 369 | 
            +
             | 
| 370 | 
            +
                  def deserialize(persisted_event)
         | 
| 371 | 
            +
                    deserialized = {
         | 
| 372 | 
            +
                      transaction_id: persisted_event.transaction_id,
         | 
| 373 | 
            +
                      lsn: persisted_event.lsn,
         | 
| 374 | 
            +
                      table: persisted_event.table_name,
         | 
| 375 | 
            +
                      primary_key: persisted_event.primary_key,
         | 
| 376 | 
            +
                      context: persisted_event.context,
         | 
| 377 | 
            +
                    }
         | 
| 378 | 
            +
                    case persisted_event.action
         | 
| 379 | 
            +
                    when "insert"
         | 
| 380 | 
            +
                      InsertEvent.new(**deserialized, new: persisted_event.new)
         | 
| 381 | 
            +
                    when "update"
         | 
| 382 | 
            +
                      UpdateEvent.new(**deserialized, old: persisted_event.old, new: persisted_event.new)
         | 
| 383 | 
            +
                    when "delete"
         | 
| 384 | 
            +
                      DeleteEvent.new(**deserialized, old: persisted_event.old)
         | 
| 385 | 
            +
                    end
         | 
| 386 | 
            +
                  end
         | 
| 387 | 
            +
                end
         | 
| 388 | 
            +
              end
         | 
| 389 | 
            +
            end
         |