nats_pubsub 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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. metadata +225 -0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/structured_logger'
4
+
5
+ module NatsPubsub
6
+ module Middleware
7
+ # Structured logging middleware for message processing
8
+ # Logs with consistent JSON format and correlation IDs
9
+ class StructuredLogging
10
+ def initialize(logger: nil)
11
+ @logger = logger || Core::LoggerFactory.create_from_config(NatsPubsub.config)
12
+ end
13
+
14
+ def call(subscriber, payload, metadata)
15
+ start_time = Time.now
16
+ context = build_log_context(subscriber, payload, metadata)
17
+
18
+ logger.with_context(context).info('Processing message started')
19
+
20
+ begin
21
+ yield
22
+
23
+ elapsed_ms = ((Time.now - start_time) * 1000).round(2)
24
+ logger.with_context(context).info('Processing message completed', {
25
+ elapsed_ms: elapsed_ms,
26
+ status: 'success'
27
+ })
28
+ rescue StandardError => e
29
+ elapsed_ms = ((Time.now - start_time) * 1000).round(2)
30
+ logger.with_context(context).error('Processing message failed', {
31
+ elapsed_ms: elapsed_ms,
32
+ status: 'error',
33
+ error_class: e.class.name,
34
+ error_message: e.message,
35
+ backtrace: e.backtrace&.first(5)
36
+ })
37
+ raise
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :logger
44
+
45
+ def build_log_context(subscriber, payload, metadata)
46
+ {
47
+ subscriber: subscriber.class.name,
48
+ event_id: metadata[:event_id],
49
+ trace_id: metadata[:trace_id],
50
+ subject: metadata[:subject],
51
+ topic: metadata[:topic],
52
+ delivery_count: metadata[:deliveries] || 1
53
+ }.compact
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+
5
+ module NatsPubsub
6
+ # Shared behavior for ActiveRecord-based event models (Inbox/Outbox)
7
+ # Provides safe column checking, common validations, and payload handling
8
+ module EventModel
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ base.include(InstanceMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Safe column presence check that never boots a connection during class load.
16
+ # rubocop:disable Naming/PredicateNames
17
+ def has_column?(name)
18
+ return false unless ar_connected?
19
+
20
+ connection.schema_cache.columns_hash(table_name).key?(name.to_s)
21
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
22
+ false
23
+ end
24
+ # rubocop:enable Naming/PredicateNames
25
+
26
+ def ar_connected?
27
+ ActiveRecord::Base.connected? && connection_pool.active_connection?
28
+ rescue StandardError
29
+ false
30
+ end
31
+ end
32
+
33
+ module InstanceMethods
34
+ # Parse and return payload as a Hash
35
+ # Handles String (JSON), Hash, and objects with as_json
36
+ def payload_hash
37
+ v = self[:payload]
38
+ case v
39
+ when String
40
+ begin
41
+ Oj.load(v, mode: :strict)
42
+ rescue Oj::Error
43
+ {}
44
+ end
45
+ when Hash then v
46
+ else
47
+ v.respond_to?(:as_json) ? v.as_json : {}
48
+ end
49
+ end
50
+ end
51
+
52
+ # Create a shim class when ActiveRecord is not available
53
+ # @param class_name [String] Name of the class (e.g., 'InboxEvent')
54
+ # @param feature_name [String] Feature name for error message (e.g., 'Inbox')
55
+ def self.create_shim(class_name, feature_name)
56
+ Class.new do
57
+ class << self
58
+ define_method(:method_missing) do |method_name, *_args, &_block|
59
+ raise(
60
+ "#{feature_name} requires ActiveRecord (tried to call ##{method_name}). " \
61
+ "Enable `use_#{feature_name.downcase}` only in apps with ActiveRecord, or add " \
62
+ '`gem "activerecord"` to your Gemfile.'
63
+ )
64
+ end
65
+
66
+ def respond_to_missing?(_method_name, _include_private = false)
67
+ false
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'event_model'
4
+
5
+ begin
6
+ require 'active_record'
7
+ rescue LoadError
8
+ # No-op; shim defined below.
9
+ end
10
+
11
+ module NatsPubsub
12
+ if defined?(ActiveRecord::Base)
13
+ class InboxEvent < ActiveRecord::Base
14
+ include EventModel
15
+
16
+ self.table_name = 'nats_pubsub_inbox'
17
+
18
+ # ---- Scopes for common queries ----
19
+ scope :received, -> { where(status: 'received') if has_column?(:status) }
20
+ scope :processing, -> { where(status: 'processing') if has_column?(:status) }
21
+ scope :processed, -> { where(status: 'processed') if has_column?(:status) }
22
+ scope :failed, -> { where(status: 'failed') if has_column?(:status) }
23
+ scope :unprocessed, -> { where(status: %w[received failed]) if has_column?(:status) }
24
+
25
+ scope :ready_to_process, lambda {
26
+ return none unless has_column?(:status)
27
+
28
+ where(status: 'received')
29
+ .order(:received_at)
30
+ }
31
+
32
+ scope :with_errors, lambda {
33
+ return none unless has_column?(:last_error)
34
+
35
+ where.not(last_error: nil)
36
+ }
37
+
38
+ scope :by_delivery_count, lambda { |count|
39
+ return none unless has_column?(:deliveries)
40
+
41
+ where(deliveries: count)
42
+ }
43
+
44
+ scope :for_cleanup, lambda { |retention_period = 30.days.ago|
45
+ return none unless has_column?(:status) && has_column?(:processed_at)
46
+
47
+ where(status: 'processed')
48
+ .where('processed_at < ?', retention_period)
49
+ }
50
+
51
+ scope :by_subject, ->(pattern) { where('subject LIKE ?', pattern) if has_column?(:subject) }
52
+ scope :recent, -> { order(received_at: :desc) if has_column?(:received_at) }
53
+ scope :oldest_first, -> { order(received_at: :asc) if has_column?(:received_at) }
54
+
55
+ # ---- Validations (NO with_options; guard everything with procs) ----
56
+
57
+ # Preferred dedupe key
58
+ validates :event_id,
59
+ presence: true,
60
+ uniqueness: true,
61
+ if: -> { self.class.has_column?(:event_id) }
62
+
63
+ # Fallback to (stream, stream_seq) when event_id column not present
64
+ validates :stream_seq,
65
+ presence: true,
66
+ if: -> { !self.class.has_column?(:event_id) && self.class.has_column?(:stream_seq) }
67
+
68
+ validates :stream_seq,
69
+ uniqueness: { scope: :stream },
70
+ if: lambda {
71
+ !self.class.has_column?(:event_id) &&
72
+ self.class.has_column?(:stream_seq) &&
73
+ self.class.has_column?(:stream)
74
+ }
75
+
76
+ validates :stream_seq,
77
+ uniqueness: true,
78
+ if: lambda {
79
+ !self.class.has_column?(:event_id) &&
80
+ self.class.has_column?(:stream_seq) &&
81
+ !self.class.has_column?(:stream)
82
+ }
83
+
84
+ validates :subject,
85
+ presence: true,
86
+ if: -> { self.class.has_column?(:subject) }
87
+
88
+ # ---- Defaults that do not require schema at load time ----
89
+ before_validation do
90
+ self.status ||= 'received' if self.class.has_column?(:status) && status.blank?
91
+ self.received_at ||= Time.now.utc if self.class.has_column?(:received_at) && received_at.blank?
92
+ end
93
+
94
+ # ---- Helpers ----
95
+ def processed?
96
+ if self.class.has_column?(:processed_at)
97
+ processed_at.present?
98
+ elsif self.class.has_column?(:status)
99
+ status == 'processed'
100
+ else
101
+ false
102
+ end
103
+ end
104
+ end
105
+ else
106
+ # Shim: loud failure if AR isn't present but someone calls the model.
107
+ InboxEvent = EventModel.create_shim('InboxEvent', 'Inbox')
108
+ end
109
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+
5
+ module NatsPubsub
6
+ module ModelCodecSetup
7
+ module_function
8
+
9
+ def apply!
10
+ return unless ar_connected?
11
+
12
+ [NatsPubsub::OutboxEvent, NatsPubsub::InboxEvent].each { |k| apply_to(k) }
13
+ end
14
+
15
+ def apply_to(klass)
16
+ return unless table_exists_safely?(klass)
17
+
18
+ %w[payload headers].each do |attr|
19
+ next unless column?(klass, attr)
20
+ next if json_column?(klass, attr) || already_serialized?(klass, attr)
21
+
22
+ klass.serialize attr.to_sym, coder: Oj
23
+ end
24
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
25
+ # ignore when schema isn’t available yet
26
+ end
27
+
28
+ # --- helpers ---
29
+
30
+ def ar_connected?
31
+ ActiveRecord::Base.connected?
32
+ rescue StandardError
33
+ false
34
+ end
35
+
36
+ def table_exists_safely?(klass)
37
+ klass.table_exists?
38
+ rescue StandardError
39
+ false
40
+ end
41
+
42
+ def column?(klass, attr)
43
+ klass.columns_hash.key?(attr)
44
+ rescue StandardError
45
+ false
46
+ end
47
+
48
+ def json_column?(klass, attr)
49
+ sql = klass.columns_hash.fetch(attr).sql_type.to_s.downcase
50
+ sql.include?('json') # covers json & jsonb
51
+ rescue StandardError
52
+ false
53
+ end
54
+
55
+ def already_serialized?(klass, attr)
56
+ klass.attribute_types.fetch(attr, nil).is_a?(ActiveRecord::Type::Serialized)
57
+ rescue StandardError
58
+ false
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+
5
+ module NatsPubsub
6
+ module ModelUtils
7
+ module_function
8
+
9
+ def constantize(name)
10
+ name.to_s.split('::').reduce(Object) { |m, c| m.const_get(c) }
11
+ end
12
+
13
+ def ar_class?(klass)
14
+ defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
15
+ end
16
+
17
+ # rubocop:disable Naming/PredicatePrefix
18
+ def has_columns?(klass, *cols)
19
+ return false unless ar_class?(klass)
20
+
21
+ cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
22
+ end
23
+ # rubocop:enable Naming/PredicatePrefix
24
+
25
+ def assign_known_attrs(record, attrs)
26
+ attrs.each do |k, v|
27
+ setter = :"#{k}="
28
+ record.public_send(setter, v) if record.respond_to?(setter)
29
+ end
30
+ end
31
+
32
+ # find_or_initialize_by on the first keyset whose columns exist; else new
33
+ def find_or_init_by_best(klass, *keysets)
34
+ keysets.each do |keys|
35
+ next if keys.nil? || keys.empty?
36
+ return klass.find_or_initialize_by(keys) if has_columns?(klass, keys.keys)
37
+ end
38
+ klass.new
39
+ end
40
+
41
+ def json_dump(obj)
42
+ return obj if obj.is_a?(String)
43
+
44
+ Oj.dump(obj, mode: :compat)
45
+ rescue Oj::Error, TypeError
46
+ obj.to_s
47
+ end
48
+
49
+ def json_load(str)
50
+ return str if str.is_a?(Hash)
51
+
52
+ Oj.load(str.to_s, mode: :strict)
53
+ rescue Oj::Error
54
+ {}
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'event_model'
4
+
5
+ begin
6
+ require 'active_record'
7
+ rescue LoadError
8
+ # No-op; shim defined below.
9
+ end
10
+
11
+ module NatsPubsub
12
+ if defined?(ActiveRecord::Base)
13
+ class OutboxEvent < ActiveRecord::Base
14
+ include EventModel
15
+
16
+ self.table_name = 'nats_pubsub_outbox'
17
+
18
+ # ---- Scopes for common queries ----
19
+ scope :pending, -> { where(status: 'pending') if has_column?(:status) }
20
+ scope :publishing, -> { where(status: 'publishing') if has_column?(:status) }
21
+ scope :sent, -> { where(status: 'sent') if has_column?(:status) }
22
+ scope :failed, -> { where(status: 'failed') if has_column?(:status) }
23
+ scope :unsent, -> { where(status: %w[pending failed]) if has_column?(:status) }
24
+
25
+ scope :ready_to_publish, lambda {
26
+ return none unless has_column?(:status) && has_column?(:enqueued_at)
27
+
28
+ where(status: %w[pending failed])
29
+ .where('enqueued_at <= ?', Time.current)
30
+ .order(:enqueued_at)
31
+ }
32
+
33
+ scope :stale_publishing, lambda { |threshold = 5.minutes.ago|
34
+ return none unless has_column?(:status) && has_column?(:updated_at)
35
+
36
+ where(status: 'publishing')
37
+ .where('updated_at < ?', threshold)
38
+ }
39
+
40
+ scope :for_cleanup, lambda { |retention_period = 7.days.ago|
41
+ return none unless has_column?(:status) && has_column?(:sent_at)
42
+
43
+ where(status: 'sent')
44
+ .where('sent_at < ?', retention_period)
45
+ }
46
+
47
+ scope :by_subject, ->(pattern) { where('subject LIKE ?', pattern) if has_column?(:subject) }
48
+ scope :recent, -> { order(created_at: :desc) }
49
+ scope :oldest_first, -> { order(created_at: :asc) }
50
+
51
+ # ---- Validations guarded by safe schema checks (no with_options) ----
52
+ validates :payload,
53
+ presence: true,
54
+ if: -> { self.class.has_column?(:payload) }
55
+
56
+ # Preferred path when event_id exists
57
+ validates :event_id,
58
+ presence: true,
59
+ uniqueness: true,
60
+ if: -> { self.class.has_column?(:event_id) }
61
+
62
+ # Fallback legacy fields when event_id is absent
63
+ validates :resource_type,
64
+ presence: true,
65
+ if: lambda {
66
+ !self.class.has_column?(:event_id) && self.class.has_column?(:resource_type)
67
+ }
68
+
69
+ validates :resource_id,
70
+ presence: true,
71
+ if: lambda {
72
+ !self.class.has_column?(:event_id) && self.class.has_column?(:resource_id)
73
+ }
74
+
75
+ validates :event_type,
76
+ presence: true,
77
+ if: -> { !self.class.has_column?(:event_id) && self.class.has_column?(:event_type) }
78
+
79
+ validates :subject,
80
+ presence: true,
81
+ if: -> { self.class.has_column?(:subject) }
82
+
83
+ validates :attempts,
84
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 },
85
+ if: -> { self.class.has_column?(:attempts) }
86
+
87
+ # ---- Defaults that do not require schema at load time ----
88
+ before_validation do
89
+ now = Time.now.utc
90
+ self.status ||= 'pending' if self.class.has_column?(:status) && status.blank?
91
+ self.enqueued_at ||= now if self.class.has_column?(:enqueued_at) && enqueued_at.blank?
92
+ self.attempts = 0 if self.class.has_column?(:attempts) && attempts.nil?
93
+ end
94
+
95
+ # ---- Helpers ----
96
+ def mark_sent!
97
+ now = Time.now.utc
98
+ self.status = 'sent' if self.class.has_column?(:status)
99
+ self.sent_at = now if self.class.has_column?(:sent_at)
100
+ save!
101
+ end
102
+
103
+ def mark_failed!(err_msg)
104
+ self.status = 'failed' if self.class.has_column?(:status)
105
+ self.last_error = err_msg if self.class.has_column?(:last_error)
106
+ save!
107
+ end
108
+ end
109
+ else
110
+ # Shim: loud failure if AR isn't present but someone calls the model.
111
+ OutboxEvent = EventModel.create_shim('OutboxEvent', 'Outbox')
112
+ end
113
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative '../core/config'
5
+ require_relative '../core/subject'
6
+
7
+ module NatsPubsub
8
+ # Service object responsible for building message envelopes.
9
+ # Extracted from Publisher to follow Single Responsibility Principle.
10
+ #
11
+ # Supports three envelope formats:
12
+ # 1. Event envelope (domain/resource/action pattern)
13
+ # 2. Topic envelope (topic-based messaging)
14
+ # 3. Legacy envelope (backward compatibility)
15
+ class EnvelopeBuilder
16
+ # Build envelope for domain/resource/action events
17
+ # More specific name makes it clear this builds an event envelope
18
+ #
19
+ # @param domain [String] Domain name (e.g., 'users', 'orders')
20
+ # @param resource [String] Resource type (e.g., 'user', 'order')
21
+ # @param action [String] Action performed (e.g., 'created', 'updated')
22
+ # @param payload [Hash] Event payload
23
+ # @param options [Hash] Additional options
24
+ # @return [Hash] Event envelope
25
+ def self.build_event_envelope(domain, resource, action, payload, options = {})
26
+ {
27
+ 'event_id' => options[:event_id] || SecureRandom.uuid,
28
+ 'schema_version' => 1,
29
+ 'domain' => domain.to_s,
30
+ 'resource' => resource.to_s,
31
+ 'action' => action.to_s,
32
+ 'producer' => NatsPubsub.config.app_name,
33
+ 'resource_id' => extract_resource_id(payload),
34
+ 'occurred_at' => format_timestamp(options[:occurred_at]),
35
+ 'trace_id' => options[:trace_id] || SecureRandom.hex(8),
36
+ 'payload' => payload
37
+ }
38
+ end
39
+
40
+ # Build envelope for topic-based messages
41
+ # More specific name makes it clear this builds a topic envelope
42
+ #
43
+ # @param topic [String] Topic name
44
+ # @param message [Hash] Message payload
45
+ # @param options [Hash] Additional options
46
+ # @return [Hash] Topic envelope
47
+ def self.build_topic_envelope(topic, message, options = {})
48
+ envelope = {
49
+ 'event_id' => options[:event_id] || SecureRandom.uuid,
50
+ 'schema_version' => 1,
51
+ 'topic' => topic.to_s,
52
+ 'message_type' => options[:message_type]&.to_s,
53
+ 'producer' => NatsPubsub.config.app_name,
54
+ 'occurred_at' => format_timestamp(options[:occurred_at]),
55
+ 'trace_id' => options[:trace_id] || SecureRandom.hex(8),
56
+ 'message' => message
57
+ }
58
+
59
+ # Add domain/resource/action fields if provided (for backward compatibility)
60
+ envelope['domain'] = options[:domain].to_s if options[:domain]
61
+ envelope['resource'] = options[:resource].to_s if options[:resource]
62
+ envelope['action'] = options[:action].to_s if options[:action]
63
+ envelope['resource_id'] = options[:resource_id].to_s if options[:resource_id]
64
+
65
+ envelope.compact
66
+ end
67
+
68
+ # Build NATS subject for topic
69
+ # Delegates to Subject class for centralized subject building logic
70
+ #
71
+ # @param topic [String] Topic name
72
+ # @return [String] NATS subject
73
+ def self.build_subject(topic)
74
+ Subject.from_topic(
75
+ env: NatsPubsub.config.env,
76
+ app_name: NatsPubsub.config.app_name,
77
+ topic: topic
78
+ ).to_s
79
+ end
80
+
81
+ # Extract resource ID from payload
82
+ #
83
+ # @param payload [Hash] Event payload
84
+ # @return [String] Resource ID
85
+ def self.extract_resource_id(payload)
86
+ (payload['id'] || payload[:id]).to_s
87
+ end
88
+ private_class_method :extract_resource_id
89
+
90
+ # Format timestamp to ISO8601
91
+ #
92
+ # @param timestamp [Time, nil] Timestamp
93
+ # @return [String] ISO8601 formatted timestamp
94
+ def self.format_timestamp(timestamp)
95
+ (timestamp || Time.now.utc).iso8601
96
+ end
97
+ private_class_method :format_timestamp
98
+ end
99
+ end