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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'publisher/envelope_builder'
4
+ require_relative 'publisher/publish_result'
5
+
6
+ module NatsPubsub
7
+ # Testing utilities for NatsPubsub
8
+ # Provides fake and inline modes for testing event publishing
9
+ module Testing
10
+ class << self
11
+ attr_accessor :mode
12
+
13
+ # Enable fake mode (records published events but doesn't process them)
14
+ def fake!
15
+ self.mode = :fake
16
+ published_events.clear
17
+ end
18
+
19
+ # Enable inline mode (executes subscribers immediately)
20
+ def inline!
21
+ self.mode = :inline
22
+ published_events.clear
23
+ end
24
+
25
+ # Disable testing mode (normal operation)
26
+ def disable!
27
+ self.mode = nil
28
+ published_events.clear
29
+ end
30
+
31
+ # Get all published events
32
+ #
33
+ # @return [Array<Hash>] Array of published events
34
+ def published_events
35
+ @published_events ||= []
36
+ end
37
+
38
+ # Clear all published events
39
+ def clear!
40
+ published_events.clear
41
+ end
42
+
43
+ # Check if an event was published
44
+ #
45
+ # @param domain [String] Domain of the event
46
+ # @param resource [String] Resource type
47
+ # @param action [String] Action performed
48
+ # @return [Boolean] true if event was published
49
+ def published?(domain, resource, action)
50
+ published_events.any? do |event|
51
+ event[:domain] == domain &&
52
+ event[:resource] == resource &&
53
+ event[:action] == action
54
+ end
55
+ end
56
+
57
+ # Get published events matching criteria
58
+ #
59
+ # @param domain [String, nil] Optional domain filter
60
+ # @param resource [String, nil] Optional resource filter
61
+ # @param action [String, nil] Optional action filter
62
+ # @return [Array<Hash>] Matching published events
63
+ def find_events(domain: nil, resource: nil, action: nil)
64
+ published_events.select do |event|
65
+ (domain.nil? || event[:domain] == domain) &&
66
+ (resource.nil? || event[:resource] == resource) &&
67
+ (action.nil? || event[:action] == action)
68
+ end
69
+ end
70
+
71
+ # Get the last published event
72
+ #
73
+ # @return [Hash, nil] Last published event or nil
74
+ def last_event
75
+ published_events.last
76
+ end
77
+
78
+ # Get count of published events
79
+ #
80
+ # @return [Integer] Number of published events
81
+ def event_count
82
+ published_events.size
83
+ end
84
+ end
85
+
86
+ # Module to prepend to Publisher for testing support
87
+ module PublisherExtension
88
+ def publish_to_topic(topic, message, **options)
89
+ event = {
90
+ topic: topic,
91
+ message: message,
92
+ options: options,
93
+ subject: EnvelopeBuilder.build_subject(topic)
94
+ }
95
+
96
+ case Testing.mode
97
+ when :fake
98
+ Testing.published_events << event
99
+ PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
100
+ when :inline
101
+ Testing.published_events << event
102
+ execute_subscribers_inline(event)
103
+ PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
104
+ else
105
+ super
106
+ end
107
+ end
108
+
109
+ def publish_event(domain, resource, action, payload, **options)
110
+ topic = "#{domain}.#{resource}.#{action}"
111
+ event = {
112
+ domain: domain,
113
+ resource: resource,
114
+ action: action,
115
+ payload: payload,
116
+ options: options,
117
+ topic: topic,
118
+ subject: EnvelopeBuilder.build_subject(topic)
119
+ }
120
+
121
+ case Testing.mode
122
+ when :fake
123
+ Testing.published_events << event
124
+ PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
125
+ when :inline
126
+ Testing.published_events << event
127
+ execute_subscribers_inline(event.merge(message: payload))
128
+ PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
129
+ else
130
+ super
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def execute_subscribers_inline(event)
137
+ require_relative 'subscribers/registry'
138
+
139
+ subject = event[:subject]
140
+ subscribers = Subscribers::Registry.instance.subscribers_for(subject)
141
+
142
+ subscribers.each do |sub_class|
143
+ subscriber = sub_class.new
144
+ subscriber.call(event[:message] || event[:payload], event)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ # Prepend testing extension to Publisher
152
+ require_relative 'publisher/publisher'
153
+ NatsPubsub::Publisher.prepend(NatsPubsub::Testing::PublisherExtension)
154
+
155
+ # Load matchers and helpers if available
156
+ require_relative 'testing/matchers' if defined?(RSpec)
157
+ require_relative 'testing/helpers'
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require_relative 'subject_matcher'
5
+ require_relative '../core/logging'
6
+
7
+ module NatsPubsub
8
+ # Checks for overlapping subjects.
9
+ class OverlapGuard
10
+ class << self
11
+ # Raise if any desired subjects conflict with other streams.
12
+ def check!(jts, target_name, new_subjects)
13
+ conflicts = overlaps(jts, target_name, new_subjects)
14
+ return if conflicts.empty?
15
+
16
+ raise conflict_message(target_name, conflicts)
17
+ end
18
+
19
+ # Return a list of conflicts against other streams, per subject.
20
+ # [{ name:'OTHER' pairs: [['a.b.*', 'a.b.c'], ...] }, ...]
21
+ def overlaps(jts, target_name, new_subjects)
22
+ desired = StreamSupport.normalize_subjects(new_subjects)
23
+ streams = list_streams_with_subjects(jts)
24
+ others = streams.reject { |stream| stream[:name] == target_name }
25
+
26
+ others.filter_map do |stream|
27
+ pairs = desired.flat_map do |desired_subject|
28
+ stream_subjects = StreamSupport.normalize_subjects(stream[:subjects])
29
+ stream_subjects.select { |existing_subject| SubjectMatcher.overlap?(desired_subject, existing_subject) }
30
+ .map { |existing_subject| [desired_subject, existing_subject] }
31
+ end
32
+ { name: stream[:name], pairs: pairs } unless pairs.empty?
33
+ end
34
+ end
35
+
36
+ # Returns [allowed, blocked] given desired subjects.
37
+ def partition_allowed(jts, target_name, desired_subjects)
38
+ desired = StreamSupport.normalize_subjects(desired_subjects)
39
+ conflicts = overlaps(jts, target_name, desired)
40
+ blocked = conflicts.flat_map { |c| c[:pairs].map(&:first) }.uniq
41
+ allowed = desired - blocked
42
+ [allowed, blocked]
43
+ end
44
+
45
+ def allowed_subjects(jts, target_name, desired_subjects)
46
+ partition_allowed(jts, target_name, desired_subjects).first
47
+ end
48
+
49
+ private
50
+
51
+ def list_streams_with_subjects(jts)
52
+ list_stream_names(jts).map do |name|
53
+ info = jts.stream_info(name)
54
+ { name: name, subjects: Array(info.config.subjects || []) }
55
+ end
56
+ end
57
+
58
+ def list_stream_names(jts)
59
+ names = []
60
+ offset = 0
61
+ loop do
62
+ resp = js_api_request(jts, '$JS.API.STREAM.NAMES', { offset: offset })
63
+ batch = Array(resp['streams']).filter_map { |h| h['name'] }
64
+ names.concat(batch)
65
+ break if names.size >= resp['total'].to_i || batch.empty?
66
+
67
+ offset = names.size
68
+ end
69
+ names
70
+ end
71
+
72
+ def js_api_request(jts, subject, payload = {})
73
+ # JetStream client should expose the underlying NATS client as `nc`
74
+ msg = jts.nc.request(subject, Oj.dump(payload, mode: :compat))
75
+ Oj.load(msg.data, mode: :strict)
76
+ end
77
+
78
+ def conflict_message(target, conflicts)
79
+ msg = "Overlapping subjects for stream #{target}:\n"
80
+ conflicts.each do |c|
81
+ msg << "- Conflicts with '#{c[:name]}' on:\n"
82
+ c[:pairs].each { |(a, b)| msg << " • #{a} × #{b}\n" }
83
+ end
84
+ msg
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+ require_relative 'overlap_guard'
5
+ require_relative 'stream_support'
6
+
7
+ module NatsPubsub
8
+ # Ensures a stream exists and updates only uncovered subjects, using work-queue semantics.
9
+ class Stream
10
+ RETENTION = 'workqueue'
11
+ STORAGE = 'file'
12
+
13
+ class << self
14
+ def ensure!(jts, name, subjects)
15
+ desired = StreamSupport.normalize_subjects(subjects)
16
+ raise ArgumentError, 'subjects must not be empty' if desired.empty?
17
+
18
+ attempts = 0
19
+ begin
20
+ info = safe_stream_info(jts, name)
21
+ info ? ensure_update(jts, name, info, desired) : ensure_create(jts, name, desired)
22
+ rescue NATS::JetStream::Error => e
23
+ if StreamSupport.overlap_error?(e) && (attempts += 1) <= 1
24
+ Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'NatsPubsub::Stream')
25
+ sleep(0.05)
26
+ retry
27
+ elsif StreamSupport.overlap_error?(e)
28
+ Logging.warn("Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
29
+ tag: 'NatsPubsub::Stream')
30
+ nil
31
+ else
32
+ raise
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def ensure_update(jts, name, info, desired_subjects)
40
+ existing = StreamSupport.normalize_subjects(info.config.subjects || [])
41
+ to_add = StreamSupport.missing_subjects(existing, desired_subjects)
42
+ add_subjects(jts, name, existing, to_add) if to_add.any?
43
+
44
+ # Retention is immutable; warn if different and do not include on update.
45
+ have_ret = info.config.retention.to_s.downcase
46
+ StreamSupport.log_retention_mismatch(name, have: have_ret, want: RETENTION) if have_ret != RETENTION
47
+
48
+ # Storage can be updated; do it without passing retention.
49
+ have_storage = info.config.storage.to_s.downcase
50
+ if have_storage != STORAGE
51
+ apply_update(jts, name, existing, storage: STORAGE)
52
+ StreamSupport.log_config_updated(name, storage: STORAGE)
53
+ return
54
+ end
55
+
56
+ return if to_add.any?
57
+
58
+ StreamSupport.log_already_covered(name)
59
+ end
60
+
61
+ # ---- tiny helpers extracted to reduce ABC ----
62
+ def add_subjects(jts, name, existing, to_add)
63
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
64
+ return StreamSupport.log_all_blocked(name, blocked) if allowed.empty?
65
+
66
+ target = (existing + allowed).uniq
67
+ OverlapGuard.check!(jts, name, target)
68
+ # Do not pass retention on update to avoid 10052.
69
+ apply_update(jts, name, target)
70
+ StreamSupport.log_updated(name, allowed, blocked)
71
+ end
72
+
73
+ # Only include mutable fields on update (subjects, storage). Never retention.
74
+ def apply_update(jts, name, subjects, storage: nil)
75
+ params = { name: name, subjects: subjects }
76
+ params[:storage] = storage if storage
77
+ jts.update_stream(**params)
78
+ end
79
+
80
+ def ensure_create(jts, name, desired_subjects)
81
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired_subjects)
82
+ return StreamSupport.log_not_created(name, blocked) if allowed.empty?
83
+
84
+ jts.add_stream(
85
+ name: name,
86
+ subjects: allowed,
87
+ retention: RETENTION,
88
+ storage: STORAGE
89
+ )
90
+ StreamSupport.log_created(name, allowed, blocked, RETENTION, STORAGE)
91
+ end
92
+
93
+ def safe_stream_info(jts, name)
94
+ jts.stream_info(name)
95
+ rescue NATS::JetStream::Error => e
96
+ return nil if StreamSupport.stream_not_found?(e)
97
+
98
+ raise
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+ require_relative 'subject_matcher'
5
+
6
+ module NatsPubsub
7
+ # Utility module providing helper methods for stream management.
8
+ # Extracted from Stream class to follow Single Responsibility Principle.
9
+ #
10
+ # This module provides:
11
+ # - Subject normalization and filtering utilities
12
+ # - NATS error detection helpers
13
+ # - Structured logging for stream operations
14
+ #
15
+ # @example Normalizing subjects
16
+ # StreamSupport.normalize_subjects(['foo.bar', nil, '', 'baz'])
17
+ # # => ['foo.bar', 'baz']
18
+ #
19
+ # @example Checking for missing subjects
20
+ # existing = ['foo.*', 'bar.>']
21
+ # desired = ['foo.bar', 'baz.qux']
22
+ # StreamSupport.missing_subjects(existing, desired)
23
+ # # => ['baz.qux'] (foo.bar is covered by foo.*)
24
+ module StreamSupport
25
+ module_function
26
+
27
+ # Normalize a list of subjects
28
+ #
29
+ # Flattens nested arrays, removes nils, empty strings, converts to strings,
30
+ # and returns unique values.
31
+ #
32
+ # @param list [Array, Object] List of subjects (can be nested)
33
+ # @return [Array<String>] Normalized unique subject list
34
+ def normalize_subjects(list)
35
+ Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
36
+ end
37
+
38
+ # Find subjects from desired list not covered by existing patterns
39
+ #
40
+ # Uses SubjectMatcher to determine if each desired subject is covered
41
+ # by any of the existing subject patterns (including wildcards).
42
+ #
43
+ # @param existing [Array<String>] Existing subject patterns
44
+ # @param desired [Array<String>] Desired subjects to check
45
+ # @return [Array<String>] Subjects not covered by existing patterns
46
+ def missing_subjects(existing, desired)
47
+ desired.reject { |d| SubjectMatcher.covered?(existing, d) }
48
+ end
49
+
50
+ # Check if error indicates stream not found
51
+ #
52
+ # Detects NATS JetStream "stream not found" errors by examining
53
+ # the error message for known patterns.
54
+ #
55
+ # @param error [Exception] Error object
56
+ # @return [Boolean] True if error indicates stream not found
57
+ def stream_not_found?(error)
58
+ msg = error.message.to_s
59
+ msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
60
+ end
61
+
62
+ # Check if error indicates subject overlap
63
+ #
64
+ # Detects NATS JetStream subject overlap errors, which occur when
65
+ # attempting to add subjects that conflict with existing streams.
66
+ #
67
+ # @param error [Exception] Error object
68
+ # @return [Boolean] True if error indicates subject overlap
69
+ def overlap_error?(error)
70
+ msg = error.message.to_s
71
+ msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\b400\b/
72
+ end
73
+
74
+ # Log when stream subjects are already covered
75
+ #
76
+ # @param name [String] Stream name
77
+ # @return [void]
78
+ def log_already_covered(name)
79
+ Logging.info(
80
+ "Stream #{name} exists; subjects and config already covered.",
81
+ tag: 'NatsPubsub::Stream'
82
+ )
83
+ end
84
+
85
+ # Log when all subjects are blocked by overlap
86
+ #
87
+ # @param name [String] Stream name
88
+ # @param blocked [Array<String>] Blocked subjects
89
+ # @return [void]
90
+ def log_all_blocked(name, blocked)
91
+ if blocked.any?
92
+ Logging.warn(
93
+ "Stream #{name}: all missing subjects belong to other streams; unchanged. blocked=#{blocked.inspect}",
94
+ tag: 'NatsPubsub::Stream'
95
+ )
96
+ else
97
+ Logging.info("Stream #{name} exists; nothing to add.", tag: 'NatsPubsub::Stream')
98
+ end
99
+ end
100
+
101
+ # Log when stream is updated with new subjects
102
+ #
103
+ # @param name [String] Stream name
104
+ # @param added [Array<String>] Successfully added subjects
105
+ # @param blocked [Array<String>] Blocked subjects
106
+ # @return [void]
107
+ def log_updated(name, added, blocked)
108
+ msg = "Updated stream #{name}; added subjects=#{added.inspect}"
109
+ msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
110
+ Logging.info(msg, tag: 'NatsPubsub::Stream')
111
+ end
112
+
113
+ # Log when stream creation is skipped due to overlaps
114
+ #
115
+ # @param name [String] Stream name
116
+ # @param blocked [Array<String>] Blocked subjects
117
+ # @return [void]
118
+ def log_not_created(name, blocked)
119
+ Logging.warn(
120
+ "Not creating stream #{name}: all desired subjects belong to other streams. blocked=#{blocked.inspect}",
121
+ tag: 'NatsPubsub::Stream'
122
+ )
123
+ end
124
+
125
+ # Log when stream is successfully created
126
+ #
127
+ # @param name [String] Stream name
128
+ # @param allowed [Array<String>] Allowed subjects
129
+ # @param blocked [Array<String>] Blocked subjects
130
+ # @param retention [String] Retention policy
131
+ # @param storage [String] Storage type
132
+ # @return [void]
133
+ def log_created(name, allowed, blocked, retention, storage)
134
+ msg = [
135
+ "Created stream #{name}",
136
+ "subjects=#{allowed.inspect}",
137
+ "retention=#{retention.inspect}",
138
+ "storage=#{storage.inspect}"
139
+ ].join(' ')
140
+ msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
141
+ Logging.info(msg, tag: 'NatsPubsub::Stream')
142
+ end
143
+
144
+ # Log when stream config is updated
145
+ #
146
+ # @param name [String] Stream name
147
+ # @param storage [String] Storage type
148
+ # @return [void]
149
+ def log_config_updated(name, storage:)
150
+ Logging.info(
151
+ "Updated stream #{name} config; storage=#{storage.inspect}",
152
+ tag: 'NatsPubsub::Stream'
153
+ )
154
+ end
155
+
156
+ # Log retention policy mismatch warning
157
+ #
158
+ # @param name [String] Stream name
159
+ # @param have [String] Current retention policy
160
+ # @param want [String] Desired retention policy
161
+ # @return [void]
162
+ def log_retention_mismatch(name, have:, want:)
163
+ Logging.warn(
164
+ "Stream #{name} retention mismatch (have=#{have.inspect}, want=#{want.inspect}). " \
165
+ "Retention is immutable; skipping retention change.",
166
+ tag: 'NatsPubsub::Stream'
167
+ )
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # Subject matching helpers.
5
+ module SubjectMatcher
6
+ module_function
7
+
8
+ def covered?(patterns, subject)
9
+ Array(patterns).any? { |pat| match?(pat.to_s, subject.to_s) }
10
+ end
11
+
12
+ # Proper NATS semantics:
13
+ # - '*' matches exactly one token
14
+ # - '>' matches the rest (zero or more tokens)
15
+ def match?(pattern, subject)
16
+ pattern_tokens = pattern.split('.')
17
+ subject_tokens = subject.split('.')
18
+
19
+ index = 0
20
+ while index < pattern_tokens.length && index < subject_tokens.length
21
+ pattern_token = pattern_tokens[index]
22
+ case pattern_token
23
+ when '>'
24
+ return true # tail wildcard absorbs the rest
25
+ when '*'
26
+ # matches this token; continue
27
+ else
28
+ return false unless pattern_token == subject_tokens[index]
29
+ end
30
+ index += 1
31
+ end
32
+
33
+ # Exact match
34
+ return true if index == pattern_tokens.length && index == subject_tokens.length
35
+
36
+ # If pattern has remaining '>' it can absorb remainder
37
+ pattern_tokens[index] == '>' || pattern_tokens[index..]&.include?('>')
38
+ end
39
+
40
+ # Do two wildcard patterns admit at least one same subject?
41
+ def overlap?(sub_a, sub_b)
42
+ overlap_parts?(sub_a.split('.'), sub_b.split('.'))
43
+ end
44
+
45
+ def overlap_parts?(a_parts, b_parts)
46
+ a_index = 0
47
+ b_index = 0
48
+ while a_index < a_parts.length && b_index < b_parts.length
49
+ a_token = a_parts[a_index]
50
+ b_token = b_parts[b_index]
51
+ return true if has_tail_wildcard?(a_token, b_token)
52
+ return false unless token_match?(a_token, b_token)
53
+
54
+ a_index += 1
55
+ b_index += 1
56
+ end
57
+
58
+ tail_overlap?(a_parts[a_index..], b_parts[b_index..])
59
+ end
60
+
61
+ def has_tail_wildcard?(a_token, b_token)
62
+ a_token == '>' || b_token == '>'
63
+ end
64
+
65
+ def token_match?(a_token, b_token)
66
+ a_token == b_token || a_token == '*' || b_token == '*'
67
+ end
68
+
69
+ def tail_overlap?(a_tail, b_tail)
70
+ a_tail ||= []
71
+ b_tail ||= []
72
+ return true if a_tail.include?('>') || b_tail.include?('>')
73
+
74
+ a_tail.empty? && b_tail.empty?
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/config'
4
+ require_relative '../core/logging'
5
+ require_relative 'stream'
6
+
7
+ module NatsPubsub
8
+ class Topology
9
+ def self.ensure!(jts)
10
+ cfg = NatsPubsub.config
11
+
12
+ # Create stream for all PubSub events
13
+ subjects = ["#{cfg.env}.events.>"]
14
+ subjects << cfg.dlq_subject if cfg.use_dlq
15
+
16
+ Stream.ensure!(jts, cfg.stream_name, subjects)
17
+
18
+ Logging.info(
19
+ "PubSub stream ready: #{cfg.stream_name} with subjects=#{subjects.inspect}",
20
+ tag: 'NatsPubsub::Topology'
21
+ )
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NatsPubsub
4
+ #
5
+ # Version constant for the gem.
6
+ module NatsPubsub
7
+ VERSION = '1.0.0'
8
+ end
@@ -0,0 +1,55 @@
1
+ <h2>Dashboard</h2>
2
+
3
+ <h3 style="margin-top: 30px; margin-bottom: 15px;">Outbox Statistics</h3>
4
+ <div class="stats">
5
+ <div class="stat-card">
6
+ <h3>Total</h3>
7
+ <div class="value"><%= @outbox_stats[:total] || 0 %></div>
8
+ </div>
9
+ <div class="stat-card warning">
10
+ <h3>Pending</h3>
11
+ <div class="value"><%= @outbox_stats[:pending] || 0 %></div>
12
+ </div>
13
+ <div class="stat-card warning">
14
+ <h3>Publishing</h3>
15
+ <div class="value"><%= @outbox_stats[:publishing] || 0 %></div>
16
+ </div>
17
+ <div class="stat-card success">
18
+ <h3>Sent</h3>
19
+ <div class="value"><%= @outbox_stats[:sent] || 0 %></div>
20
+ </div>
21
+ <div class="stat-card danger">
22
+ <h3>Failed</h3>
23
+ <div class="value"><%= @outbox_stats[:failed] || 0 %></div>
24
+ </div>
25
+ </div>
26
+
27
+ <h3 style="margin-top: 30px; margin-bottom: 15px;">Inbox Statistics</h3>
28
+ <div class="stats">
29
+ <div class="stat-card">
30
+ <h3>Total</h3>
31
+ <div class="value"><%= @inbox_stats[:total] || 0 %></div>
32
+ </div>
33
+ <div class="stat-card warning">
34
+ <h3>Received</h3>
35
+ <div class="value"><%= @inbox_stats[:received] || 0 %></div>
36
+ </div>
37
+ <div class="stat-card warning">
38
+ <h3>Processing</h3>
39
+ <div class="value"><%= @inbox_stats[:processing] || 0 %></div>
40
+ </div>
41
+ <div class="stat-card success">
42
+ <h3>Processed</h3>
43
+ <div class="value"><%= @inbox_stats[:processed] || 0 %></div>
44
+ </div>
45
+ <div class="stat-card danger">
46
+ <h3>Failed</h3>
47
+ <div class="value"><%= @inbox_stats[:failed] || 0 %></div>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="card" style="margin-top: 30px;">
52
+ <h2>Quick Links</h2>
53
+ <p><a href="/outbox" class="btn btn-primary">View Outbox Events</a></p>
54
+ <p style="margin-top: 10px;"><a href="/inbox" class="btn btn-primary">View Inbox Events</a></p>
55
+ </div>