thunderchunk 0.2.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +3 -0
  3. data/lib/rubocop/cop/thunderchunk/restricted_access.rb +38 -0
  4. data/lib/thunderchunk/chunk.rb +123 -0
  5. data/lib/thunderchunk/configuration.rb +127 -0
  6. data/lib/thunderchunk/database.rb +64 -0
  7. data/lib/thunderchunk/default_configuration.rb +29 -0
  8. data/lib/thunderchunk/dependency.rb +6 -0
  9. data/lib/thunderchunk/dependency_injector.rb +51 -0
  10. data/lib/thunderchunk/event.rb +40 -0
  11. data/lib/thunderchunk/exporter.rb +55 -0
  12. data/lib/thunderchunk/exporters/base.rb +88 -0
  13. data/lib/thunderchunk/exporters/events.rb +24 -0
  14. data/lib/thunderchunk/exporters/operations.rb +30 -0
  15. data/lib/thunderchunk/external_importer.rb +51 -0
  16. data/lib/thunderchunk/external_importers/base.rb +91 -0
  17. data/lib/thunderchunk/external_importers/events.rb +58 -0
  18. data/lib/thunderchunk/external_importers/operations.rb +28 -0
  19. data/lib/thunderchunk/loader/load.rb +62 -0
  20. data/lib/thunderchunk/loader/namespace.rb +47 -0
  21. data/lib/thunderchunk/loader.rb +66 -0
  22. data/lib/thunderchunk/model.rb +67 -0
  23. data/lib/thunderchunk/operation.rb +0 -0
  24. data/lib/thunderchunk/operations/adapters/use_dependecy.rb +42 -0
  25. data/lib/thunderchunk/operations/base.rb +12 -0
  26. data/lib/thunderchunk/operations/external.rb +7 -0
  27. data/lib/thunderchunk/operations/internal.rb +6 -0
  28. data/lib/thunderchunk/operations/remote.rb +50 -0
  29. data/lib/thunderchunk/operations/subscriber.rb +12 -0
  30. data/lib/thunderchunk/outbox.rb +10 -0
  31. data/lib/thunderchunk/registries/base.rb +76 -0
  32. data/lib/thunderchunk/registries/models.rb +26 -0
  33. data/lib/thunderchunk/registries/operations.rb +17 -0
  34. data/lib/thunderchunk/registry.rb +55 -0
  35. data/lib/thunderchunk/rubocop.rb +6 -0
  36. data/lib/thunderchunk/sender_dock.rb +49 -0
  37. data/lib/thunderchunk/type_names.rb +35 -0
  38. data/lib/thunderchunk/usage_importer/kinds/model.rb +14 -0
  39. data/lib/thunderchunk/usage_importer/use.rb +13 -0
  40. data/lib/thunderchunk/version.rb +5 -0
  41. data/lib/thunderchunk.rb +61 -0
  42. metadata +132 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class ExternalImporter
5
+ def initialize(chunk = nil)
6
+ @chunk = chunk
7
+ @importers = chunk.configuration['boot.external_importers'].to_h do |klass|
8
+ [klass.kind, klass.new(chunk)]
9
+ end
10
+ end
11
+
12
+ def load
13
+ @importers.each_value(&:load)
14
+ end
15
+
16
+ def hash
17
+ @importers.transform_values(&:items)
18
+ end
19
+
20
+ def get(kind)
21
+ @importers[kind.to_sym]
22
+ end
23
+
24
+ def get!(kind, path)
25
+ get(kind)&.get(path) || raise(Error, "#{kind} #{path} is not imported")
26
+ end
27
+
28
+ def each_constant(*, &)
29
+ hash.each_value do |items|
30
+ items.each_value(*, &)
31
+ end
32
+ end
33
+
34
+ def clear
35
+ @importers.each_value(&:clear)
36
+ end
37
+
38
+ def create_kind(importer)
39
+ kind = importer.class.kind
40
+
41
+ raise ArgumentError, 'This importer already has a kind' if @importers.has_value?(importer)
42
+ raise ArgumentError, 'This kind already has an importer' if @importers.has_key?(kind)
43
+
44
+ @importers[kind] = importer
45
+ end
46
+
47
+ def [](value)
48
+ hash[value]
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class ExternalImporters
5
+ class Base
6
+ def self.kind(value = nil)
7
+ return @kind = value if value
8
+
9
+ @kind || raise(NotImplementedError, "#{name} must declare `kind`")
10
+ end
11
+
12
+ def self.path(value = nil)
13
+ if value
14
+ @path = value
15
+ @should_load = true
16
+
17
+ return
18
+ end
19
+
20
+ @path || @kind.to_s.pluralize || raise(NotImplementedError, "#{name} must declare `kind` or `path`")
21
+ end
22
+
23
+ # Whether load reads app/external/<path> at boot. Off by default: an importer that builds each import on get
24
+ # has nothing to load.
25
+ def self.should_load(value = nil)
26
+ return @should_load = value unless value.nil?
27
+
28
+ @should_load || false
29
+ end
30
+
31
+ attr_reader :items
32
+
33
+ def initialize(chunk)
34
+ @chunk = chunk
35
+ @items = {}
36
+ @loader = Loader.new(@chunk.name, @chunk.root_path)
37
+ end
38
+
39
+ def add_constant(constant)
40
+ return false unless constant.is_a?(Class)
41
+
42
+ key = key_for(constant)
43
+ return false unless key
44
+
45
+ add(key, constant)
46
+ true
47
+ end
48
+
49
+ def key_for(_constant)
50
+ raise NotImplementedError
51
+ end
52
+
53
+ def get(_path)
54
+ raise NotImplementedError
55
+ end
56
+
57
+ def add(path, object)
58
+ @items[path] = object
59
+ end
60
+
61
+ def load
62
+ return unless self.class.should_load
63
+
64
+ @items = {}
65
+
66
+ @loader.load(["app/external/#{self.class.path}"])
67
+
68
+ @loader.each do |_parent, _name, constant|
69
+ add_constant(constant)
70
+ end
71
+
72
+ @loader.unload
73
+ end
74
+
75
+ def clear
76
+ @items.clear
77
+ end
78
+
79
+ private
80
+
81
+ def key_from_name(constant, *namespace)
82
+ segments = constant.name.split('::')
83
+ index = segments.each_index.find { |i| segments[i, namespace.size] == namespace }
84
+ return unless index
85
+
86
+ rest = segments.drop(index + namespace.size)
87
+ rest.map(&:underscore).join('.') unless rest.empty?
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class ExternalImporters
5
+ class Events < Base
6
+ class << self
7
+ # WARN: AI MADE
8
+ attr_accessor :resolver
9
+ # WARN: ---
10
+ end
11
+
12
+ kind :event
13
+ path 'messaging/events/'
14
+
15
+ def key_for(constant)
16
+ key_from_name(constant, 'External', 'Messaging', 'Events')&.tap do |key|
17
+ unless constant < ThunderChunk::Event
18
+ raise RegistryRequirementError, "#{constant.name} must inherit from ThunderChunk::Event"
19
+ end
20
+ end
21
+ end
22
+
23
+ # books.add
24
+ def get(key)
25
+ event_klass = @items[key] || raise(Error, 'this event is not registered')
26
+
27
+ event_klass.new(
28
+ @chunk,
29
+ key,
30
+ self.class.publisher(@chunk)
31
+ )
32
+ end
33
+
34
+ # WARN: AI MADE
35
+ # One publisher per chunk: each one is built with the config of its chunk.
36
+ def self.publisher(chunk)
37
+ @publishers ||= {}.compare_by_identity
38
+ @publishers[chunk] ||= begin
39
+ # WARN: ---
40
+ broker = chunk.config['messaging.broker.type']
41
+ publisher = chunk.config['messaging.publisher']
42
+
43
+ # WARN: AI MADE
44
+ resolver = Events.resolver || raise(Error, "no publisher for #{broker} #{publisher}: require 'thunderwire'")
45
+ # WARN: ---
46
+
47
+ publisher_klass = resolver.call(broker, publisher)
48
+
49
+ configuration = publisher_klass.configurations.to_h do |key, path|
50
+ [key, chunk.config[path]]
51
+ end.compact
52
+
53
+ publisher_klass.new(**configuration)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class ExternalImporters
5
+ class Operations < Base
6
+ kind :external_operation
7
+
8
+ # library/books.add
9
+ def get(path)
10
+ unless path.match?(%r{\A[a-z]+(_[a-z]+)*/[a-z]+([._][a-z]+)*\z})
11
+ raise Error, "invalid operation path #{path.inspect}"
12
+ end
13
+
14
+ @items[path] ||= begin
15
+ chunk_identifier, key = path.split('/')
16
+ dock = @chunk.sender_dock
17
+ # WARN: AI MADE
18
+ # The sender (and the remote's url in the config) is found on boot, so a missing url fails there; in specs
19
+ # every call is stubbed (ThunderSpec), so it is found on the first call only.
20
+ sender = -> { dock.sender_for(chunk_identifier, :operation) }
21
+ sender.call unless ENV['THUNDER_ENV'] == 'test'
22
+
23
+ ThunderChunk::Operations::Remote.new(sender:, key:)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class Loader
5
+ class Load
6
+ # WARN: AI MADE
7
+ # The classes the chunk's code inherits from (ApplicationModel, InternalOperation): a file there defines one
8
+ # class at the top, without the chunk's modules, and any file of the chunk reaches it by its bare name.
9
+ COMMON = 'app/common'
10
+
11
+ def initialize(restrict_module, chunk_module, root_path)
12
+ @restrict_module = restrict_module
13
+ @root_path = root_path
14
+ @root = Namespace.new(chunk_module, root_path.join('app'), self)
15
+ @loaded = Set.new
16
+ @running = false
17
+
18
+ load = self
19
+ restrict_module.define_singleton_method(:const_missing) { |name| load.resolve_common(name) || super(name) }
20
+ end
21
+
22
+ def running? = @running
23
+
24
+ def run(files)
25
+ @running = true
26
+ files.each { |file| load_file(file) }
27
+ ensure
28
+ @running = false
29
+ end
30
+
31
+ def load_file(file)
32
+ return false unless @loaded.add?(file)
33
+
34
+ parent = common?(file) ? @restrict_module : @root.namespace_for(file.dirname).mod
35
+ @restrict_module.module_eval(file.read, file.to_s, 1)
36
+ validate_file_constant!(file, parent)
37
+ true
38
+ end
39
+
40
+ # WARN: AI MADE
41
+ # The common class with this name, loaded on its first use; nil when app/common has no file for it.
42
+ def resolve_common(name)
43
+ return unless running?
44
+
45
+ file = @root_path.glob("#{COMMON}/**/#{name.to_s.underscore}.rb").min
46
+ file && load_file(file) && @restrict_module.const_get(name, false)
47
+ end
48
+
49
+ private
50
+
51
+ def common?(file) = file.to_s.start_with?("#{@root_path.join(COMMON)}/")
52
+
53
+ def validate_file_constant!(file, parent)
54
+ constant = file.basename('.rb').to_s.camelize
55
+ return if parent.const_defined?(constant, false)
56
+
57
+ expected = parent.equal?(@restrict_module) ? constant : "#{parent.name}::#{constant}"
58
+ raise FileConstantMismatchError, "expected #{file.relative_path_from(@root_path)} to define #{expected}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class Loader
5
+ class Namespace
6
+ attr_reader :mod
7
+
8
+ def initialize(mod, directory, load)
9
+ @mod = mod
10
+ @directory = directory
11
+ @load = load
12
+ @children = {}
13
+
14
+ namespace = self
15
+ mod.define_singleton_method(:const_missing) { |name| namespace.resolve(name) || super(name) }
16
+ end
17
+
18
+ def namespace_for(directory)
19
+ directory.relative_path_from(@directory).each_filename.reduce(self) { |namespace, name| namespace.child(name) }
20
+ end
21
+
22
+ def child(name)
23
+ @children[name] ||= begin
24
+ constant = name.camelize
25
+ mod = @mod.const_defined?(constant, false) ? @mod.const_get(constant, false) : @mod.const_set(constant, Module.new)
26
+ Namespace.new(mod, @directory.join(name), @load)
27
+ end
28
+ end
29
+
30
+ def resolve(name)
31
+ return unless @load.running?
32
+
33
+ path = @directory.join(name.to_s.underscore)
34
+ file = path.sub_ext('.rb')
35
+
36
+ if file.file?
37
+ @load.load_file(file) && @mod.const_get(name, false)
38
+ elsif path.directory?
39
+ child(path.basename.to_s).mod
40
+ else
41
+ # WARN: AI MADE
42
+ @load.resolve_common(name)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ LOADING_ORDER = %w[
5
+ app/internal/models
6
+ app/internal/operations
7
+ ].freeze
8
+
9
+ class Loader
10
+ attr_reader :chunk_module
11
+
12
+ def initialize(chunk_name, root_path)
13
+ @root_path = root_path
14
+ @chunk_name = chunk_name
15
+ @restrict_module = Module.new.tap { |mod| mod.set_temporary_name('<restrict>') }
16
+ end
17
+
18
+ def load(order = LOADING_ORDER)
19
+ @restrict_module.send(:remove_const, @chunk_name) if @restrict_module.const_defined?(@chunk_name, false)
20
+ @chunk_module = named_module(@chunk_name)
21
+ @restrict_module.const_set(@chunk_name, @chunk_module)
22
+
23
+ Load.new(@restrict_module, @chunk_module, @root_path).run(files(order))
24
+ rescue Exception
25
+ unload
26
+
27
+ raise
28
+ end
29
+
30
+ def each(&block)
31
+ constants_of(@restrict_module).reverse_each(&block)
32
+ end
33
+
34
+ def unload
35
+ constants_of(@restrict_module).reverse_each do |parent, name, value|
36
+ yield(parent, name, value) if block_given?
37
+
38
+ parent.send(:remove_const, name)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def files(order)
45
+ order.flat_map { |path| Dir.glob(@root_path.join(path, '**', '*.rb')).sort.map { Pathname(it) } }
46
+ end
47
+
48
+ def constants_of(parent)
49
+ parent.constants(false).flat_map do |name|
50
+ value = parent.const_get(name, false)
51
+ namespace = value.equal?(@chunk_module) || (value.instance_of?(Module) && value.name == "#{parent.name}::#{name}")
52
+
53
+ [[parent, name, value], *(namespace ? constants_of(value) : [])]
54
+ end
55
+ end
56
+
57
+ def named_module(name)
58
+ raise Error, "::#{name} already exists; it would collide with the chunk" if Object.const_defined?(name, false)
59
+
60
+ Module.new.tap do |mod|
61
+ Object.const_set(name, mod)
62
+ Object.send(:remove_const, name)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class Model < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ class_attribute :chunk_key, :chunk_registry, instance_writer: false, instance_predicate: false
8
+
9
+ # WARN: AI MADE
10
+ # A uuid primary key gets a UUIDv7 (ordered by creation time) when the record is created without one, as in
11
+ # tcard_backend's ApplicationRecord. The column default (gen_random_uuid(), a v4) is left for rows inserted in SQL.
12
+ before_create :assign_uuid_v7_id
13
+
14
+ class << self
15
+ def module_parent = Object
16
+ def module_parents = [Object]
17
+
18
+ def chunk_name
19
+ raise Error, "#{name} is not registered yet" unless chunk_key
20
+
21
+ chunk_key.split('.').map(&:camelize).join('::')
22
+ end
23
+
24
+ def model_name
25
+ @model_name ||= ActiveModel::Name.new(self, nil, chunk_name)
26
+ end
27
+
28
+ def sti_name = chunk_name
29
+ def polymorphic_name = chunk_name
30
+
31
+ def sti_class_for(type_name)
32
+ model_for(type_name) || raise(ActiveRecord::SubclassNotFound, "unknown model #{type_name} for #{chunk_name}")
33
+ end
34
+
35
+ def polymorphic_class_for(name) = model_for!(name)
36
+
37
+ private
38
+
39
+ def compute_type(type_name)
40
+ return model_for!(type_name.delete_prefix('::')) if type_name.start_with?('::')
41
+
42
+ scope = chunk_name.split('::')[0...-1]
43
+ candidates = scope.size.downto(0).map { |n| [*scope.first(n), type_name].join('::') }
44
+ candidates.each { |candidate| (klass = model_for(candidate)) and return klass }
45
+
46
+ raise NameError.new("model #{type_name} not found from #{chunk_name} (tried #{candidates.join(', ')})", type_name)
47
+ end
48
+
49
+ def model_for(name) = chunk_registry.get(name.underscore.tr('/', '.'))
50
+ def model_for!(name) = model_for(name) || raise(NameError.new("unknown model #{name}", name))
51
+
52
+ def compute_table_name
53
+ base_class? ? chunk_name.underscore.tr('/', '_').pluralize : superclass.table_name
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def assign_uuid_v7_id
60
+ return if id.present?
61
+ return unless self.class.primary_key == 'id'
62
+ return unless self.class.columns_hash['id']&.type == :uuid
63
+
64
+ self.id = SecureRandom.uuid_v7
65
+ end
66
+ end
67
+ end
File without changes
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ module Operations
5
+ module Adapters
6
+ class UseDependency < ThunderOperation::Adapter
7
+ adapted do
8
+ def self.use(kind, path, **options)
9
+ dependencies << Dependency.new(
10
+ kind: kind.to_sym,
11
+ path: path.to_s,
12
+ config: { on_instance: true },
13
+ options:
14
+ )
15
+ end
16
+
17
+ def self.dependencies
18
+ @dependencies ||= []
19
+ end
20
+
21
+ def self.const_missing(name)
22
+ raise NameError.new("#{name} is not available in this operation; #{use_hint(name)}", name)
23
+ end
24
+
25
+ def self.use_hint(name)
26
+ key = name.to_s.underscore
27
+ return "declare it with `use :<kind>, '#{key}'`" unless respond_to?(:chunk) && chunk
28
+
29
+ matches = chunk.registry.hash.flat_map do |kind, items|
30
+ items.keys.sort.select { |path| path == key || path.start_with?("#{key}.") }.map { |path| "`use :#{kind}, '#{path}'`" }
31
+ end
32
+ return "nothing named #{key} is registered in the chunk" if matches.empty?
33
+
34
+ "declare it with #{matches.join(' or ')}"
35
+ end
36
+
37
+ private_class_method :use_hint
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,12 @@
1
+
2
+ module ThunderChunk
3
+ module Operations
4
+ class Base < ThunderOperation::Base
5
+ append_adapter ThunderOperation::Adapters::RichParameters
6
+ append_adapter ThunderOperation::Adapters::Interruptable
7
+ append_adapter Adapters::UseDependency
8
+ # WARN: AI MADE
9
+ append_adapter ThunderOperation::Adapters::Validations
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ module ThunderChunk
2
+ module Operations
3
+ class External < Base
4
+ append_adapter ThunderOperation::Adapters::Framer
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module ThunderChunk
2
+ module Operations
3
+ class Internal < Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,50 @@
1
+ module ThunderChunk
2
+ module Operations
3
+ class Remote
4
+ class RemoteFailure < ThunderChunk::Error
5
+ attr_reader :response
6
+
7
+ def initialize(response)
8
+ @response = response
9
+ super("Remote operation failed: #{response.inspect}")
10
+ end
11
+ end
12
+
13
+ # The error of a Response that failed in another chunk: the original exception stays there, so this one carries
14
+ # its message, class name, extras and trail.
15
+ class RemoteResponseFailure < ThunderChunk::Error
16
+ attr_reader :error_class, :extras, :trail
17
+
18
+ def initialize(message, error_class:, extras:, trail:)
19
+ @error_class = error_class
20
+ @extras = extras
21
+ @trail = trail
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ # WARN: AI MADE
27
+ # sender: a sender, or a lambda that returns one when the first call needs it.
28
+ def initialize(sender:, key:)
29
+ @sender = sender
30
+ @key = key
31
+ end
32
+
33
+ def call(**params) = sender.call(@key, params)
34
+
35
+ def call!(**params)
36
+ response = call(**params)
37
+ raise RemoteFailure, response if response.failure?
38
+
39
+ response
40
+ end
41
+
42
+ private
43
+
44
+ def sender
45
+ @sender = @sender.call if @sender.is_a?(Proc)
46
+ @sender
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ module Operations
5
+ class Subscriber < Base
6
+ # Every param of a subscriber is optional: a key the event leaves out arrives as nil.
7
+ def self.param(field_name, *args, **options, &)
8
+ super(field_name, *args, { **options, optional: true }, &)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderChunk
4
+ class Outbox < Model
5
+ self.chunk_key = 'outbox'
6
+ self.table_name = 'thunder_outbox'
7
+
8
+ scope :pending, -> { where(published_at: nil).order(:id) }
9
+ end
10
+ end