nti_event_bus 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 20a81903201667f24f670449fdcd72d0c5dc7e2ff317fc992a32d129c585eb60
4
+ data.tar.gz: 91b184e622a256994b980f6ed0fae575768144ee195b8dcf97ab1477dc86fa60
5
+ SHA512:
6
+ metadata.gz: 1f711d6690c639c15e71cab839ec930ef5d0e86e01e290f537757386ead3dd330ee88e91826f8ea9b54788c311b146c0de296f3f55271823369a510dfd8440a3
7
+ data.tar.gz: 2e50543c96df318dc59523ee3f03a97bfcb6eea141d26ec919988428ce6c4f4fa91b746032bb55d7393ee728efd50b961f380f76d1ec18c750946083ca114442
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-07-07
4
+
5
+ - Initial release. Extracted from the Tindahang Tapat OnPrem application.
6
+ - `NtiEventBus.publish` fans a `domain.action` event out to subscribed handler classes.
7
+ - `NtiEventBus::DSL` (`draw` / `on_event` / `perform`) declares subscriptions loaded from event files.
8
+ - `NtiEventBus::Registry` — deep-frozen after `finalize!` for lock-free reads.
9
+ - `NtiEventBus::HandlerBase` — base class for handlers (indifferent-access frozen payload, `require_keys`).
10
+ - `NtiEventBus::HandleEventJob` — ActiveJob-backed asynchronous dispatch on a configurable queue.
11
+ - `NtiEventBus::Dispatchers::ActiveJob` (default) and `NtiEventBus::Dispatchers::Inline`.
12
+ - `NtiEventBus::Railtie` — sets event-file path defaults and rebuilds the registry on `to_prepare`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Nueca Technologies Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # NTI Event Bus
2
+
3
+ [![CI](https://github.com/nueca-tech/nti_event_bus/actions/workflows/ci.yml/badge.svg)](https://github.com/nueca-tech/nti_event_bus/actions/workflows/ci.yml)
4
+
5
+ A small, production-grade **publish/subscribe event bus** for Ruby & Rails.
6
+
7
+ Declare `domain.action` subscriptions in a compact DSL, `publish` events, and fan them out to handler classes **asynchronously** via ActiveJob. The subscription registry is built once at boot and **deep-frozen**, so publishing is a lock-free hash lookup with no per-call allocation and no long-lived, unbounded state — no thread pools, no notification subscribers, nothing to leak.
8
+
9
+ ```
10
+ publisher → NtiEventBus.publish("order.created", payload)
11
+ → registry.handlers_for("order.created") # frozen O(1) lookup
12
+ → dispatcher.call(handler_name, payload)
13
+ → NtiEventBus::HandleEventJob (ActiveJob, async)
14
+ → Handler.new(payload).call
15
+ ```
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ gem 'nti_event_bus'
21
+ ```
22
+
23
+ ## Rails usage
24
+
25
+ On Rails the bundled `NtiEventBus::Railtie` wires everything up automatically:
26
+
27
+ - Defaults `configuration.root_events_file` to `config/events.rb` and `configuration.events_dir` to
28
+ `config/events/`.
29
+ - Rebuilds the registry on every `to_prepare` (once in production; per code-reload in development, so edits to event files are picked up and old handler references are released).
30
+
31
+ Define the root events file and one or more drawn files:
32
+
33
+ ```ruby
34
+ # config/events.rb
35
+ draw :internal_events
36
+
37
+ # config/events/internal_events.rb
38
+ on_event 'order.created' do
39
+ perform Orders::Handlers::Created
40
+ perform Notifications::Handlers::OrderCreated
41
+ end
42
+ ```
43
+
44
+ Publish from anywhere:
45
+
46
+ ```ruby
47
+ NtiEventBus.publish('order.created', { order_id: order.id })
48
+ ```
49
+
50
+ Write handlers by subclassing `HandlerBase`:
51
+
52
+ ```ruby
53
+ module Orders::Handlers
54
+ class Created < NtiEventBus::HandlerBase
55
+ def call
56
+ order_id = payload.fetch(:order_id) # payload is an indifferent-access, frozen hash
57
+ # ...
58
+ end
59
+ end
60
+ end
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ ```ruby
66
+ NtiEventBus.configure do |config|
67
+ config.root_events_file = Rails.root.join('config/events.rb').to_s # defaulted by the Railtie
68
+ config.events_dir = Rails.root.join('config/events').to_s # defaulted by the Railtie
69
+ config.queue_name = :events # ActiveJob queue (default)
70
+ config.dispatcher = NtiEventBus::Dispatchers::ActiveJob.new # default; async
71
+ config.logger = Rails.logger # optional
72
+ end
73
+ ```
74
+
75
+ ### Dispatchers
76
+
77
+ - `NtiEventBus::Dispatchers::ActiveJob` (default) — enqueues `HandleEventJob` on `config.queue_name`.
78
+ - `NtiEventBus::Dispatchers::Inline` — runs the handler synchronously in-process (tests / non-ActiveJob hosts).
79
+ - Any object responding to `#call(handler_name, payload)` may be used.
80
+
81
+ ## Non-Rails usage
82
+
83
+ ```ruby
84
+ NtiEventBus.configure do |config|
85
+ config.root_events_file = '/path/to/events.rb'
86
+ config.events_dir = '/path/to/events'
87
+ end
88
+ NtiEventBus.setup! # build the registry
89
+ NtiEventBus.publish('order.created', { order_id: 1 })
90
+ ```
91
+
92
+ ## Thread-safety & reloads
93
+
94
+ `setup!` builds a fresh `Registry`, deep-freezes it, and atomically swaps it in under a mutex. Readers always see a fully-frozen registry (the previous one or the new one), so `publish` never locks and never observes a half-built registry.
95
+
96
+ ## Event naming
97
+
98
+ Event names must be non-empty strings in `domain.action` form (they must contain a `.`). This is enforced at subscription time by the DSL.
99
+
100
+ ## License
101
+
102
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiEventBus
4
+ # Runtime configuration. On Rails the Railtie fills in path defaults; hosts may override any of it.
5
+ class Configuration
6
+ # Absolute path to the root events file that is evaluated by `setup!` (e.g. config/events.rb).
7
+ attr_accessor :root_events_file
8
+ # Absolute path to the directory that `draw` resolves nested event files from (e.g. config/events).
9
+ attr_accessor :events_dir
10
+ # ActiveJob queue used by the default dispatcher.
11
+ attr_accessor :queue_name
12
+ # Optional logger; when set, receives debug lines from the bus.
13
+ attr_accessor :logger
14
+
15
+ attr_writer :dispatcher
16
+
17
+ def initialize
18
+ @queue_name = :events
19
+ @root_events_file = nil
20
+ @events_dir = nil
21
+ @logger = nil
22
+ @dispatcher = nil
23
+ end
24
+
25
+ # Object responding to `#call(handler_name, payload)`. Defaults to asynchronous ActiveJob dispatch.
26
+ def dispatcher
27
+ @dispatcher ||= Dispatchers::ActiveJob.new
28
+ end
29
+
30
+ def root_events_file!
31
+ root_events_file ||
32
+ raise(NotConfiguredError, 'NtiEventBus.configuration.root_events_file must be set')
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiEventBus
4
+ # Strategies for turning a resolved (handler_name, payload) pair into actual handler execution.
5
+ # A dispatcher is any object responding to `#call(handler_name, payload)`.
6
+ module Dispatchers
7
+ # Default. Enqueues handler execution asynchronously via ActiveJob (Solid Queue, Sidekiq, ...).
8
+ class ActiveJob
9
+ def call(handler_name, payload)
10
+ NtiEventBus::HandleEventJob.perform_later(handler_name, payload)
11
+ end
12
+ end
13
+
14
+ # Runs the handler synchronously, in-process. Useful for tests or hosts without a job backend.
15
+ class Inline
16
+ def call(handler_name, payload)
17
+ NtiEventBus::HandleEventJob.perform_now(handler_name, payload)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiEventBus
4
+ # Evaluation context for event-definition files.
5
+ #
6
+ # A root file is `instance_eval`ed against a DSL instance; it may `draw` additional files from the
7
+ # configured `events_dir`, and declares subscriptions with `on_event` / `perform`:
8
+ #
9
+ # on_event 'order.created' do
10
+ # perform Orders::Handlers::Created
11
+ # end
12
+ class DSL
13
+ def initialize(registry, events_dir)
14
+ @registry = registry
15
+ @events_dir = events_dir
16
+ @current_event_name = nil
17
+ end
18
+
19
+ def on_event(event_name, &block)
20
+ raise ArgumentError, 'Nested `on_event` not allowed' if @current_event_name
21
+ raise ArgumentError, 'Block required for `on_event`' unless block
22
+
23
+ event_name = normalize_event!(event_name)
24
+
25
+ @current_event_name = event_name
26
+ instance_eval(&block)
27
+ ensure
28
+ @current_event_name = nil
29
+ end
30
+
31
+ def perform(handlers)
32
+ raise ArgumentError, 'perform must be inside `on_event`' unless @current_event_name
33
+
34
+ Array(handlers).each do |handler|
35
+ @registry.add(@current_event_name, handler)
36
+ end
37
+ end
38
+
39
+ def draw(name)
40
+ raise NotConfiguredError, 'NtiEventBus.configuration.events_dir must be set' unless @events_dir
41
+
42
+ path = File.join(@events_dir.to_s, "#{name}.rb")
43
+ raise LoadError, "Event file not found: #{path}" unless File.exist?(path)
44
+
45
+ instance_eval(File.read(path), path.to_s)
46
+ end
47
+
48
+ private
49
+
50
+ def normalize_event!(event_name)
51
+ raise ArgumentError, 'event_name must be a String' unless event_name.is_a?(String)
52
+
53
+ event_name = event_name.strip
54
+ raise ArgumentError, 'event_name cannot be empty' if event_name.empty?
55
+
56
+ # enforce naming convention: domain.action
57
+ raise ArgumentError, "event_name must follow 'domain.action' format" unless event_name.include?('.')
58
+
59
+ event_name.freeze
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+ require 'active_support/core_ext/string/inflections'
5
+
6
+ module NtiEventBus
7
+ # ActiveJob that runs a single handler for a published event.
8
+ #
9
+ # The handler is passed by name (a String) rather than as a class so the argument serializes
10
+ # cleanly and is re-resolved to the current class version at run time (reload-safe). The queue is
11
+ # read from configuration at enqueue time so hosts can route event work to a dedicated queue.
12
+ class HandleEventJob < ::ActiveJob::Base
13
+ queue_as { NtiEventBus.configuration.queue_name }
14
+
15
+ def perform(handler_name, payload)
16
+ handler = handler_name.constantize
17
+
18
+ handler.new(payload).call
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/indifferent_access'
4
+
5
+ module NtiEventBus
6
+ # Base class for all event handlers.
7
+ #
8
+ # A handler lives inside the *subscribing* module and is responsible for:
9
+ # 1. Extracting what it needs from the raw event payload
10
+ # 2. Doing any lookups / transformations
11
+ # 3. Calling the module's Main interactor (or service) with normalized params
12
+ #
13
+ # Subclasses must implement #call.
14
+ #
15
+ # Example:
16
+ # class CreateAuditLog::Handlers::OrderVoided < NtiEventBus::HandlerBase
17
+ # def call
18
+ # CreateAuditLog::Main.call(
19
+ # cashier_id: payload.fetch(:cashier_id),
20
+ # ...
21
+ # )
22
+ # end
23
+ # end
24
+ class HandlerBase
25
+ attr_reader :payload
26
+
27
+ def initialize(payload)
28
+ @payload = payload.with_indifferent_access.freeze
29
+ end
30
+
31
+ def call
32
+ raise NotImplementedError, "#{self.class}#call must be implemented"
33
+ end
34
+
35
+ private
36
+
37
+ # Convenience — fetch multiple required keys at once.
38
+ # Raises KeyError with a clear message if any are missing.
39
+ #
40
+ # cashier_id, order_id = require_keys(:cashier_id, :order_id)
41
+ #
42
+ def require_keys(*keys)
43
+ keys.map { |key| payload.fetch(key) }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module NtiEventBus
6
+ # Rails integration: sets sensible event-file path defaults and (re)builds the registry whenever the
7
+ # framework prepares the app. `to_prepare` runs once during boot in production and on every code
8
+ # reload in development, so event-file edits are picked up and stale handler references are released.
9
+ class Railtie < ::Rails::Railtie
10
+ initializer 'nti_event_bus.set_defaults' do
11
+ NtiEventBus.configure do |config|
12
+ config.root_events_file ||= Rails.root.join('config/events.rb').to_s
13
+ config.events_dir ||= Rails.root.join('config/events').to_s
14
+ end
15
+ end
16
+
17
+ config.to_prepare do
18
+ NtiEventBus.setup!
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiEventBus
4
+ # Maps event names to the ordered, de-duplicated list of handler classes subscribed to them.
5
+ #
6
+ # Built once during `NtiEventBus.setup!` and then deep-frozen via `#finalize!`, which makes reads
7
+ # (`#handlers_for`) lock-free and allocation-free on the hot path.
8
+ class Registry
9
+ EMPTY = [].freeze
10
+
11
+ def initialize
12
+ @handlers = Hash.new { |hash, key| hash[key] = [] }
13
+ end
14
+
15
+ def add(event_name, handler)
16
+ list = @handlers[event_name]
17
+ list << handler unless list.include?(handler)
18
+ end
19
+
20
+ def finalize!
21
+ @handlers.each_value(&:freeze)
22
+ @handlers.freeze
23
+ self
24
+ end
25
+
26
+ def handlers_for(event_name)
27
+ @handlers.fetch(event_name, EMPTY)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiEventBus
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/indifferent_access'
4
+ require 'active_support/core_ext/string/inflections'
5
+
6
+ require_relative 'nti_event_bus/version'
7
+ require_relative 'nti_event_bus/configuration'
8
+ require_relative 'nti_event_bus/registry'
9
+ require_relative 'nti_event_bus/dsl'
10
+ require_relative 'nti_event_bus/handler_base'
11
+ require_relative 'nti_event_bus/dispatchers'
12
+ require_relative 'nti_event_bus/handle_event_job'
13
+ require_relative 'nti_event_bus/railtie' if defined?(Rails::Railtie)
14
+
15
+ # Publish/subscribe event bus.
16
+ #
17
+ # NtiEventBus.publish('order.created', { order_id: 1 })
18
+ #
19
+ # Handlers are declared in event files (see NtiEventBus::DSL) and executed asynchronously via a
20
+ # configurable dispatcher (see NtiEventBus::Dispatchers). The subscription registry is built once by
21
+ # `setup!`, deep-frozen, and read lock-free on the publish path.
22
+ module NtiEventBus
23
+ class Error < StandardError; end
24
+
25
+ # Raised when the bus is used before `setup!` has run or when required configuration is missing.
26
+ class NotConfiguredError < Error; end
27
+
28
+ # Guards registry (re)builds; reads of the frozen registry never take this lock.
29
+ SETUP_MUTEX = Mutex.new
30
+
31
+ class << self
32
+ def configure
33
+ yield(configuration) if block_given?
34
+ configuration
35
+ end
36
+
37
+ def configuration
38
+ @configuration ||= Configuration.new
39
+ end
40
+
41
+ # Resets configuration to defaults. Intended for test suites.
42
+ def reset_configuration!
43
+ @configuration = Configuration.new
44
+ end
45
+
46
+ # Build the subscription registry from the configured event files and atomically install it.
47
+ #
48
+ # A fresh Registry is built, deep-frozen, then swapped in under SETUP_MUTEX. Readers always see a
49
+ # fully-frozen registry (the previous one or the new one) — never a half-built one.
50
+ def setup!
51
+ SETUP_MUTEX.synchronize do
52
+ registry = Registry.new
53
+ path = configuration.root_events_file!
54
+ DSL.new(registry, configuration.events_dir).instance_eval(File.read(path), path.to_s)
55
+ registry.finalize!
56
+
57
+ configuration.logger&.debug { "[NtiEventBus] registry built from #{path}" }
58
+ @registry = registry
59
+ end
60
+ self
61
+ end
62
+
63
+ def registry
64
+ @registry || raise(NotConfiguredError, 'NtiEventBus.setup! has not been run')
65
+ end
66
+
67
+ # Fan an event out to every subscribed handler via the configured dispatcher.
68
+ # Returns nil. Unknown events (no handlers) are a no-op.
69
+ def publish(event_name, payload)
70
+ handlers = registry.handlers_for(event_name)
71
+ return if handlers.empty?
72
+
73
+ dispatcher = configuration.dispatcher
74
+ handlers.each { |handler| dispatcher.call(handler.name, payload) }
75
+ nil
76
+ end
77
+ end
78
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nti_event_bus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Den Meralpis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: NtiEventBus is a lightweight publish/subscribe event bus. Declare `domain.action`
41
+ subscriptions in a small DSL, publish events, and fan them out to handler classes
42
+ asynchronously via ActiveJob. The subscription registry is built once and deep-frozen
43
+ for lock-free reads, so publishing adds no locking and no unbounded state.
44
+ email:
45
+ - denmark@nueca.com.ph
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - lib/nti_event_bus.rb
55
+ - lib/nti_event_bus/configuration.rb
56
+ - lib/nti_event_bus/dispatchers.rb
57
+ - lib/nti_event_bus/dsl.rb
58
+ - lib/nti_event_bus/handle_event_job.rb
59
+ - lib/nti_event_bus/handler_base.rb
60
+ - lib/nti_event_bus/railtie.rb
61
+ - lib/nti_event_bus/registry.rb
62
+ - lib/nti_event_bus/version.rb
63
+ homepage: https://github.com/nueca-tech/nti_event_bus
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ allowed_push_host: https://rubygems.org
68
+ homepage_uri: https://github.com/nueca-tech/nti_event_bus
69
+ source_code_uri: https://github.com/nueca-tech/nti_event_bus
70
+ changelog_uri: https://github.com/nueca-tech/nti_event_bus/blob/main/CHANGELOG.md
71
+ rubygems_mfa_required: 'true'
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.4.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.10
87
+ specification_version: 4
88
+ summary: A production-grade event bus with a Rails DSL and asynchronous ActiveJob
89
+ dispatch.
90
+ test_files: []