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.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # Returned by Component#graph. #components are hashes describing each declared component, by full path:
6
+ # {
7
+ # key: 'sourced.db', # the component's full path from the root
8
+ # type: <Plumb type>,
9
+ # type_name: 'Interface[exec]',
10
+ # implemented: true,
11
+ # mode: :singleton, # nil if not implemented
12
+ # status: :built,
13
+ # deps: ['logger'], # full paths of the components this one depends on
14
+ # missing: [], # deps that aren't declared, or are namespaces without an implementation
15
+ # dependents: ['app'], # components in the graph that depend on this one
16
+ # provider: <provider> # the provider, or the block of #config!/#config. nil for blocks of hooks
17
+ # }
18
+ class Graph < Data.define(:status, :components)
19
+ # Mermaid classes for component statuses, plus declared-but-unimplemented components,
20
+ # missing dependencies, and dependencies outside the graph (ex. a library's graph, with an app's overrides)
21
+ MERMAID_CLASSES = Mermaid::STATUS_CLASSES.merge(
22
+ missing: 'fill:#fee2e2,stroke:#dc2626,stroke-dasharray:4 3',
23
+ external: 'fill:#ffffff,stroke:#a1a1aa,stroke-dasharray:2 2'
24
+ ).freeze
25
+
26
+ # A Mermaid flowchart of the dependency graph.
27
+ # Edges point from each dependency to its dependents (build and start order).
28
+ # Singletons are rectangles, dynamic components are rounded, and nodes are styled by status.
29
+ # Unimplemented components, missing dependencies and dependencies outside the graph are included, with dashed borders.
30
+ def to_mermaid
31
+ ids = {}
32
+ id_for = ->(key) { ids[key] ||= "c#{ids.size}" }
33
+ lines = ['flowchart LR']
34
+
35
+ components.each do |node|
36
+ details = Mermaid.details(node[:implemented], node[:mode], node[:status], node[:deferred])
37
+ label = "#{Mermaid.escape(node[:key])}<br/>#{Mermaid.escape(node[:type_name])}<br/><i>#{details}</i>"
38
+ open, close = Mermaid.component_shape(node[:mode])
39
+ css_class = node[:implemented] ? node[:status] : :unimplemented
40
+ lines << " #{id_for.(node[:key])}#{open}#{label}#{close}:::#{css_class}"
41
+ end
42
+
43
+ keys = components.map { |node| node[:key] }
44
+ missing = components.flat_map { |node| node.fetch(:missing, []) }.uniq
45
+ outside = components.flat_map { |node| node[:deps] }.uniq - keys
46
+ outside.each do |key|
47
+ details, css_class = missing.include?(key) ? ['not declared', :missing] : ['outside this component', :external]
48
+ lines << " #{id_for.(key)}[\"#{Mermaid.escape(key)}<br/><i>#{details}</i>\"]:::#{css_class}"
49
+ end
50
+
51
+ components.each do |node|
52
+ node[:deps].each { |dep| lines << " #{id_for.(dep)} --> #{id_for.(node[:key])}" }
53
+ end
54
+
55
+ lines.concat(Mermaid.class_defs(MERMAID_CLASSES))
56
+ lines.join("\n")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # singleton: built once, on #build!, and memoized
6
+ # dynamic: built on every read
7
+ # alias: reads another component (its only dep). Memoized if that component is, see Component#alias
8
+ MODES = %i[singleton dynamic alias].freeze
9
+
10
+ # How a node is built. Deps are keys relative to the implementer: the component that called #component! or #component.
11
+ # prepare: hooks run with no arguments
12
+ # build: hooks run with dep values. The last result is the node's value
13
+ # start: hooks run with (value, context). Again after each stop, for a component started by key
14
+ # stop: hooks run with (value), when a started component is stopped, or torn down
15
+ # teardown: hooks run with (value), once, when the root is torn down
16
+ # A dep ending in '.*' depends on every component directly under that key, and its value is
17
+ # a hash of their values by key segment, ex. 'reactors.*' => { 'foo' => <Foo>, 'bar' => <Bar> }
18
+ class Implementation
19
+ WILDCARD = /\A[^.*]+(\.[^.*]+)*\.\*\z/
20
+
21
+ # provider: the provider the component was implemented with (see Component#component!), the block of
22
+ # Component#config! and #config, or nil for blocks of hooks
23
+ attr_reader :deps, :implementer, :mode, :provider
24
+
25
+ def self.from_block(deps, implementer:, mode:, &block)
26
+ dsl = DSL.new
27
+ if block
28
+ block.arity > 0 ? block.call(dsl) : dsl.instance_eval(&block)
29
+ end
30
+ new(deps, implementer:, mode:, hooks: dsl.hooks)
31
+ end
32
+
33
+ # An alias of the component at +target+, relative to the implementer: its value is the target's,
34
+ # and it has no other hooks
35
+ def self.alias(target, implementer:)
36
+ target = target.to_s
37
+ raise ArgumentError, "can't alias #{target.inspect}: an alias takes a single component, not a wildcard" if target.include?('*')
38
+
39
+ new([target], implementer:, mode: :alias, hooks: DSL.new.build { |value| value }.hooks)
40
+ end
41
+
42
+ # Hooks that a provider's builder can implement, besides #call (the build step)
43
+ OPTIONAL_HOOKS = %i[prepare start stop teardown].freeze
44
+
45
+ # From a provider's builder: #call(*deps) is the build step, and it can also implement
46
+ # #prepare, #start(value, context), #stop(value) and #teardown(value).
47
+ def self.from_builder(builder, deps, implementer:, mode:, provider: nil)
48
+ dsl = DSL.new
49
+ dsl.build(builder)
50
+ OPTIONAL_HOOKS.each do |name|
51
+ dsl.public_send(name, builder.method(name)) if builder.respond_to?(name)
52
+ end
53
+ new(deps, implementer:, mode:, hooks: dsl.hooks, provider:)
54
+ end
55
+
56
+ def initialize(deps, implementer:, mode:, hooks:, provider: nil)
57
+ raise ArgumentError, "unknown mode #{mode.inspect}, expected one of #{MODES.join(', ')}" unless MODES.include?(mode)
58
+
59
+ @deps = deps.map { |d| d.to_s.freeze }.freeze
60
+ @deps.each do |dep|
61
+ next unless dep.include?('*')
62
+ next if dep.match?(WILDCARD)
63
+
64
+ raise ArgumentError, "invalid dependency #{dep.inspect}: a wildcard must be the last segment, ex. 'reactors.*'"
65
+ end
66
+ @implementer = implementer
67
+ @mode = mode
68
+ @provider = provider
69
+ @hooks = hooks.transform_values(&:freeze).freeze
70
+ end
71
+
72
+ def singleton? = mode == :singleton
73
+ def dynamic? = mode == :dynamic
74
+ def alias? = mode == :alias
75
+
76
+ def prepare = @hooks[:prepare].each(&:call)
77
+ def build(*deps) = @hooks[:build].reduce(nil) { |_, b| b.call(*deps) }
78
+ def start(value, context) = @hooks[:start].each { |b| b.call(value, context) }
79
+ def stop(value) = @hooks[:stop].each { |b| b.call(value) }
80
+ def teardown(value) = @hooks[:teardown].each { |b| b.call(value) }
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # A module that injects components into a class as keyword arguments to #initialize,
6
+ # defaulting to the component's value when the object is instantiated.
7
+ # include App.inject('logger', 'sourced.store' => 'st')
8
+ # Each include prepends its own #initialize, which takes its kwargs and passes the rest on to super,
9
+ # so multiple injections (and the class' own #initialize) compose.
10
+ # Including it raises InjectionError if the class already has a method named like an injected reader.
11
+ # Values are read from the nodes themselves, so a class injecting from a library's component
12
+ # gets the overrides of the application that mounts it.
13
+ class Injector < Module
14
+ attr_reader :names
15
+
16
+ # nodes: { 'component.key' => <Component node> }
17
+ # names: { 'component.key' => :kwarg_name }
18
+ def initialize(nodes, names)
19
+ super()
20
+ @names = names.freeze
21
+
22
+ # Instance variable names are computed once, not on every #new
23
+ entries = names.map { |key, name| [nodes.fetch(key), name, :"@#{name}"] }.freeze
24
+
25
+ initializer = Module.new do
26
+ define_method(:initialize) do |*args, **kwargs, &block|
27
+ entries.each do |node, name, ivar|
28
+ instance_variable_set(ivar, kwargs.key?(name) ? kwargs.delete(name) : node.read)
29
+ end
30
+ super(*args, **kwargs, &block)
31
+ end
32
+ end
33
+
34
+ # Checked before the module is added to the class, so a refused include leaves the class untouched
35
+ define_singleton_method(:append_features) do |base|
36
+ taken = (base.ancestors.grep(Injector) - [self]).flat_map { |i| i.names.values } & names.values
37
+ raise InjectionError, "#{base} already injects #{taken.join(', ')}" if taken.any?
38
+
39
+ # Readers would silently replace these, including private ones (ex. Kernel#format)
40
+ defined = names.values.select { |name| base.method_defined?(name) || base.private_method_defined?(name) }
41
+ if defined.any?
42
+ methods = defined.map { |name| "##{name} (from #{base.instance_method(name).owner})" }
43
+ raise InjectionError, "#{base} already defines #{methods.join(', ')}: " \
44
+ "inject under another name instead, ex. inject('key' => 'other_name')"
45
+ end
46
+
47
+ super(base)
48
+ end
49
+
50
+ define_singleton_method(:included) do |base|
51
+ base.prepend(initializer)
52
+ base.attr_reader(*names.values)
53
+ end
54
+ end
55
+
56
+ def inspect = "#<#{self.class} #{names.map { |key, name| "#{key} => #{name}" }.join(', ')}>"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # Helpers shared by Graph#to_mermaid and Tree#to_mermaid
6
+ module Mermaid
7
+ # Styles for component statuses, and for declared-but-unimplemented components
8
+ STATUS_CLASSES = {
9
+ open: 'fill:#f4f4f5,stroke:#71717a',
10
+ prepared: 'fill:#e0f2fe,stroke:#0284c7',
11
+ built: 'fill:#ede9fe,stroke:#7c3aed',
12
+ started: 'fill:#dcfce7,stroke:#16a34a',
13
+ stopped: 'fill:#ffedd5,stroke:#ea580c',
14
+ torn_down: 'fill:#e4e4e7,stroke:#52525b,color:#52525b',
15
+ unimplemented: 'fill:#fef9c3,stroke:#ca8a04,stroke-dasharray:4 3'
16
+ }.freeze
17
+
18
+ # Escape label text, so type names with brackets, pipes or quotes are safe
19
+ def self.escape(text)
20
+ text.to_s.gsub('&', '#amp;').gsub('"', '#quot;').gsub('<', '#lt;').gsub('>', '#gt;')
21
+ end
22
+
23
+ # ex. 'singleton, started', 'singleton, built, deferred', or 'not implemented'
24
+ def self.details(implemented, mode, status, deferred = false)
25
+ return 'not implemented' unless implemented
26
+
27
+ [mode, status, ('deferred' if deferred)].compact.join(', ')
28
+ end
29
+
30
+ # Open and close brackets: singletons are rectangles, dynamic components are rounded
31
+ def self.component_shape(mode) = mode == :dynamic ? ['(["', '"])'] : ['["', '"]']
32
+
33
+ def self.class_defs(classes) = classes.map { |name, style| " classDef #{name} #{style}" }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # The default notifier. Custom notifiers must implement the same #publish and #subscribe interface.
6
+ # Handlers are called synchronously, in the order they subscribed, by the thread or fiber running the lifecycle step.
7
+ # Errors raised by handlers propagate to the caller.
8
+ class Notifier
9
+ def initialize
10
+ @subscriptions = [].freeze
11
+ @lock = Mutex.new
12
+ end
13
+
14
+ # Subscribe to an event type string (ex. 'components.built'),
15
+ # or an event class, which also matches its subclasses (ex. Events::ComponentEvent for all component events)
16
+ def subscribe(event_class_or_type, &handler)
17
+ raise ArgumentError, 'a handler block is required' unless handler
18
+
19
+ matcher = case event_class_or_type
20
+ when Class
21
+ ->(event) { event.is_a?(event_class_or_type) }
22
+ when String, Symbol
23
+ type = event_class_or_type.to_s
24
+ raise ArgumentError, "unknown event type #{type}" unless Event.registry[type]
25
+
26
+ ->(event) { event.type == type }
27
+ else
28
+ raise ArgumentError, "can't subscribe to #{event_class_or_type.inspect}"
29
+ end
30
+
31
+ # copy-on-write, so publishing never needs the lock
32
+ @lock.synchronize { @subscriptions = [*@subscriptions, [matcher, handler]].freeze }
33
+ self
34
+ end
35
+
36
+ def publish(event)
37
+ @subscriptions.each { |matcher, handler| handler.call(event) if matcher.call(event) }
38
+ self
39
+ end
40
+ end
41
+
42
+ NotifierInterface = Plumb::Types::Interface[:publish, :subscribe]
43
+ end
44
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ # Returned by Component#tree: the tree of components under a component, as nested nodes.
6
+ # Unlike Component#graph (the dependency graph), it shows how components are nested,
7
+ # which components are mounted, and who declared and implemented each node.
8
+ # tree = App.tree
9
+ # tree.status # => :built, the root's status
10
+ # tree.root # => a Tree::Node, with #children
11
+ # puts tree # => an ASCII tree
12
+ # tree.to_h # => nested hashes
13
+ class Tree < Data.define(:status, :root)
14
+ # key: the node's segment, ex. 'db'. nil for the root of a tree
15
+ # path: the full path from the root, ex. 'sourced.db'. nil for the root of a tree
16
+ # namespace: whether it has no type and no implementation
17
+ # mounted: whether it's a component mounted here (it owns itself)
18
+ # deferred: whether the root's start! skips it (see Component#defer)
19
+ # owner: the full path of the component that declared it. nil for the root
20
+ # implementer: the full path of the component that implemented it. nil for the root, or if not implemented
21
+ class Node < Data.define(
22
+ :key, :path, :type, :type_name, :namespace, :mounted, :implemented, :mode, :status, :deferred, :owner, :implementer, :children
23
+ )
24
+ # Whether it's implemented by a component other than the one that declared it, ex. an app overriding a library's component
25
+ def overridden? = implemented && owner != implementer
26
+
27
+ def to_h = super.merge(children: children.map(&:to_h))
28
+ end
29
+
30
+ def to_h = { status:, root: root.to_h }
31
+
32
+ # (root)
33
+ # ├── logger Interface[info] (singleton, built)
34
+ # ├── sourced [mounted]
35
+ # │ ├── logger Interface[info] (singleton, built)
36
+ # │ └── db DB (singleton, built) implemented by (root)
37
+ # └── cache
38
+ # └── redis String (singleton, built)
39
+ def to_s
40
+ lines = [label(root, root.path || '(root)')]
41
+ render(root.children, '', lines)
42
+ lines.join("\n")
43
+ end
44
+
45
+ # Mermaid classes for component statuses and unimplemented components, plus namespaces and roots
46
+ MERMAID_CLASSES = Mermaid::STATUS_CLASSES.merge(
47
+ namespace: 'fill:#ffffff,stroke:#a1a1aa',
48
+ root: 'fill:#fafafa,stroke:#18181b,stroke-width:2px'
49
+ ).freeze
50
+
51
+ # A top-down Mermaid flowchart of the tree, with an edge from each node to its children.
52
+ # Roots (the root of the tree, and mounted components) are hexagons, namespaces are rounded,
53
+ # and components are styled like Graph#to_mermaid: singletons are rectangles, dynamic components
54
+ # are rounded, colored by status. Components implemented by another component say which one.
55
+ # flowchart TD
56
+ # n0{{"(root)"}}:::root
57
+ # n1["logger<br/>Interface[info]<br/><i>singleton, built</i>"]:::built
58
+ # n2{{"sourced"}}:::root
59
+ # n3["db<br/>DB<br/><i>singleton, built</i><br/><i>implemented by (root)</i>"]:::built
60
+ # n0 --> n1
61
+ # n0 --> n2
62
+ # n2 --> n3
63
+ def to_mermaid
64
+ nodes = []
65
+ edges = []
66
+ add = lambda do |node, name, parent_id, subtree_root|
67
+ id = "n#{nodes.size}"
68
+ nodes << " #{id}#{mermaid_node(node, name, subtree_root)}"
69
+ edges << " #{parent_id} --> #{id}" if parent_id
70
+ node.children.each { |child| add.(child, child.key, id, child.mounted) }
71
+ end
72
+ add.(root, root.path || '(root)', nil, true)
73
+
74
+ ['flowchart TD', *nodes, *edges, *Mermaid.class_defs(MERMAID_CLASSES)].join("\n")
75
+ end
76
+
77
+ private def mermaid_node(node, name, subtree_root)
78
+ lines = [Mermaid.escape(name)]
79
+ unless node.namespace
80
+ lines << Mermaid.escape(node.type_name)
81
+ lines << "<i>#{Mermaid.details(node.implemented, node.mode, node.status, node.deferred)}</i>"
82
+ end
83
+ lines << "<i>implemented by #{Mermaid.escape(node.implementer || '(root)')}</i>" if node.overridden?
84
+ label = lines.join('<br/>')
85
+
86
+ css_class = if node.namespace then subtree_root ? :root : :namespace
87
+ elsif node.implemented then node.status
88
+ else :unimplemented
89
+ end
90
+ open, close = if subtree_root then ['{{"', '"}}']
91
+ elsif node.namespace then ['("', '")']
92
+ else Mermaid.component_shape(node.mode)
93
+ end
94
+ "#{open}#{label}#{close}:::#{css_class}"
95
+ end
96
+
97
+ private def render(nodes, prefix, lines)
98
+ nodes.each_with_index do |node, i|
99
+ last = i == nodes.size - 1
100
+ lines << "#{prefix}#{last ? '└── ' : '├── '}#{label(node, node.key)}"
101
+ render(node.children, prefix + (last ? ' ' : '│ '), lines)
102
+ end
103
+ end
104
+
105
+ private def label(node, name)
106
+ parts = [name]
107
+ parts << '[mounted]' if node.mounted
108
+ unless node.namespace
109
+ parts << node.type_name
110
+ parts << "(#{[node.implemented ? node.mode : 'not implemented', node.status, ('deferred' if node.deferred)].compact.join(', ')})"
111
+ end
112
+ parts << "implemented by #{node.implementer || '(root)'}" if node.overridden?
113
+ parts.join(' ')
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Component
5
+ VERSION = "0.1.0"
6
+ end
7
+ end