smart_message 0.0.13 → 0.0.17

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +184 -0
  4. data/Gemfile.lock +6 -6
  5. data/README.md +75 -25
  6. data/docs/guides/transport-selection.md +361 -0
  7. data/docs/index.md +2 -0
  8. data/docs/reference/transports.md +78 -29
  9. data/docs/transports/file-transport.md +535 -0
  10. data/docs/transports/memory-transport.md +2 -1
  11. data/docs/transports/multi-transport.md +484 -0
  12. data/docs/transports/redis-transport.md +1 -1
  13. data/docs/transports/stdout-transport.md +580 -0
  14. data/examples/file/00_run_all_file_demos.rb +260 -0
  15. data/examples/file/01_basic_file_transport_demo.rb +237 -0
  16. data/examples/file/02_fifo_transport_demo.rb +289 -0
  17. data/examples/file/03_file_watching_demo.rb +332 -0
  18. data/examples/file/04_multi_transport_file_demo.rb +432 -0
  19. data/examples/file/README.md +257 -0
  20. data/examples/memory/00_run_all_demos.rb +317 -0
  21. data/examples/memory/01_message_deduplication_demo.rb +18 -30
  22. data/examples/memory/02_dead_letter_queue_demo.rb +9 -9
  23. data/examples/memory/03_point_to_point_orders.rb +3 -3
  24. data/examples/memory/04_publish_subscribe_events.rb +15 -15
  25. data/examples/memory/05_many_to_many_chat.rb +19 -19
  26. data/examples/memory/06_stdout_publish_only.rb +118 -0
  27. data/examples/memory/07_proc_handlers_demo.rb +13 -13
  28. data/examples/memory/08_custom_logger_demo.rb +136 -136
  29. data/examples/memory/09_error_handling_demo.rb +7 -7
  30. data/examples/memory/10_entity_addressing_basic.rb +25 -25
  31. data/examples/memory/11_entity_addressing_with_filtering.rb +32 -32
  32. data/examples/memory/12_regex_filtering_microservices.rb +10 -10
  33. data/examples/memory/14_global_configuration_demo.rb +12 -12
  34. data/examples/memory/README.md +34 -17
  35. data/examples/memory/log/demo_app.log.2 +100 -0
  36. data/examples/multi_transport_example.rb +114 -0
  37. data/examples/redis/01_smart_home_iot_demo.rb +20 -20
  38. data/examples/utilities/box_it.rb +12 -0
  39. data/examples/utilities/doing.rb +19 -0
  40. data/examples/utilities/temp.md +28 -0
  41. data/lib/smart_message/base.rb +5 -7
  42. data/lib/smart_message/errors.rb +3 -0
  43. data/lib/smart_message/header.rb +1 -1
  44. data/lib/smart_message/logger/default.rb +1 -1
  45. data/lib/smart_message/messaging.rb +36 -6
  46. data/lib/smart_message/plugins.rb +46 -4
  47. data/lib/smart_message/serializer/base.rb +1 -1
  48. data/lib/smart_message/serializer.rb +3 -2
  49. data/lib/smart_message/subscription.rb +18 -20
  50. data/lib/smart_message/transport/async_publish_queue.rb +284 -0
  51. data/lib/smart_message/transport/fifo_operations.rb +264 -0
  52. data/lib/smart_message/transport/file_operations.rb +232 -0
  53. data/lib/smart_message/transport/file_transport.rb +152 -0
  54. data/lib/smart_message/transport/file_watching.rb +72 -0
  55. data/lib/smart_message/transport/partitioned_files.rb +46 -0
  56. data/lib/smart_message/transport/stdout_transport.rb +7 -81
  57. data/lib/smart_message/transport/stdout_transport.rb.backup +88 -0
  58. data/lib/smart_message/version.rb +1 -1
  59. data/mkdocs.yml +4 -5
  60. metadata +26 -10
  61. data/ideas/README.md +0 -41
  62. data/ideas/agents.md +0 -1001
  63. data/ideas/database_transport.md +0 -980
  64. data/ideas/improvement.md +0 -359
  65. data/ideas/meshage.md +0 -1788
  66. data/ideas/message_discovery.md +0 -178
  67. data/ideas/message_schema.md +0 -1381
  68. data/lib/smart_message/wrapper.rb.bak +0 -132
  69. /data/examples/memory/{06_pretty_print_demo.rb → 16_pretty_print_demo.rb} +0 -0
@@ -0,0 +1,232 @@
1
+ # lib/smart_message/transport/file_operations.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ module SmartMessage
6
+ module Transport
7
+ # Module for shared file operations, including buffering, rotation, and basic I/O.
8
+ module FileOperations
9
+ def configure_file_output
10
+ ensure_directory_exists if @options[:create_directories]
11
+ @file_handle = open_file_handle
12
+ @write_buffer = []
13
+ @last_flush = Time.now
14
+ setup_rotation_timer if rotation_enabled?
15
+ @file_mutex = Mutex.new # For thread-safety
16
+ end
17
+
18
+ def write_to_file(serialized_message)
19
+ content = prepare_file_content(serialized_message)
20
+
21
+ @file_mutex.synchronize do
22
+ if buffered_mode?
23
+ buffer_write(content)
24
+ else
25
+ direct_write(content)
26
+ end
27
+
28
+ rotate_file_if_needed
29
+ end
30
+ end
31
+
32
+ def flush_buffer
33
+ return if @write_buffer.empty?
34
+
35
+ # Only synchronize if we're not already holding the lock
36
+ if @file_mutex.owned?
37
+ @file_handle.write(@write_buffer.join)
38
+ @file_handle.flush
39
+ @write_buffer.clear
40
+ @last_flush = Time.now
41
+ else
42
+ @file_mutex.synchronize do
43
+ @file_handle.write(@write_buffer.join)
44
+ @file_handle.flush
45
+ @write_buffer.clear
46
+ @last_flush = Time.now
47
+ end
48
+ end
49
+ end
50
+
51
+ def close_file_handle
52
+ flush_buffer if buffered_mode?
53
+ if @file_mutex
54
+ @file_mutex.synchronize do
55
+ @file_handle&.flush unless @file_handle&.closed?
56
+ @file_handle&.close unless @file_handle&.closed?
57
+ @file_handle = nil
58
+ end
59
+ else
60
+ @file_handle&.flush unless @file_handle&.closed?
61
+ @file_handle&.close unless @file_handle&.closed?
62
+ @file_handle = nil
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def prepare_file_content(serialized_message)
69
+ case @options[:format] || @options[:file_format] || :jsonl
70
+ when :json, :raw
71
+ serialized_message
72
+ when :jsonl, :lines
73
+ "#{serialized_message}\n"
74
+ when :pretty
75
+ begin
76
+ require 'amazing_print'
77
+ # Use the serializer to decode back to the original data structure
78
+ if @serializer.respond_to?(:decode)
79
+ data = @serializer.decode(serialized_message)
80
+ data.ai + "\n"
81
+ else
82
+ # Fallback: try to parse as JSON
83
+ begin
84
+ require 'json'
85
+ data = JSON.parse(serialized_message)
86
+ data.ai + "\n"
87
+ rescue JSON::ParserError
88
+ # If not JSON, pretty print the raw string
89
+ serialized_message.ai + "\n"
90
+ end
91
+ end
92
+ rescue LoadError
93
+ # Fallback if amazing_print not available
94
+ "#{serialized_message}\n"
95
+ rescue => e
96
+ # Handle any other errors (like circuit breaker issues)
97
+ "#{serialized_message}\n"
98
+ end
99
+ else
100
+ "#{serialized_message}\n" # default to jsonl
101
+ end
102
+ end
103
+
104
+ def open_file_handle
105
+ # Return IO objects directly, don't try to open them
106
+ return @options[:file_path] if @options[:file_path].respond_to?(:write)
107
+ # Open file handle for string paths
108
+ File.open(current_file_path, file_mode, encoding: @options[:encoding])
109
+ end
110
+
111
+ def ensure_directory_exists
112
+ return unless @options[:create_directories]
113
+ return if @options[:file_path].respond_to?(:write) # Skip for IO objects
114
+
115
+ dir = File.dirname(current_file_path)
116
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
117
+ end
118
+
119
+ def current_file_path
120
+ if rotation_enabled? && time_based_rotation?
121
+ timestamped_file_path
122
+ else
123
+ @options[:file_path]
124
+ end
125
+ end
126
+
127
+ def file_mode
128
+ @options[:file_mode] || 'a' # append by default
129
+ end
130
+
131
+ def buffered_mode?
132
+ @options[:buffer_size] && @options[:buffer_size] > 0
133
+ end
134
+
135
+ def buffer_write(content)
136
+ @write_buffer << content
137
+
138
+ if buffer_full? || flush_interval_exceeded?
139
+ flush_buffer
140
+ end
141
+ end
142
+
143
+ def direct_write(content)
144
+ return unless @file_handle
145
+ @file_handle.write(content)
146
+ @file_handle.flush if @options[:auto_flush]
147
+ end
148
+
149
+ def buffer_full?
150
+ @write_buffer.join.bytesize >= @options[:buffer_size]
151
+ end
152
+
153
+ def flush_interval_exceeded?
154
+ @options[:flush_interval] &&
155
+ (Time.now - @last_flush) >= @options[:flush_interval]
156
+ end
157
+
158
+ def rotation_enabled?
159
+ @options[:rotate_size] || @options[:rotate_time]
160
+ end
161
+
162
+ def time_based_rotation?
163
+ @options[:rotate_time]
164
+ end
165
+
166
+ def should_rotate?
167
+ size_rotation_needed? || time_rotation_needed?
168
+ end
169
+
170
+ def size_rotation_needed?
171
+ @options[:rotate_size] &&
172
+ File.exist?(current_file_path) && File.size(current_file_path) >= @options[:rotate_size]
173
+ end
174
+
175
+ def time_rotation_needed?
176
+ return false unless @options[:rotate_time]
177
+
178
+ case @options[:rotate_time]
179
+ when :hourly
180
+ Time.now.min == 0 && Time.now.sec == 0
181
+ when :daily
182
+ Time.now.hour == 0 && Time.now.min == 0 && Time.now.sec == 0
183
+ else
184
+ false
185
+ end
186
+ end
187
+
188
+ def rotate_file_if_needed
189
+ return unless should_rotate?
190
+
191
+ close_current_file
192
+ archive_current_file
193
+ @file_handle = open_file_handle
194
+ end
195
+
196
+ def close_current_file
197
+ flush_buffer if buffered_mode?
198
+ @file_handle&.close
199
+ @file_handle = nil
200
+ end
201
+
202
+ def archive_current_file
203
+ return unless File.exist?(current_file_path)
204
+
205
+ timestamp = Time.now.strftime(@options[:timestamp_format] || '%Y%m%d_%H%M%S')
206
+ base = File.basename(@options[:file_path], '.*')
207
+ ext = File.extname(@options[:file_path])
208
+ dir = File.dirname(@options[:file_path])
209
+ archive_path = File.join(dir, "#{base}_#{timestamp}#{ext}")
210
+
211
+ FileUtils.mv(current_file_path, archive_path)
212
+
213
+ # Maintain rotation count
214
+ if @options[:rotate_count]
215
+ files = Dir.glob(File.join(dir, "#{base}_*#{ext}")).sort
216
+ while files.size > @options[:rotate_count]
217
+ File.delete(files.shift)
218
+ end
219
+ end
220
+ end
221
+
222
+ def timestamped_file_path
223
+ base = File.basename(@options[:file_path], '.*')
224
+ ext = File.extname(@options[:file_path])
225
+ dir = File.dirname(@options[:file_path])
226
+ timestamp = Time.now.strftime(@options[:timestamp_format] || '%Y%m%d_%H%M%S')
227
+
228
+ File.join(dir, "#{base}_#{timestamp}#{ext}")
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,152 @@
1
+ # lib/smart_message/transport/file_transport.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'file_operations'
6
+ require_relative 'file_watching'
7
+ require_relative 'partitioned_files'
8
+ require_relative 'async_publish_queue'
9
+ require_relative 'fifo_operations'
10
+
11
+ module SmartMessage
12
+ module Transport
13
+ class FileTransport < Base
14
+ include FileOperations
15
+ include FileWatching
16
+ include PartitionedFiles
17
+ include AsyncPublishQueue
18
+ include FifoOperations
19
+
20
+ # @param path [String, IO, Pathname] file path or IO-like object
21
+ # @param mode [String] file open mode ("a" for append, etc.)
22
+ # @param encoding [String, nil] file encoding
23
+ def initialize(options = {})
24
+ @current_message_class = nil
25
+ super(**options)
26
+ end
27
+
28
+ def default_options
29
+ {
30
+ file_path: 'messages.log',
31
+ file_mode: 'a',
32
+ encoding: nil,
33
+ file_format: :lines,
34
+ buffer_size: 0,
35
+ flush_interval: nil,
36
+ auto_flush: true,
37
+ rotate_size: nil,
38
+ rotate_time: nil,
39
+ rotate_count: 5,
40
+ timestamp_format: '%Y%m%d_%H%M%S',
41
+ create_directories: true,
42
+ async: false,
43
+ max_queue: nil,
44
+ drop_when_full: false,
45
+ queue_overflow_strategy: :block,
46
+ max_retries: 3,
47
+ max_retry_delay: 30,
48
+ worker_timeout: 5,
49
+ shutdown_timeout: 10,
50
+ queue_warning_threshold: 0.8,
51
+ enable_queue_monitoring: true,
52
+ drain_queue_on_shutdown: true,
53
+ send_dropped_to_dlq: false,
54
+ read_from_end: true,
55
+ poll_interval: 1.0,
56
+ file_type: :regular,
57
+ create_fifo: false,
58
+ fifo_mode: :blocking,
59
+ fifo_permissions: 0644,
60
+ fallback_transport: nil,
61
+ enable_subscriptions: false,
62
+ subscription_mode: :polling,
63
+ filename_selector: nil,
64
+ directory: nil,
65
+ subscription_file_path: nil
66
+ }
67
+ end
68
+
69
+ def configure
70
+ # Call parent configuration first
71
+ super if defined?(super)
72
+
73
+ # Then configure our file-specific features
74
+ if @options[:async]
75
+ configure_async_publishing
76
+ elsif @options[:file_type] == :fifo
77
+ configure_fifo
78
+ else
79
+ configure_file_output
80
+ end
81
+ end
82
+
83
+ def publish(message)
84
+ # Extract message class and serialize the message
85
+ message_class = message._sm_header.message_class
86
+ serialized_message = encode_message(message)
87
+ do_publish(message_class, serialized_message)
88
+ end
89
+
90
+ def do_publish(message_class, serialized_message)
91
+ @current_message_class = message_class
92
+ if @options[:async]
93
+ async_publish(message_class, serialized_message)
94
+ else
95
+ if @options[:filename_selector] || @options[:directory]
96
+ header = { message_class_name: message_class.to_s }
97
+ path = determine_file_path(serialized_message, header)
98
+ @file_handle = get_or_open_partition_handle(path)
99
+ write_to_file(serialized_message)
100
+ elsif @options[:file_type] == :fifo
101
+ write_to_fifo(serialized_message)
102
+ else
103
+ write_to_file(serialized_message)
104
+ end
105
+ end
106
+ end
107
+
108
+ def subscribe(message_class, process_method, filter_options = {})
109
+ unless @options[:enable_subscriptions]
110
+ logger.warn { "[FileTransport] Subscriptions disabled - set enable_subscriptions: true" }
111
+ return
112
+ end
113
+
114
+ if @options[:file_type] == :fifo
115
+ start_fifo_reader(message_class, process_method, filter_options)
116
+ else
117
+ start_file_polling(message_class, process_method, filter_options)
118
+ end
119
+
120
+ super(message_class, process_method, filter_options)
121
+ end
122
+
123
+ def connected?
124
+ case @options[:file_type]
125
+ when :fifo
126
+ subscription_active? || fifo_active?
127
+ else
128
+ (@file_handle && !@file_handle.closed?) || subscription_active?
129
+ end
130
+ end
131
+
132
+ def disconnect
133
+ stop_file_subscriptions
134
+ stop_fifo_operations if @options[:file_type] == :fifo
135
+ stop_async_publishing if @options[:async]
136
+ close_partition_handles if @options[:filename_selector] || @options[:directory]
137
+ close_file_handle
138
+ end
139
+
140
+ private
141
+
142
+ def subscription_active?
143
+ @polling_thread&.alive? || fifo_active?
144
+ end
145
+
146
+ def stop_file_subscriptions
147
+ @polling_thread&.kill
148
+ @polling_thread&.join(5)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,72 @@
1
+ # lib/smart_message/transport/file_watching.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ module SmartMessage
6
+ module Transport
7
+ # Module for file watching and subscription support (reading/tailing).
8
+ module FileWatching
9
+
10
+ private
11
+
12
+ def logger
13
+ # Ensure we have a proper logger, not just an IO object
14
+ if @logger && @logger.respond_to?(:error) && @logger.respond_to?(:info) && @logger.respond_to?(:warn)
15
+ return @logger
16
+ end
17
+ @logger = SmartMessage::Logger.default
18
+ end
19
+
20
+ public
21
+ def start_file_polling(message_class, process_method, filter_options)
22
+ poll_interval = filter_options[:poll_interval] || @options[:poll_interval] || 1.0
23
+ file_path = subscription_file_path(message_class, filter_options)
24
+
25
+ @polling_thread = Thread.new do
26
+ Thread.current.name = "FileTransport-Poller"
27
+ last_position = @options[:read_from_end] ? (File.exist?(file_path) ? File.size(file_path) : 0) : 0
28
+
29
+ loop do
30
+ sleep poll_interval
31
+ next unless File.exist?(file_path)
32
+
33
+ current_size = File.size(file_path)
34
+ if current_size > last_position
35
+ process_new_file_content(file_path, last_position, current_size, message_class)
36
+ last_position = current_size
37
+ end
38
+ end
39
+ rescue => e
40
+ logger.error { "[FileTransport] Polling thread error: #{e.message}" }
41
+ end
42
+ end
43
+
44
+ def process_new_file_content(file_path, start_pos, end_pos, message_class)
45
+ File.open(file_path, 'r', encoding: @options[:encoding]) do |file|
46
+ file.seek(start_pos)
47
+ content = file.read(end_pos - start_pos)
48
+
49
+ content.each_line do |line|
50
+ next if line.strip.empty?
51
+
52
+ begin
53
+ receive(message_class, line.strip)
54
+ rescue => e
55
+ begin
56
+ logger.error { "[FileTransport] Error processing message: #{e.message}" }
57
+ rescue
58
+ # Fallback if logger is not available
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ def subscription_file_path(message_class, filter_options)
66
+ filter_options[:file_path] ||
67
+ @options[:subscription_file_path] ||
68
+ File.join(File.dirname(@options[:file_path]), "#{message_class.to_s.downcase}.jsonl")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,46 @@
1
+ # lib/smart_message/transport/partitioned_files.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ module SmartMessage
6
+ module Transport
7
+ # Module for fan-out/partitioned file support.
8
+ module PartitionedFiles
9
+ def determine_file_path(payload, header)
10
+ if @options[:filename_selector]&.respond_to?(:call)
11
+ @options[:filename_selector].call(payload, header)
12
+ elsif @options[:directory]
13
+ File.join(@options[:directory], "#{header[:message_class_name].to_s.downcase}.log")
14
+ else
15
+ @options[:file_path]
16
+ end
17
+ end
18
+
19
+ def get_or_open_partition_handle(full_path)
20
+ @partition_handles ||= {}
21
+ @partition_mutexes ||= {}
22
+
23
+ unless @partition_handles[full_path]
24
+ ensure_directory_exists_for(full_path)
25
+ @partition_handles[full_path] = File.open(full_path, file_mode, encoding: @options[:encoding])
26
+ @partition_mutexes[full_path] = Mutex.new
27
+ end
28
+
29
+ @partition_handles[full_path]
30
+ end
31
+
32
+ def ensure_directory_exists_for(path)
33
+ dir = File.dirname(path)
34
+ FileUtils.mkdir_p(dir) if @options[:create_directories] && !Dir.exist?(dir)
35
+ end
36
+
37
+ def close_partition_handles
38
+ @partition_handles&.each do |_, handle|
39
+ handle.close
40
+ end
41
+ @partition_handles = {}
42
+ @partition_mutexes = {}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,89 +2,15 @@
2
2
  # encoding: utf-8
3
3
  # frozen_string_literal: true
4
4
 
5
- module SmartMessage
6
- module Transport
7
- # STDOUT transport for testing and development
8
- # This transport outputs messages to STDOUT and optionally loops them back
9
- class StdoutTransport < Base
10
- def default_options
11
- {
12
- loopback: false,
13
- output: $stdout,
14
- format: :pretty # :pretty or :json
15
- }
16
- end
17
-
18
- # Default to JSON for readability in STDOUT
19
- def default_serializer
20
- SmartMessage::Serializer::Json.new
21
- end
22
-
23
- def configure
24
- @output = @options[:output].is_a?(String) ? File.open(@options[:output], 'w') : @options[:output]
25
- end
26
-
27
- # Enable/disable loopback mode
28
- def loopback=(enabled)
29
- @options[:loopback] = enabled
30
- end
31
-
32
- def loopback?
33
- @options[:loopback]
34
- end
35
-
36
- # Publish message to STDOUT
37
- def do_publish(message_class, serialized_message)
38
- logger.debug { "[SmartMessage::StdoutTransport] do_publish called" }
39
- logger.debug { "[SmartMessage::StdoutTransport] message_class: #{message_class}" }
40
-
41
- @output.puts format_message(message_class, serialized_message)
42
- @output.flush
5
+ require_relative 'file_transport'
43
6
 
44
- # If loopback is enabled, route the message back through the dispatcher
45
- if loopback?
46
- logger.debug { "[SmartMessage::StdoutTransport] Loopback enabled, calling receive" }
47
- receive(message_class, serialized_message)
48
- end
49
- rescue => e
50
- logger.error { "[SmartMessage] Error in stdout transport do_publish: #{e.class.name} - #{e.message}" }
51
- raise
52
- end
53
-
54
- def connected?
55
- !@output.closed?
56
- end
57
-
58
- def disconnect
59
- @output.close if @output.respond_to?(:close) && @output != $stdout && @output != $stderr
60
- end
61
-
62
- private
63
-
64
- def format_message(message_class, serialized_message)
65
- if @options[:format] == :json
66
- # Output as JSON for machine parsing
67
- {
68
- transport: 'stdout',
69
- message_class: message_class,
70
- serialized_message: serialized_message,
71
- timestamp: Time.now.iso8601
72
- }.to_json
73
- else
74
- # Pretty format for human reading
75
- <<~MESSAGE
76
-
77
- ===================================================
78
- == SmartMessage Published via STDOUT Transport
79
- == Message Class: #{message_class}
80
- == Serializer: #{@serializer.class.name}
81
- == Serialized Message:
82
- #{serialized_message}
83
- ===================================================
84
-
85
- MESSAGE
7
+ module SmartMessage
8
+ module Transport
9
+ class StdoutTransport < FileTransport
10
+ def initialize(options = {})
11
+ defaults = { file_path: $stdout, file_mode: 'w', file_type: :regular }
12
+ super(defaults.merge(options))
86
13
  end
87
14
  end
88
15
  end
89
16
  end
90
- end
@@ -0,0 +1,88 @@
1
+ # lib/smart_message/transport/stdout_transport.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ module SmartMessage
6
+ module Transport
7
+ # STDOUT transport for testing and development
8
+ # This is a publish-only transport that outputs messages to STDOUT
9
+ class StdoutTransport < Base
10
+ def default_options
11
+ {
12
+ output: $stdout,
13
+ format: :pretty # :pretty or :json
14
+ }
15
+ end
16
+
17
+ # Default to JSON for readability in STDOUT
18
+ def default_serializer
19
+ SmartMessage::Serializer::Json.new
20
+ end
21
+
22
+ def configure
23
+ @output = @options[:output].is_a?(String) ? File.open(@options[:output], 'w') : @options[:output]
24
+ end
25
+
26
+
27
+ # Publish message to STDOUT
28
+ def do_publish(message_class, serialized_message)
29
+ logger.debug { "[SmartMessage::StdoutTransport] do_publish called" }
30
+ logger.debug { "[SmartMessage::StdoutTransport] message_class: #{message_class}" }
31
+
32
+ @output.puts format_message(message_class, serialized_message)
33
+ @output.flush
34
+ rescue => e
35
+ logger.error { "[SmartMessage] Error in stdout transport do_publish: #{e.class.name} - #{e.message}" }
36
+ raise
37
+ end
38
+
39
+ def connected?
40
+ !@output.closed?
41
+ end
42
+
43
+ def disconnect
44
+ @output.close if @output.respond_to?(:close) && @output != $stdout && @output != $stderr
45
+ end
46
+
47
+ # Override subscribe methods to log warnings since this is a publish-only transport
48
+ def subscribe(message_class, process_method, filter_options = {})
49
+ logger.warn { "[SmartMessage::StdoutTransport] Subscription attempt ignored - STDOUT transport is publish-only (message_class: #{message_class}, process_method: #{process_method})" }
50
+ end
51
+
52
+ def unsubscribe(message_class, process_method)
53
+ logger.warn { "[SmartMessage::StdoutTransport] Unsubscribe attempt ignored - STDOUT transport is publish-only (message_class: #{message_class}, process_method: #{process_method})" }
54
+ end
55
+
56
+ def unsubscribe!(message_class)
57
+ logger.warn { "[SmartMessage::StdoutTransport] Unsubscribe all attempt ignored - STDOUT transport is publish-only (message_class: #{message_class})" }
58
+ end
59
+
60
+ private
61
+
62
+ def format_message(message_class, serialized_message)
63
+ if @options[:format] == :json
64
+ # Output as JSON for machine parsing
65
+ {
66
+ transport: 'stdout',
67
+ message_class: message_class,
68
+ serialized_message: serialized_message,
69
+ timestamp: Time.now.iso8601
70
+ }.to_json
71
+ else
72
+ # Pretty format for human reading
73
+ <<~MESSAGE
74
+
75
+ ===================================================
76
+ == SmartMessage Published via STDOUT Transport
77
+ == Message Class: #{message_class}
78
+ == Serializer: #{@serializer.class.name}
79
+ == Serialized Message:
80
+ #{serialized_message}
81
+ ===================================================
82
+
83
+ MESSAGE
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  module SmartMessage
6
- VERSION = '0.0.13'
6
+ VERSION = '0.0.17'
7
7
  end