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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +862 -0
- data/Rakefile +8 -0
- data/examples/env.rb +78 -0
- data/examples/tree.rb +221 -0
- data/lib/sourced/component/dsl.rb +26 -0
- data/lib/sourced/component/env_provider.rb +167 -0
- data/lib/sourced/component/errors.rb +19 -0
- data/lib/sourced/component/events.rb +111 -0
- data/lib/sourced/component/graph.rb +60 -0
- data/lib/sourced/component/implementation.rb +83 -0
- data/lib/sourced/component/injector.rb +59 -0
- data/lib/sourced/component/mermaid.rb +36 -0
- data/lib/sourced/component/notifier.rb +44 -0
- data/lib/sourced/component/tree.rb +117 -0
- data/lib/sourced/component/version.rb +7 -0
- data/lib/sourced/component.rb +952 -0
- metadata +100 -0
data/Rakefile
ADDED
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
|