jetstream_bridge 1.5.0 → 1.7.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.idea/dictionaries/project.xml +2 -0
  3. data/.idea/jetstream_bridge.iml +6 -1
  4. data/.rubocop.yml +102 -0
  5. data/Gemfile.lock +1 -5
  6. data/README.md +163 -78
  7. data/jetstream_bridge.gemspec +9 -10
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
  12. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
  13. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
  14. data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
  15. data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
  16. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
  17. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
  18. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
  19. data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
  20. data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
  21. data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
  22. data/lib/jetstream_bridge/core/model_utils.rb +51 -0
  23. data/lib/jetstream_bridge/models/inbox_event.rb +98 -0
  24. data/lib/jetstream_bridge/models/outbox_event.rb +114 -0
  25. data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
  26. data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +41 -4
  27. data/lib/jetstream_bridge/railtie.rb +12 -0
  28. data/lib/jetstream_bridge/tasks/install.rake +10 -0
  29. data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
  30. data/lib/jetstream_bridge/topology/stream.rb +129 -0
  31. data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
  32. data/lib/jetstream_bridge/version.rb +1 -1
  33. data/lib/jetstream_bridge.rb +35 -23
  34. metadata +49 -49
  35. data/lib/jetstream_bridge/consumer.rb +0 -136
  36. data/lib/jetstream_bridge/dlq.rb +0 -24
  37. data/lib/jetstream_bridge/inbox_event.rb +0 -46
  38. data/lib/jetstream_bridge/outbox_event.rb +0 -60
  39. data/lib/jetstream_bridge/stream.rb +0 -114
  40. /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
  41. /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
  42. /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
  43. /data/lib/jetstream_bridge/{subject_matcher.rb → topology/subject_matcher.rb} +0 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module JetstreamBridge
7
+ module Generators
8
+ # Migrations generator.
9
+ class MigrationsGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+ source_root File.expand_path('templates', __dir__)
12
+ desc 'Creates Inbox/Outbox migrations for JetstreamBridge'
13
+
14
+ def create_outbox_migration
15
+ name = 'create_jetstream_outbox_events'
16
+ return say_status :skip, "migration #{name} already exists", :yellow if migration_exists?('db/migrate', name)
17
+
18
+ migration_template 'create_jetstream_outbox_events.rb.erb', "db/migrate/#{name}.rb"
19
+ end
20
+
21
+ def create_inbox_migration
22
+ name = 'create_jetstream_inbox_events'
23
+ return say_status :skip, "migration #{name} already exists", :yellow if migration_exists?('db/migrate', name)
24
+
25
+ migration_template 'create_jetstream_inbox_events.rb.erb', "db/migrate/#{name}.rb"
26
+ end
27
+
28
+ # -- Rails::Generators::Migration plumbing --
29
+ def self.next_migration_number(dirname)
30
+ if ActiveRecord::Base.timestamped_migrations
31
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
32
+ else
33
+ format('%.3d', current_migration_number(dirname) + 1)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def migration_exists?(dirname, file_name)
40
+ Dir.glob(File.join(dirname, '[0-9]*_*.rb')).grep(/\d+_#{file_name}\.rb$/).any?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateJetstreamInboxEvents < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :jetstream_inbox_events do |t|
6
+ t.string :event_id # preferred dedupe key
7
+ t.string :subject, null: false
8
+ t.jsonb :payload, null: false, default: {}
9
+ t.jsonb :headers, null: false, default: {}
10
+ t.string :stream
11
+ t.bigint :stream_seq
12
+ t.integer :deliveries
13
+ t.string :status, null: false, default: 'received' # received|processing|processed|failed
14
+ t.text :last_error
15
+ t.datetime :received_at
16
+ t.datetime :processed_at
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :jetstream_inbox_events, :event_id, unique: true, where: 'event_id IS NOT NULL'
21
+ add_index :jetstream_inbox_events, [:stream, :stream_seq], unique: true, where: 'stream IS NOT NULL AND stream_seq IS NOT NULL'
22
+ add_index :jetstream_inbox_events, :status
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateJetstreamOutboxEvents < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :jetstream_outbox_events do |t|
6
+ t.string :event_id, null: false
7
+ t.string :subject, null: false
8
+ t.jsonb :payload, null: false, default: {}
9
+ t.jsonb :headers, null: false, default: {}
10
+ t.string :status, null: false, default: 'pending' # pending|publishing|sent|failed
11
+ t.integer :attempts, null: false, default: 0
12
+ t.text :last_error
13
+ t.datetime :enqueued_at
14
+ t.datetime :sent_at
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :jetstream_outbox_events, :event_id, unique: true
19
+ add_index :jetstream_outbox_events, :status
20
+ end
21
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require_relative '../core/connection'
6
+ require_relative '../core/duration'
7
+ require_relative '../core/logging'
8
+ require_relative '../core/config'
9
+ require_relative '../core/model_utils'
10
+ require_relative 'consumer_config'
11
+ require_relative 'message_processor'
12
+ require_relative 'subscription_manager'
13
+ require_relative 'inbox/inbox_processor'
14
+
15
+ module JetstreamBridge
16
+ # Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
17
+ class Consumer
18
+ DEFAULT_BATCH_SIZE = 25
19
+ FETCH_TIMEOUT_SECS = 5
20
+ IDLE_SLEEP_SECS = 0.05
21
+
22
+ def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
23
+ @handler = block
24
+ @batch_size = batch_size
25
+ @durable = durable_name
26
+ @jts = Connection.connect!
27
+
28
+ ensure_destination!
29
+
30
+ @sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
31
+ @sub_mgr.ensure_consumer!
32
+ @psub = @sub_mgr.subscribe!
33
+
34
+ @processor = MessageProcessor.new(@jts, @handler)
35
+ @inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
36
+ end
37
+
38
+ def run!
39
+ Logging.info("Consumer #{@durable} started…", tag: 'JetstreamBridge::Consumer')
40
+ loop do
41
+ processed = process_batch
42
+ sleep(IDLE_SLEEP_SECS) if processed.zero?
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def ensure_destination!
49
+ return unless JetstreamBridge.config.destination_app.to_s.empty?
50
+
51
+ raise ArgumentError, 'destination_app must be configured'
52
+ end
53
+
54
+ # Returns number of messages processed; 0 on timeout/idle or after recovery.
55
+ def process_batch
56
+ msgs = fetch_messages
57
+ process_messages(msgs)
58
+ rescue NATS::Timeout, NATS::IO::Timeout
59
+ 0
60
+ rescue NATS::JetStream::Error => e
61
+ handle_js_error(e)
62
+ end
63
+
64
+ # --- helpers ---
65
+
66
+ def fetch_messages
67
+ @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
68
+ end
69
+
70
+ def process_messages(msgs)
71
+ msgs.sum { |m| process_one(m) }
72
+ end
73
+
74
+ def process_one(m)
75
+ if @inbox_proc
76
+ @inbox_proc.process(m) ? 1 : 0
77
+ else
78
+ @processor.handle_message(m)
79
+ 1
80
+ end
81
+ end
82
+
83
+ def handle_js_error(e)
84
+ if recoverable_consumer_error?(e)
85
+ Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
86
+ tag: 'JetstreamBridge::Consumer')
87
+ @sub_mgr.ensure_consumer!
88
+ @psub = @sub_mgr.subscribe!
89
+ else
90
+ Logging.error("Fetch failed: #{e.class} #{e.message}",
91
+ tag: 'JetstreamBridge::Consumer')
92
+ end
93
+ 0
94
+ end
95
+
96
+ def recoverable_consumer_error?(error)
97
+ msg = error.message.to_s
98
+ msg =~ /consumer.*(not\s+found|deleted)/i ||
99
+ msg =~ /no\s+responders/i ||
100
+ msg =~ /stream.*not\s+found/i
101
+ end
102
+ end
103
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'duration'
4
- require_relative 'logging'
5
- require_relative 'config'
3
+ require_relative '../core/duration'
4
+ require_relative '../core/config'
5
+ require_relative '../core/logging'
6
6
 
7
7
  module JetstreamBridge
8
8
  # Consumer configuration helpers.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module JetstreamBridge
6
+ # Immutable value object for a single NATS message.
7
+ class InboxMessage
8
+ attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
9
+
10
+ def self.from_nats(m)
11
+ meta = (m.respond_to?(:metadata) && m.metadata) || nil
12
+ seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
13
+ deliveries = meta.respond_to?(:num_delivered) ? meta.num_delivered : nil
14
+ stream = meta.respond_to?(:stream) ? meta.stream : nil
15
+ subject = m.subject.to_s
16
+
17
+ headers = {}
18
+ (m.header || {}).each { |k, v| headers[k.to_s.downcase] = v }
19
+
20
+ raw = m.data
21
+ body = begin
22
+ JSON.parse(raw)
23
+ rescue StandardError
24
+ {}
25
+ end
26
+
27
+ id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
28
+ id = "seq:#{seq}" if id.empty?
29
+
30
+ new(m, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc)
31
+ end
32
+
33
+ def initialize(m, seq, deliveries, stream, subject, headers, body, raw, event_id, now)
34
+ @msg = m
35
+ @seq = seq
36
+ @deliveries = deliveries
37
+ @stream = stream
38
+ @subject = subject
39
+ @headers = headers
40
+ @body = body
41
+ @raw = raw
42
+ @event_id = event_id
43
+ @now = now
44
+ end
45
+
46
+ def body_for_store
47
+ body.empty? ? raw : body
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../..//core/logging'
4
+ require_relative '../../core/model_utils'
5
+ require_relative 'inbox_message'
6
+ require_relative 'inbox_repository'
7
+
8
+ module JetstreamBridge
9
+ # Orchestrates AR-backed inbox processing.
10
+ class InboxProcessor
11
+ def initialize(message_processor)
12
+ @processor = message_processor
13
+ end
14
+
15
+ # @return [true,false] processed?
16
+ def process(m)
17
+ klass = ModelUtils.constantize(JetstreamBridge.config.inbox_model)
18
+ return process_direct(m, klass) unless ModelUtils.ar_class?(klass)
19
+
20
+ msg = InboxMessage.from_nats(m)
21
+ repo = InboxRepository.new(klass)
22
+ record = repo.find_or_build(msg)
23
+
24
+ if repo.already_processed?(record)
25
+ m.ack
26
+ return true
27
+ end
28
+
29
+ repo.persist_pre(record, msg)
30
+ @processor.handle_message(m)
31
+ repo.persist_post(record)
32
+ true
33
+ rescue => e
34
+ repo.persist_failure(record, e) if defined?(repo) && defined?(record)
35
+ Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
36
+ tag: 'JetstreamBridge::Consumer')
37
+ false
38
+ end
39
+
40
+ private
41
+
42
+ def process_direct(m, klass)
43
+ unless ModelUtils.ar_class?(klass)
44
+ Logging.warn("Inbox model #{klass} is not an ActiveRecord model; processing directly.",
45
+ tag: 'JetstreamBridge::Consumer')
46
+ end
47
+ @processor.handle_message(m)
48
+ true
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../core/model_utils'
4
+ require_relative '../../core/logging'
5
+
6
+ module JetstreamBridge
7
+ # AR-facing operations for inbox rows (find/build/persist).
8
+ class InboxRepository
9
+ def initialize(klass)
10
+ @klass = klass
11
+ end
12
+
13
+ def find_or_build(msg)
14
+ if ModelUtils.has_columns?(@klass, :event_id)
15
+ @klass.find_or_initialize_by(event_id: msg.event_id)
16
+ elsif ModelUtils.has_columns?(@klass, :stream_seq)
17
+ @klass.find_or_initialize_by(stream_seq: msg.seq)
18
+ else
19
+ @klass.new
20
+ end
21
+ end
22
+
23
+ def already_processed?(record)
24
+ record.respond_to?(:processed_at) && record.processed_at
25
+ end
26
+
27
+ def persist_pre(record, msg)
28
+ ModelUtils.assign_known_attrs(record, {
29
+ event_id: (if ModelUtils.has_columns?(@klass,
30
+ :event_id)
31
+ msg.event_id
32
+ end),
33
+ subject: msg.subject,
34
+ payload: ModelUtils.json_dump(msg.body_for_store),
35
+ headers: ModelUtils.json_dump(msg.headers),
36
+ stream: (if ModelUtils.has_columns?(@klass,
37
+ :stream)
38
+ msg.stream
39
+ end),
40
+ stream_seq: (if ModelUtils.has_columns?(@klass,
41
+ :stream_seq)
42
+ msg.seq
43
+ end),
44
+ deliveries: (if ModelUtils.has_columns?(@klass,
45
+ :deliveries)
46
+ msg.deliveries
47
+ end),
48
+ status: 'processing',
49
+ last_error: nil,
50
+ received_at: (if ModelUtils.has_columns?(@klass,
51
+ :received_at)
52
+ record.received_at || msg.now
53
+ end),
54
+ updated_at: (if ModelUtils.has_columns?(@klass,
55
+ :updated_at)
56
+ msg.now
57
+ end)
58
+ })
59
+ record.save!
60
+ end
61
+
62
+ def persist_post(record)
63
+ now = Time.now.utc
64
+ ModelUtils.assign_known_attrs(record, {
65
+ status: 'processed',
66
+ processed_at: (if ModelUtils.has_columns?(@klass,
67
+ :processed_at)
68
+ now
69
+ end),
70
+ updated_at: (if ModelUtils.has_columns?(@klass,
71
+ :updated_at)
72
+ now
73
+ end)
74
+ })
75
+ record.save!
76
+ end
77
+
78
+ def persist_failure(record, error)
79
+ return unless record
80
+
81
+ now = Time.now.utc
82
+ ModelUtils.assign_known_attrs(record, {
83
+ status: (if ModelUtils.has_columns?(@klass,
84
+ :status)
85
+ 'failed'
86
+ end),
87
+ last_error: (if ModelUtils.has_columns?(@klass,
88
+ :last_error)
89
+ "#{error.class}: #{error.message}"
90
+ end),
91
+ updated_at: (if ModelUtils.has_columns?(@klass,
92
+ :updated_at)
93
+ now
94
+ end)
95
+ })
96
+ record.save!
97
+ rescue StandardError => e
98
+ Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
99
+ tag: 'JetstreamBridge::Consumer')
100
+ end
101
+ end
102
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
5
- require_relative 'logging'
5
+ require_relative '../core/logging'
6
6
 
7
7
  module JetstreamBridge
8
8
  # Handles parse → handler → ack / nak → DLQ
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+ require_relative '../consumer/consumer_config'
5
+
6
+ module JetstreamBridge
7
+ # Encapsulates durable ensure + subscribe for a consumer.
8
+ class SubscriptionManager
9
+ def initialize(jts, durable, cfg = JetstreamBridge.config)
10
+ @jts = jts
11
+ @durable = durable
12
+ @cfg = cfg
13
+ end
14
+
15
+ def stream_name
16
+ @cfg.stream_name
17
+ end
18
+
19
+ def filter_subject
20
+ @cfg.destination_subject
21
+ end
22
+
23
+ def desired_consumer_cfg
24
+ ConsumerConfig.consumer_config(@durable, filter_subject)
25
+ end
26
+
27
+ def ensure_consumer!
28
+ info = consumer_info_or_nil
29
+ return create_consumer! unless info
30
+ return log_consumer_ok if consumer_matches?(info)
31
+
32
+ recreate_consumer!
33
+ end
34
+
35
+ private
36
+
37
+ def consumer_info_or_nil
38
+ @jts.consumer_info(stream_name, @durable)
39
+ rescue NATS::JetStream::Error
40
+ nil
41
+ end
42
+
43
+ def consumer_matches?(info)
44
+ cfg = info.config
45
+ have = (cfg.respond_to?(:filter_subject) ? cfg.filter_subject : cfg[:filter_subject]).to_s
46
+ want = desired_consumer_cfg[:filter_subject].to_s
47
+ have == want
48
+ end
49
+
50
+ def recreate_consumer!
51
+ Logging.warn(
52
+ "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
53
+ tag: 'JetstreamBridge::Consumer'
54
+ )
55
+ safe_delete_consumer
56
+ create_consumer!
57
+ end
58
+
59
+ def create_consumer!
60
+ @jts.add_consumer(stream_name, **desired_consumer_cfg)
61
+ Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
62
+ tag: 'JetstreamBridge::Consumer')
63
+ end
64
+
65
+ def log_consumer_ok
66
+ Logging.info("Consumer #{@durable} exists with desired config.",
67
+ tag: 'JetstreamBridge::Consumer')
68
+ end
69
+
70
+ def safe_delete_consumer
71
+ @jts.delete_consumer(stream_name, @durable)
72
+ rescue NATS::JetStream::Error => e
73
+ Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
74
+ tag: 'JetstreamBridge::Consumer')
75
+ end
76
+ def subscribe!
77
+ @jts.pull_subscribe(
78
+ filter_subject,
79
+ @durable,
80
+ stream: stream_name,
81
+ config: ConsumerConfig.subscribe_config
82
+ )
83
+ end
84
+
85
+ def consumer_mismatch?(info, desired_cfg)
86
+ cfg = info.config
87
+ (cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
88
+ desired_cfg[:filter_subject].to_s
89
+ end
90
+ end
91
+ end
@@ -5,8 +5,8 @@ require 'singleton'
5
5
  require 'json'
6
6
  require_relative 'duration'
7
7
  require_relative 'logging'
8
- require_relative 'topology'
9
8
  require_relative 'config'
9
+ require_relative '../topology/topology'
10
10
 
11
11
  module JetstreamBridge
12
12
  # Singleton connection to NATS.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module ModelUtils
5
+ module_function
6
+
7
+ def constantize(name)
8
+ name.to_s.split('::').reduce(Object) { |m, c| m.const_get(c) }
9
+ end
10
+
11
+ def ar_class?(klass)
12
+ defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
13
+ end
14
+
15
+ def has_columns?(klass, *cols)
16
+ return false unless ar_class?(klass)
17
+ cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
18
+ end
19
+
20
+ def assign_known_attrs(record, attrs)
21
+ attrs.each do |k, v|
22
+ setter = :"#{k}="
23
+ record.public_send(setter, v) if record.respond_to?(setter)
24
+ end
25
+ end
26
+
27
+ # find_or_initialize_by on the first keyset whose columns exist; else new
28
+ def find_or_init_by_best(klass, *keysets)
29
+ keysets.each do |keys|
30
+ next if keys.nil? || keys.empty?
31
+ if has_columns?(klass, keys.keys)
32
+ return klass.find_or_initialize_by(keys)
33
+ end
34
+ end
35
+ klass.new
36
+ end
37
+
38
+ def json_dump(obj)
39
+ obj.is_a?(String) ? obj : JSON.generate(obj)
40
+ rescue
41
+ obj.to_s
42
+ end
43
+
44
+ def json_load(str)
45
+ return str if str.is_a?(Hash)
46
+ JSON.parse(str.to_s)
47
+ rescue
48
+ {}
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'active_record'
5
+ rescue LoadError
6
+ # No-op; shim below if AR missing.
7
+ end
8
+
9
+ module JetstreamBridge
10
+ # Default Inbox model when `use_inbox` is enabled.
11
+ # Prefers event_id, but can fall back to (stream, stream_seq).
12
+ if defined?(ActiveRecord::Base)
13
+ class InboxEvent < ActiveRecord::Base
14
+ self.table_name = 'jetstream_inbox_events'
15
+
16
+ class << self
17
+ def column?(name) = column_names.include?(name.to_s)
18
+
19
+ def attribute_json?(name)
20
+ return false unless respond_to?(:attribute_types) && attribute_types.key?(name.to_s)
21
+ attribute_types[name.to_s].to_s.downcase.include?('json')
22
+ end
23
+ end
24
+
25
+ if column?(:payload)
26
+ serialize :payload, coder: JSON unless attribute_json?(:payload)
27
+ end
28
+ if column?(:headers)
29
+ serialize :headers, coder: JSON unless attribute_json?(:headers)
30
+ end
31
+
32
+ if column?(:event_id)
33
+ validates :event_id, presence: true, uniqueness: true
34
+ elsif column?(:stream_seq)
35
+ validates :stream_seq, presence: true
36
+ validates :stream, presence: true, if: -> { self.class.column?(:stream) }
37
+ if column?(:stream)
38
+ validates :stream_seq, uniqueness: { scope: :stream }
39
+ else
40
+ validates :stream_seq, uniqueness: true
41
+ end
42
+ end
43
+
44
+ validates :subject, presence: true, if: -> { self.class.column?(:subject) }
45
+
46
+ if self.class.column?(:status)
47
+ STATUSES = %w[received processing processed failed].freeze
48
+ validates :status, inclusion: { in: STATUSES }
49
+ end
50
+
51
+ scope :processed, -> { where(status: 'processed') }, if: -> { column?(:status) }
52
+
53
+ before_validation do
54
+ self.status ||= 'received' if self.class.column?(:status) && status.blank?
55
+ self.received_at ||= Time.now.utc if self.class.column?(:received_at) && received_at.blank?
56
+ end
57
+
58
+ def processed?
59
+ if self.class.column?(:processed_at)
60
+ processed_at.present?
61
+ elsif self.class.column?(:status)
62
+ status == 'processed'
63
+ else
64
+ false
65
+ end
66
+ end
67
+
68
+ def payload_hash
69
+ v = self[:payload]
70
+ case v
71
+ when String then JSON.parse(v) rescue {}
72
+ when Hash then v
73
+ else v.respond_to?(:as_json) ? v.as_json : {}
74
+ end
75
+ end
76
+ end
77
+ else
78
+ class InboxEvent
79
+ class << self
80
+ def method_missing(method_name, *_args, &_block)
81
+ raise_missing_ar!('Inbox', method_name)
82
+ end
83
+
84
+ def respond_to_missing?(_name, _priv = false) = false
85
+
86
+ private
87
+
88
+ def raise_missing_ar!(which, method_name)
89
+ raise(
90
+ "#{which} requires ActiveRecord (tried to call ##{method_name}). " \
91
+ 'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
92
+ '`gem "activerecord"` to your Gemfile.'
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end