sourced-component 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.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
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
+ task default: :spec
data/examples/env.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run with: bundle exec ruby examples/env.rb
4
+ # Override any variable, ex: APP_PORT=9000 APP_DEBUG=false bundle exec ruby examples/env.rb
5
+
6
+ require 'date'
7
+ require 'bundler/setup'
8
+ require 'sourced/component'
9
+
10
+ T = Sourced::Component::T
11
+
12
+ # Sample ENV, unless already set
13
+ ENV['USER_NAME'] ||= 'Ismael'
14
+ ENV['USER_DOB'] ||= '1977-11-29'
15
+ ENV['USER_EMAIL'] ||= 'ismael@example.com'
16
+ ENV['APP_HOST'] ||= 'localhost'
17
+ ENV['APP_PORT'] ||= '3000'
18
+ ENV['APP_DEBUG'] ||= 'true'
19
+
20
+ # Declared types. ENV values are strings, decoded into these types with Plumb::Codec::Forms
21
+ User = T::Data[name: String, dob: Date]
22
+ AppSettings = T::Data[
23
+ host: String,
24
+ port: Integer,
25
+ debug: T::Boolean,
26
+ workers: T::Integer.default(2) # no APP_WORKERS in ENV: uses the default
27
+ ]
28
+ Shell = T::Data[user: String, home: String]
29
+
30
+ App = Sourced::Component.new
31
+
32
+ App.declare('user.email', T::Email)
33
+ App.declare('app.port', Integer)
34
+ App.declare('user.info', User)
35
+ App.declare('app.settings', AppSettings)
36
+ App.declare('shell', Shell)
37
+
38
+ # Single variables, decoded into each declared type
39
+ App.env('USER_EMAIL' => 'user.email', 'APP_PORT' => 'app.port')
40
+
41
+ # Variables matching a regex, collected into a hash with the match removed,
42
+ # and downcased: USER_NAME => name, USER_DOB => dob
43
+ App.env(:downcase, /^USER_/ => 'user.info', /^APP_/ => 'app.settings')
44
+
45
+ # All variables, downcased: picks up the system's USER and HOME.
46
+ # This is why collecting with a regex matters for anything that isn't meant to read system variables.
47
+ App.env(:downcase, 'shell')
48
+
49
+ App.start!
50
+
51
+ puts "== Components\n\n"
52
+ %w[user.email app.port user.info app.settings shell].each do |key|
53
+ value = App[key]
54
+ puts "#{key}: #{(value.respond_to?(:to_h) ? value.to_h : value).inspect}"
55
+ end
56
+
57
+ puts "\n== Component\n\n"
58
+ puts App.ordered_nodes.map(&:inspect)
59
+
60
+ # Missing or invalid variables fail the boot, naming each variable
61
+ puts "\n== Missing variables\n\n"
62
+
63
+ Broken = Sourced::Component.new
64
+ Broken.declare('payments.settings', T::Data[api_key: String, region: String])
65
+ Broken.declare('payments.webhook', T::String[/\Ahttps:/])
66
+ Broken.env(:downcase, /^PAYMENTS_/ => 'payments.settings')
67
+ Broken.env('PAYMENTS_WEBHOOK_URL' => 'payments.webhook')
68
+
69
+ begin
70
+ Broken.start!
71
+ puts "payments.settings: #{Broken['payments.settings'].to_h.inspect}"
72
+ puts "payments.webhook: #{Broken['payments.webhook'].inspect}"
73
+ rescue Plumb::ParseError => e
74
+ puts "boot failed: #{e.class}\n#{e.message}\n\n"
75
+ puts 'try: PAYMENTS_API_KEY=abc PAYMENTS_REGION=eu PAYMENTS_WEBHOOK_URL=https://example.com bundle exec ruby examples/env.rb'
76
+ end
77
+
78
+ App.teardown!
data/examples/tree.rb ADDED
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run with: bundle exec ruby examples/tree.rb (Ctrl-C to stop), or RUN_FOR=2 bundle exec ruby examples/tree.rb
4
+
5
+ require 'bundler/setup'
6
+ require 'sourced/component'
7
+ require 'logger'
8
+
9
+ T = Sourced::Component::T
10
+
11
+ class FakeDB
12
+ attr_reader :name, :logger
13
+
14
+ def initialize(name, logger:)
15
+ @name = name
16
+ @logger = logger
17
+ end
18
+
19
+ def connect = logger.info("#{name}: connect")
20
+ def disconnect = logger.info("#{name}: disconnect")
21
+ def insert(job) = logger.info("#{name}: insert #{job}")
22
+ end
23
+
24
+ # A long-running component: processes jobs in a background thread.
25
+ # Start hooks run while holding the root's lock, so #start spawns the thread and returns.
26
+ class Worker
27
+ def initialize(db, logger:)
28
+ @db = db
29
+ @logger = logger
30
+ @queue = Queue.new
31
+ @thread = nil
32
+ end
33
+
34
+ def <<(job)
35
+ @queue << job
36
+ self
37
+ end
38
+
39
+ def start
40
+ @thread = Thread.new do
41
+ while (job = @queue.pop) != :stop
42
+ @db.insert(job)
43
+ end
44
+ end
45
+ @logger.info('worker: started')
46
+ end
47
+
48
+ # Process what's queued, then stop
49
+ def stop
50
+ return unless @thread
51
+
52
+ @queue << :stop
53
+ @thread.join
54
+ @logger.info('worker: stopped')
55
+ end
56
+ end
57
+
58
+ # Another long-running component: pushes a job to the worker on an interval
59
+ class Ticker
60
+ def initialize(worker, interval:, logger:, &job)
61
+ @worker = worker
62
+ @interval = interval
63
+ @logger = logger
64
+ @job = job
65
+ @thread = nil
66
+ end
67
+
68
+ def start
69
+ @thread = Thread.new do
70
+ loop do
71
+ sleep @interval
72
+ @worker << @job.call
73
+ end
74
+ end
75
+ @logger.info("ticker: every #{@interval}s")
76
+ end
77
+
78
+ def stop
79
+ @thread&.kill&.join
80
+ @logger.info('ticker: stopped')
81
+ end
82
+ end
83
+
84
+ # ---- A library, with its own root component ------------------------------------
85
+
86
+ Library = Sourced::Component.new
87
+ Library.declare('logger', T::Interface[:info]) { Logger.new($stdout, progname: 'sourced') }
88
+ Library.declare('db', FakeDB)
89
+ Library.component!('db', ['logger']) do
90
+ build { |logger| FakeDB.new('sourced-db', logger:) }
91
+ start { |db, _| db.connect }
92
+ teardown { |db| db.disconnect }
93
+ end
94
+
95
+ # ---- An app, mounting it -----------------------------------------------------------
96
+
97
+ App = Sourced::Component.new
98
+ App.declare('logger', T::Interface[:info]) { Logger.new($stdout, progname: 'app') }
99
+ App.mount('sourced', Library)
100
+
101
+ # Re-implement the library's db, with the app's own logger. Deps are relative to App
102
+ App.component!('sourced.db', ['logger']) do
103
+ build { |logger| FakeDB.new('app-db', logger:) }
104
+ start { |db, _| db.connect }
105
+ teardown { |db| db.disconnect }
106
+ end
107
+
108
+ # Nested declarations: App owns 'cache' and 'cache.redis', so it can keep declaring under them
109
+ App.declare('cache.redis', String)
110
+ App.declare('cache.redis.pool', Integer)
111
+
112
+ # Configs: components with only a build step. config! is a singleton
113
+ App.config!('cache.redis') { 'redis://localhost' }
114
+ App.config!('cache.redis.pool', ['sourced.settings.retries']) { |retries| retries + 2 }
115
+
116
+ # A dynamic config, built on each read
117
+ counter = 0
118
+ App.declare('request_id', String)
119
+ App.config('request_id') { "req-#{counter += 1}" }
120
+
121
+ # The library can still declare more after being mounted; App's index picks them up
122
+ Library.declare('settings.retries', Integer) { 3 }
123
+
124
+ # The library's classes inject from the library's own root component.
125
+ # Defined before the component is built: values are read on instantiation
126
+ class Dispatcher
127
+ include Library.inject('db', 'settings.retries')
128
+
129
+ def dispatch(event) = db.insert(event)
130
+ end
131
+
132
+ # Long-running components. The ticker depends on the worker, so it starts after it and is torn down before it
133
+ App.declare('worker', Worker)
134
+ App.component!('worker', ['sourced.db', 'logger']) do
135
+ build { |db, logger| Worker.new(db, logger:) }
136
+ start { |worker, _| worker.start }
137
+ teardown { |worker| worker.stop }
138
+ end
139
+
140
+ App.declare('ticker', Ticker)
141
+ App.component!('ticker', ['worker', 'logger']) do
142
+ # Read the dynamic request_id on each tick: a fresh value every time
143
+ build { |worker, logger| Ticker.new(worker, interval: 0.5, logger:) { "job #{App['request_id']}" } }
144
+ start { |ticker, _| ticker.start }
145
+ teardown { |ticker| ticker.stop }
146
+ end
147
+
148
+ puts '== Ownership'
149
+ begin
150
+ App.declare('sourced.extra', String)
151
+ rescue Sourced::Component::OwnershipError => e
152
+ puts "App.declare('sourced.extra') => #{e.class}: #{e.message}"
153
+ end
154
+
155
+ puts "\n== Index"
156
+ puts App.index.keys.join(', ')
157
+
158
+ # Lifecycle events go to the root's notifier, for every component in the tree
159
+ App.notifier.subscribe('components.built') do |event|
160
+ puts format('built %-26s (%.3fms)', event.payload.key, event.payload.duration * 1000)
161
+ end
162
+ App.notifier.subscribe('components.failed') do |event|
163
+ puts "failed #{event.payload.key} (#{event.payload.stage}): #{event.payload.error_class}: #{event.payload.error_message}"
164
+ end
165
+ App.notifier.subscribe('components.torn_down') do |event|
166
+ puts format('torn_down %-26s (%.3fms)', event.payload.key, event.payload.duration * 1000)
167
+ end
168
+
169
+ puts "\n== Boot"
170
+ begin
171
+ Library.start!
172
+ rescue Sourced::Component::SubcomponentError => e
173
+ puts "Library.start! => #{e.class}: #{e.message}"
174
+ end
175
+ App.start!
176
+ puts App.ordered_nodes.map(&:inspect)
177
+
178
+ puts "\n== Tree\n\n"
179
+ puts App.tree
180
+ puts App.tree.to_mermaid
181
+
182
+ puts "\n== Graph (paste into https://mermaid.live)\n\n"
183
+ puts App.graph.to_mermaid
184
+
185
+ puts "\n== Read"
186
+ puts "App['sourced.db'].name => #{App['sourced.db'].name}"
187
+ puts "Library['db'].name => #{Library['db'].name} (the app's override, seen by the library)"
188
+ puts "same object => #{App['sourced.db'].equal?(Library['db'])}"
189
+ puts "Library['db'].logger => #{Library['db'].logger.progname}"
190
+ puts "Dispatcher.new.db.name => #{Dispatcher.new.db.name} (injected from the library's component)"
191
+ puts "Dispatcher.new.retries => #{Dispatcher.new.retries}"
192
+ puts "Dispatcher.new(db: ...).db => #{Dispatcher.new(db: :fake).db}"
193
+ puts "App['sourced.settings.retries'] => #{App['sourced.settings.retries']}"
194
+ puts "App['cache.redis.pool'] => #{App['cache.redis.pool']}"
195
+ puts "App['request_id'] x2 => #{App['request_id']}, #{App['request_id']}"
196
+ begin
197
+ App['cache']
198
+ rescue Sourced::Component::UndeclaredComponentError => e
199
+ puts "App['cache'] => #{e.class}: #{e.message}"
200
+ end
201
+
202
+ begin
203
+ App.declare('late', String)
204
+ rescue Sourced::Component::LockedComponentError => e
205
+ puts "App.declare('late') => #{e.class}: #{e.message}"
206
+ end
207
+
208
+ # Run until Ctrl-C or SIGTERM, or for RUN_FOR seconds, ex. RUN_FOR=2 bundle exec ruby examples/tree.rb
209
+ # Components run in their own threads, so the main thread only waits. Teardown always runs.
210
+ run_for = ENV['RUN_FOR']&.to_f
211
+ puts "\n== Running #{run_for ? "for #{run_for}s" : '(Ctrl-C to stop)'}"
212
+ trap('TERM') { Thread.main.raise(Interrupt) }
213
+ begin
214
+ run_for ? sleep(run_for) : sleep
215
+ rescue Interrupt
216
+ puts
217
+ ensure
218
+ puts "\n== Teardown"
219
+ App.teardown!
220
+ puts "App: #{App.boot_status}, worker: #{App.node('worker').status}, sourced.db: #{App.node('sourced.db').status}"
221
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # Lifecycle hooks a component can implement, in the order they run
6
+ HOOKS = %i[prepare build start stop teardown].freeze
7
+
8
+ CallableInterface = Plumb::Types::Interface[:call]
9
+
10
+ # Records lifecycle hooks from a component block
11
+ class DSL
12
+ attr_reader :hooks
13
+
14
+ def initialize
15
+ @hooks = HOOKS.to_h { |name| [name, []] }
16
+ end
17
+
18
+ HOOKS.each do |name|
19
+ define_method(name) do |callable = nil, &block|
20
+ @hooks[name] << CallableInterface.parse(callable || block)
21
+ self
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # Builds a component from ENV, decoding values into the node's type with Plumb::Codec::Forms,
6
+ # the codec for string input (ex. '30' => 30, '1977-11-29' => Date).
7
+ # ENVProvider.new('USER_EMAIL') # a single variable
8
+ # ENVProvider.new(/^USER_/) # matching variables into a hash, with the match removed: USER_NAME => NAME
9
+ # ENVProvider.new(/^USER_/, :downcase) # ... and modified: USER_NAME => name
10
+ # ENVProvider.new # all variables into a hash, same as ENVProvider.new(ENVProvider::ALL)
11
+ # A component provider (see Component#component!), and what Component#env uses:
12
+ # comp.component!('user.email', ENVProvider.new('USER_EMAIL'))
13
+ # comp.component!('everything', ENVProvider) # all variables
14
+ class ENVProvider
15
+ T = Plumb::Types
16
+
17
+ # Raised when ENV variables are missing or invalid, naming each variable.
18
+ # A Plumb::ParseError, like any other type mismatch.
19
+ Error = Class.new(Plumb::ParseError)
20
+
21
+ # Matches every variable, without removing anything from their names
22
+ ALL = /\A/
23
+
24
+ # Applied to collected variable names, in order, after the match is removed
25
+ MODIFIERS = { downcase: :downcase.to_proc }.freeze
26
+
27
+ # The provider interface, collecting all variables
28
+ def self.builder_for(node) = new.builder_for(node)
29
+
30
+ def self.error_text(error) = error.is_a?(::String) ? error : error.inspect
31
+
32
+ # Whether a type accepts a hash, from what it consumes: Any, Hash types and Data structs,
33
+ # or a union or wrapper (ex. nullable, default) with a branch that does.
34
+ def self.takes_hash?(type)
35
+ type = type.input_type if type.respond_to?(:input_type)
36
+ return true if type.is_a?(Plumb::AnyClass) || type.subtype_of?(T::Hash)
37
+
38
+ case type
39
+ when Plumb::Disjunction, Plumb::Policy then type.children.any? { |child| takes_hash?(child) }
40
+ when Plumb::And then takes_hash?(type.children.first)
41
+ else false
42
+ end
43
+ end
44
+
45
+ attr_reader :source, :modifiers
46
+
47
+ # source: a variable name, or a regex to collect variables with
48
+ # modifiers: ex. :downcase. Only when collecting with a regex
49
+ def initialize(source = ALL, *modifiers)
50
+ case source
51
+ when ::String
52
+ if modifiers.any?
53
+ raise ArgumentError, "ENV modifiers (#{modifiers.join(', ')}) can only be used when collecting variables with a regex"
54
+ end
55
+ when ::Regexp
56
+ unknown = modifiers - MODIFIERS.keys
57
+ if unknown.any?
58
+ raise ArgumentError, "unknown ENV modifiers: #{unknown.join(', ')}. Supported: #{MODIFIERS.keys.join(', ')}"
59
+ end
60
+ else
61
+ raise ArgumentError, "an ENV source must be a variable name or a regex, got #{source.inspect}"
62
+ end
63
+
64
+ @source = source.dup.freeze
65
+ @modifiers = modifiers.uniq.freeze
66
+ end
67
+
68
+ # Regex sources collect variables into a hash, so the node's type must take one
69
+ def check!(node)
70
+ return self if source.is_a?(::String) || ENVProvider.takes_hash?(node.type)
71
+
72
+ raise ArgumentError, "#{node.path}: ENV variables matching #{source.inspect} are collected into a hash, " \
73
+ "but #{node.type.inspect} doesn't take one. Declare a Hash or Data struct type, " \
74
+ "or map a single variable, ex. env('VAR_NAME' => '#{node.path}')"
75
+ end
76
+
77
+ # The provider interface: a callable that reads and decodes ENV for a node, to use as its build step.
78
+ # Checks the node's type first.
79
+ def builder_for(node)
80
+ check!(node)
81
+ # Any (no declared type) takes raw strings. Codec::Forms can't decode into it
82
+ type = node.type
83
+ decoder = type.is_a?(Plumb::AnyClass) ? type : Plumb::Codec::Forms >> type
84
+ if source.is_a?(::String)
85
+ VariableBuilder.new(node, source, decoder)
86
+ else
87
+ # Collected names are strings. Codec::Forms decodes them into the type's keys (ex. 'name' => :name)
88
+ CollectionBuilder.new(node, source, modifiers, decoder)
89
+ end
90
+ end
91
+
92
+ def inspect = "#<#{self.class} #{[source.inspect, *modifiers].join(' ')}>"
93
+
94
+ # Builds from a single variable. Reads ENV on each call.
95
+ class VariableBuilder
96
+ def initialize(node, name, decoder)
97
+ @node = node
98
+ @name = name
99
+ @decoder = decoder
100
+ end
101
+
102
+ # ex. invalid ENV for user.email: USER_EMAIL is invalid: Must match /.../
103
+ # Values are left out, as ENV often holds secrets.
104
+ def call(*_)
105
+ result = @decoder.resolve(ENV[@name])
106
+ return result.value if result.valid?
107
+
108
+ detail = ENV.key?(@name) ? "is invalid: #{ENVProvider.error_text(result.errors)}" : 'is missing'
109
+ raise Error, "invalid ENV for #{@node.path}: #{@name} #{detail}"
110
+ end
111
+ end
112
+
113
+ # Builds from the variables matching a regex, collected into a hash. Reads ENV on each call.
114
+ class CollectionBuilder
115
+ def initialize(node, regex, modifiers, decoder)
116
+ @node = node
117
+ @regex = regex
118
+ @modifiers = modifiers.map { |name| MODIFIERS.fetch(name) }
119
+ @decoder = decoder
120
+ end
121
+
122
+ def call(*_)
123
+ vars, names = collect
124
+ result = @decoder.resolve(vars)
125
+ raise Error, error_message(result.errors, vars, names) unless result.valid?
126
+
127
+ result.value
128
+ end
129
+
130
+ # Matching variables, as { collected name => value }, and { collected name => ENV name }.
131
+ # The match is removed from names, then modifiers are applied. Names left empty are skipped.
132
+ private def collect
133
+ ENV.each_with_object([{}, {}]) do |(name, value), (vars, names)|
134
+ next unless @regex.match?(name)
135
+
136
+ collected = @modifiers.reduce(name.sub(@regex, '')) { |n, modifier| modifier.call(n) }
137
+ next if collected.empty?
138
+
139
+ vars[collected] = value
140
+ names[collected] = name
141
+ end
142
+ end
143
+
144
+ # ex.
145
+ # invalid ENV for user.info:
146
+ # USER_DOB is invalid: Must match /\A\d{4}-\d{2}-\d{2}\z/
147
+ # email is missing from ENV variables matching /^USER_/
148
+ # Values are left out, as ENV often holds secrets.
149
+ private def error_message(errors, vars, names)
150
+ return "invalid ENV for #{@node.path}: #{ENVProvider.error_text(errors)}" unless errors.is_a?(::Hash)
151
+
152
+ lines = errors.map do |attribute, error|
153
+ attribute = attribute.to_s
154
+ if names.key?(attribute)
155
+ " #{names[attribute]} is invalid: #{ENVProvider.error_text(error)}"
156
+ else
157
+ line = " #{attribute} is missing from ENV variables matching #{@regex.inspect}"
158
+ near = vars.keys.find { |collected| collected.casecmp?(attribute) }
159
+ near ? "#{line} (found #{names[near]}, try :downcase)" : line
160
+ end
161
+ end
162
+ ["invalid ENV for #{@node.path}:", *lines].join("\n")
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ ComponentError = Class.new(StandardError)
6
+ DeclarationOverrideError = Class.new(ComponentError)
7
+ OwnershipError = Class.new(ComponentError)
8
+ LockedComponentError = Class.new(ComponentError)
9
+ SubcomponentError = Class.new(ComponentError)
10
+ UndeclaredComponentError = Class.new(ComponentError)
11
+ UnimplementedComponentError = Class.new(ComponentError)
12
+ MissingDependencyError = Class.new(ComponentError)
13
+ CircularDependencyError = Class.new(ComponentError)
14
+ NotBuiltError = Class.new(ComponentError)
15
+ TornDownError = Class.new(ComponentError)
16
+ NotStartedError = Class.new(ComponentError)
17
+ InjectionError = Class.new(ComponentError)
18
+ end
19
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sourced/message'
4
+
5
+ module Sourced
6
+ class Component
7
+ # The parent class of all lifecycle events published to a component's notifier.
8
+ # Events are Sourced::Message structs, so they build their own registry
9
+ # (Component::Event.registry), and can be serialized with Sourced::Message codecs.
10
+ #
11
+ # Every event's payload carries the process, thread and fiber it was published from:
12
+ # event.type # => 'components.built'
13
+ # event.created_at # => Time
14
+ # event.payload.pid # => 12345
15
+ # event.payload.key # => 'sourced.db' (component events)
16
+ #
17
+ # Payloads only hold JSON-friendly values: Sourced::Message's registry is shared by
18
+ # every message type in the process, so these events are compiled by any
19
+ # Sourced::Message::JSONCodec. That's why failures carry the error's class, message
20
+ # and backtrace, rather than the exception itself.
21
+ class Event < Sourced::Message
22
+ # Define an event type, with the runtime ids every event carries
23
+ def self.define(type_str, &block)
24
+ super(type_str) do
25
+ attribute :pid, Integer
26
+ attribute :thread_id, Integer
27
+ attribute :fiber_id, Integer
28
+ class_eval(&block) if block
29
+ end
30
+ end
31
+ end
32
+
33
+ # Each event class has a #type string, which can be used to subscribe to it,
34
+ # ex. notifier.subscribe('components.built') { |event| ... }
35
+ # Events::RootEvent and Events::ComponentEvent can be used to subscribe to all events of a kind.
36
+ module Events
37
+ # Events about the whole tree, published by its root
38
+ class RootEvent < Event; end
39
+
40
+ # Events about a component, with its full path as the payload's key
41
+ class ComponentEvent < Event
42
+ def self.define(type_str, &block)
43
+ super(type_str) do
44
+ attribute :key, String
45
+ class_eval(&block) if block
46
+ end
47
+ end
48
+ end
49
+
50
+ Completed = proc { attribute :duration, Float }
51
+ Failed = proc do
52
+ attribute :stage, Symbol # :prepare, :build, :start, :stop or :teardown
53
+ attribute :error_class, String
54
+ attribute :error_message, String
55
+ attribute :backtrace, Plumb::Types::Array[String]
56
+ end
57
+
58
+ RootPreparing = RootEvent.define('root.preparing')
59
+ RootPrepared = RootEvent.define('root.prepared', &Completed)
60
+ RootBuilding = RootEvent.define('root.building')
61
+ RootBuilt = RootEvent.define('root.built', &Completed)
62
+ RootStarting = RootEvent.define('root.starting')
63
+ RootStarted = RootEvent.define('root.started', &Completed)
64
+ RootTearingDown = RootEvent.define('root.tearing_down')
65
+ RootTornDown = RootEvent.define('root.torn_down', &Completed)
66
+ RootFailed = RootEvent.define('root.failed', &Failed)
67
+
68
+ ComponentDeclared = ComponentEvent.define('components.declared') do
69
+ attribute :type_name, String
70
+ end
71
+ ComponentImplemented = ComponentEvent.define('components.implemented') do
72
+ attribute :mode, Symbol
73
+ attribute :deps, Plumb::Types::Array[String] # relative to the implementer
74
+ attribute :implementer, Plumb::Types::String.nullable # the implementer's full path. nil for the root
75
+ attribute :override, Plumb::Types::Boolean # whether it replaced a previous implementation
76
+ end
77
+ ComponentDeferred = ComponentEvent.define('components.deferred') do
78
+ attribute :deferrer, Plumb::Types::String.nullable # the full path of the component that deferred it. nil for the root
79
+ end
80
+ ComponentPreparing = ComponentEvent.define('components.preparing')
81
+ ComponentPrepared = ComponentEvent.define('components.prepared', &Completed)
82
+ ComponentBuilding = ComponentEvent.define('components.building')
83
+ ComponentBuilt = ComponentEvent.define('components.built', &Completed)
84
+ ComponentStarting = ComponentEvent.define('components.starting')
85
+ ComponentStarted = ComponentEvent.define('components.started', &Completed)
86
+ ComponentStopping = ComponentEvent.define('components.stopping')
87
+ ComponentStopped = ComponentEvent.define('components.stopped', &Completed)
88
+ ComponentTearingDown = ComponentEvent.define('components.tearing_down')
89
+ ComponentTornDown = ComponentEvent.define('components.torn_down', &Completed)
90
+ ComponentFailed = ComponentEvent.define('components.failed', &Failed)
91
+ end
92
+
93
+ # Lifecycle stages, the status each one moves to, and their events
94
+ STAGES = { prepare: :prepared, build: :built, start: :started, stop: :stopped, teardown: :torn_down }.freeze
95
+
96
+ ROOT_EVENTS = {
97
+ prepare: [Events::RootPreparing, Events::RootPrepared],
98
+ build: [Events::RootBuilding, Events::RootBuilt],
99
+ start: [Events::RootStarting, Events::RootStarted],
100
+ teardown: [Events::RootTearingDown, Events::RootTornDown]
101
+ }.freeze
102
+
103
+ COMPONENT_EVENTS = {
104
+ prepare: [Events::ComponentPreparing, Events::ComponentPrepared],
105
+ build: [Events::ComponentBuilding, Events::ComponentBuilt],
106
+ start: [Events::ComponentStarting, Events::ComponentStarted],
107
+ stop: [Events::ComponentStopping, Events::ComponentStopped],
108
+ teardown: [Events::ComponentTearingDown, Events::ComponentTornDown]
109
+ }.freeze
110
+ end
111
+ end