yes-core 1.0.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 (128) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
  6. data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
  7. data/lib/yes/core/aggregate/draftable.rb +205 -0
  8. data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
  9. data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
  10. data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
  11. data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
  12. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
  13. data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
  14. data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
  15. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
  16. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
  17. data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
  18. data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
  19. data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
  20. data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
  21. data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
  22. data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
  23. data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
  24. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
  25. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
  26. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
  27. data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
  28. data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
  29. data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
  30. data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
  31. data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
  32. data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
  33. data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
  34. data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
  35. data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
  36. data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
  37. data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
  38. data/lib/yes/core/aggregate/has_read_model.rb +169 -0
  39. data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
  40. data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
  41. data/lib/yes/core/aggregate.rb +404 -0
  42. data/lib/yes/core/authentication_error.rb +8 -0
  43. data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
  44. data/lib/yes/core/authorization/command_authorizer.rb +40 -0
  45. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
  46. data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
  47. data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
  48. data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
  49. data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
  50. data/lib/yes/core/command.rb +35 -0
  51. data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
  52. data/lib/yes/core/command_handling/command_executor.rb +171 -0
  53. data/lib/yes/core/command_handling/command_handler.rb +124 -0
  54. data/lib/yes/core/command_handling/event_publisher.rb +189 -0
  55. data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
  56. data/lib/yes/core/command_handling/guard_runner.rb +76 -0
  57. data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
  58. data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
  59. data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
  60. data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
  61. data/lib/yes/core/command_handling/state_updater.rb +113 -0
  62. data/lib/yes/core/commands/bus.rb +46 -0
  63. data/lib/yes/core/commands/group.rb +135 -0
  64. data/lib/yes/core/commands/group_response.rb +13 -0
  65. data/lib/yes/core/commands/helper.rb +126 -0
  66. data/lib/yes/core/commands/notifier.rb +65 -0
  67. data/lib/yes/core/commands/processor.rb +137 -0
  68. data/lib/yes/core/commands/response.rb +63 -0
  69. data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
  70. data/lib/yes/core/commands/stateless/group_response.rb +15 -0
  71. data/lib/yes/core/commands/stateless/handler.rb +292 -0
  72. data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
  73. data/lib/yes/core/commands/stateless/response.rb +14 -0
  74. data/lib/yes/core/commands/stateless/subject.rb +41 -0
  75. data/lib/yes/core/commands/validator.rb +28 -0
  76. data/lib/yes/core/configuration.rb +432 -0
  77. data/lib/yes/core/data_decryptor.rb +59 -0
  78. data/lib/yes/core/data_encryptor.rb +60 -0
  79. data/lib/yes/core/encryption_metadata.rb +33 -0
  80. data/lib/yes/core/error.rb +14 -0
  81. data/lib/yes/core/error_messages.rb +37 -0
  82. data/lib/yes/core/event.rb +222 -0
  83. data/lib/yes/core/event_class_resolver.rb +40 -0
  84. data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
  85. data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
  86. data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
  87. data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
  88. data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
  89. data/lib/yes/core/middlewares/encryptor.rb +48 -0
  90. data/lib/yes/core/middlewares/timestamp.rb +29 -0
  91. data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
  92. data/lib/yes/core/models/application_record.rb +9 -0
  93. data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
  94. data/lib/yes/core/open_telemetry/trackable.rb +101 -0
  95. data/lib/yes/core/payload_store/base.rb +33 -0
  96. data/lib/yes/core/payload_store/errors.rb +13 -0
  97. data/lib/yes/core/payload_store/lookup.rb +44 -0
  98. data/lib/yes/core/process_managers/access_token_client.rb +107 -0
  99. data/lib/yes/core/process_managers/base.rb +40 -0
  100. data/lib/yes/core/process_managers/command_runner.rb +109 -0
  101. data/lib/yes/core/process_managers/service_client.rb +57 -0
  102. data/lib/yes/core/process_managers/state.rb +118 -0
  103. data/lib/yes/core/railtie.rb +58 -0
  104. data/lib/yes/core/read_model/builder.rb +267 -0
  105. data/lib/yes/core/read_model/event_handler.rb +64 -0
  106. data/lib/yes/core/read_model/filter.rb +118 -0
  107. data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
  108. data/lib/yes/core/serializer.rb +21 -0
  109. data/lib/yes/core/subscriptions.rb +94 -0
  110. data/lib/yes/core/test_support/event_helpers.rb +27 -0
  111. data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
  112. data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
  113. data/lib/yes/core/test_support/test_helper.rb +27 -0
  114. data/lib/yes/core/test_support.rb +5 -0
  115. data/lib/yes/core/transaction_details.rb +90 -0
  116. data/lib/yes/core/type_lookup.rb +88 -0
  117. data/lib/yes/core/types.rb +110 -0
  118. data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
  119. data/lib/yes/core/utils/caller_utils.rb +37 -0
  120. data/lib/yes/core/utils/command_utils.rb +226 -0
  121. data/lib/yes/core/utils/error_notifier.rb +101 -0
  122. data/lib/yes/core/utils/event_name_resolver.rb +67 -0
  123. data/lib/yes/core/utils/exponential_retrier.rb +180 -0
  124. data/lib/yes/core/utils/hash_utils.rb +63 -0
  125. data/lib/yes/core/version.rb +7 -0
  126. data/lib/yes/core.rb +85 -0
  127. data/lib/yes.rb +0 -0
  128. metadata +324 -0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module TestSupport
6
+ # Helpers for generating JWT tokens in API tests.
7
+ module JwtHelpers
8
+ # @param expires_at [Time]
9
+ # @param host [String, nil]
10
+ # @param identity_id [String, nil]
11
+ # @return [String]
12
+ def jwt_sign_in(expires_at: 1.hour.from_now, host: nil, identity_id: nil)
13
+ generate_spec_token(expires_at, host:, identity_id:)
14
+ end
15
+
16
+ # @param expires_at [Time]
17
+ # @param data [Hash]
18
+ # @return [String]
19
+ def generate_spec_token(expires_at, data)
20
+ private_key = RbNaCl::Signatures::Ed25519::SigningKey.new(
21
+ ENV.fetch('JWT_TOKEN_AUTH_PRIVATE_KEY')
22
+ )
23
+ JWT.encode(
24
+ data.merge(exp: expires_at.to_i), private_key, 'ED25519'
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/support/spec/in_sub_process'
4
+ require 'rspec/support/spec/stderr_splitter'
5
+
6
+ ENV['GRPC_ENABLE_FORK_SUPPORT'] = '1' if defined?(GRPC)
7
+
8
+ module Yes
9
+ module Core
10
+ module TestSupport
11
+ # RSpec helper for asserting that PgEventstore subscriptions start correctly.
12
+ #
13
+ # @example
14
+ # RSpec.describe 'Subscriptions' do
15
+ # include Yes::Core::TestSupport::SubscriptionsHelper
16
+ #
17
+ # it 'starts all subscriptions' do
18
+ # assert_running_subscriptions('eventstore/subscriptions.rb', 5)
19
+ # end
20
+ # end
21
+ module SubscriptionsHelper
22
+ include ::RSpec::Support::InSubProcess
23
+
24
+ # Asserts that the expected number of subscriptions are running.
25
+ #
26
+ # @param subscriptions_paths [Array<String>] relative paths to subscription files
27
+ # @param number_of_subscriptions [Integer] expected number of running subscriptions
28
+ # @param root [String] root directory of subscription files
29
+ # @param timeout [Integer] timeout in seconds for subscriptions to start
30
+ # @return [void]
31
+ def assert_running_subscriptions(*subscriptions_paths, number_of_subscriptions, root: './lib/tasks', timeout: 5)
32
+ GRPC.prefork if defined?(GRPC)
33
+ in_sub_process do
34
+ GRPC.postfork_child if defined?(GRPC)
35
+ setup_eventstore_cli
36
+ require_options = subscriptions_paths.flat_map { |path| ['-r', "#{root}/#{path}"] }
37
+ runner = Thread.new { PgEventstore::CLI.execute(['subscriptions', 'start', *require_options]) }
38
+ subscriptions_count = poll_subscriptions(number_of_subscriptions, timeout)
39
+
40
+ runner.exit
41
+ aggregate_failures do
42
+ expect(subscriptions_count['count_running']).to eq(number_of_subscriptions)
43
+ expect(subscriptions_count['count_all']).to eq(number_of_subscriptions)
44
+ end
45
+ rescue StandardError => e
46
+ Rails.logger.debug e.message
47
+ Rails.logger.debug e.backtrace
48
+ raise e
49
+ end
50
+ nil
51
+ ensure
52
+ GRPC.postfork_parent if defined?(GRPC)
53
+ end
54
+
55
+ private
56
+
57
+ # @return [void]
58
+ def setup_eventstore_cli
59
+ require 'pg_eventstore/cli'
60
+ PgEventstore.logger = Logger.new($stdout)
61
+ PgEventstore.logger.level = :error
62
+ PgEventstore.connection.with { |c| c.exec('DELETE FROM subscriptions') }
63
+ sleep 0.5
64
+ end
65
+
66
+ # @param expected_count [Integer] expected number of subscriptions
67
+ # @param timeout [Integer] timeout in seconds
68
+ # @return [Hash] subscription counts
69
+ def poll_subscriptions(expected_count, timeout)
70
+ deadline = timeout.seconds.from_now
71
+ loop do
72
+ counts = PgEventstore.connection.with do |c|
73
+ c.exec(<<~SQL.squish)
74
+ select count(*) filter (where state = 'running') as count_running, count(*) as count_all
75
+ from subscriptions
76
+ SQL
77
+ end.first
78
+ break counts if (counts['count_running'] == expected_count &&
79
+ counts['count_all'] == expected_count) ||
80
+ Time.current > deadline
81
+
82
+ sleep 0.1
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module TestSupport
6
+ # Utility class for configuring and cleaning up Yes::Core in tests.
7
+ class TestHelper
8
+ class << self
9
+ # Resets the Yes::Core configuration to a clean state.
10
+ # @return [void]
11
+ def clean_up_config
12
+ config = Yes::Core.configuration
13
+ config.aggregate_shortcuts = false
14
+ config.super_admin_check = ->(_auth_data) { false }
15
+ config.logger = nil
16
+ config.error_reporter = nil
17
+ config.payload_store_client = nil
18
+ config.process_commands_inline = true
19
+ config.command_notifier_classes = []
20
+ config.otl_tracer = nil
21
+ config.auth_adapter = nil
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_support/event_helpers'
4
+ require_relative 'test_support/jwt_helpers'
5
+ require_relative 'test_support/test_helper'
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Yes
6
+ module Core
7
+ # @api private
8
+ module TransactionDetailsTypes
9
+ # OpenTelemetry context data for distributed tracing
10
+ class OtlContexts < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ context_schema = Types::Hash.schema(
14
+ traceparent?: Types::String,
15
+ service?: Types::String
16
+ ).with_key_transform(&:to_sym)
17
+
18
+ timestamps_schema = Types::Hash.schema(
19
+ command_request_started_at_ms?: Types::Integer,
20
+ command_handling_started_at_ms?: Types::Integer
21
+ ).with_key_transform(&:to_sym)
22
+
23
+ attribute?(:root, context_schema.default { {} })
24
+ attribute?(:publisher, context_schema.default { {} })
25
+ attribute?(:timestamps, timestamps_schema.default { {} })
26
+
27
+ # @param context [Hash]
28
+ def publisher=(context)
29
+ @attributes = attributes.merge(publisher: context)
30
+ end
31
+
32
+ # @param context [Hash]
33
+ def root=(context)
34
+ @attributes = attributes.merge(root: context)
35
+ end
36
+ end
37
+ end
38
+
39
+ # Value object representing a command transaction's context.
40
+ #
41
+ # Carries correlation/causation IDs for event sourcing traceability,
42
+ # caller identity, and OpenTelemetry trace context.
43
+ #
44
+ # @example
45
+ # TransactionDetails.new(
46
+ # name: "CreateUser",
47
+ # correlation_id: SecureRandom.uuid,
48
+ # caller_id: current_user.id,
49
+ # caller_type: 'User'
50
+ # )
51
+ class TransactionDetails < Dry::Struct
52
+ schema schema.strict
53
+ transform_keys(&:to_sym)
54
+
55
+ attribute?(:name, Types::String.optional.default(nil))
56
+ attribute(:correlation_id, Types::UUID.default { SecureRandom.uuid })
57
+ attribute?(:causation_id, Types::UUID.optional.default(nil))
58
+ attribute?(:caller_id, Types::UUID.optional.default(nil))
59
+ attribute?(:caller_type, Types::String.optional.default(nil))
60
+ attribute?(:otl_contexts, TransactionDetailsTypes::OtlContexts)
61
+
62
+ # Returns metadata formatted for the event store.
63
+ #
64
+ # @return [Hash] with :$correlationId and :$causationId keys
65
+ def for_eventstore_metadata
66
+ to_h.slice(:correlation_id, :causation_id, :otl_contexts).tap do |h|
67
+ h[:$correlationId] = h.delete(:correlation_id)
68
+ h[:$causationId] = h.delete(:causation_id)
69
+ end
70
+ end
71
+
72
+ # @return [Hash] compact hash representation
73
+ def to_h
74
+ super.compact
75
+ end
76
+
77
+ # Creates TransactionDetails from an existing event.
78
+ #
79
+ # @param event [Yes::Core::Event] the source event
80
+ # @return [TransactionDetails]
81
+ def self.from_event(event)
82
+ new(
83
+ correlation_id: event.metadata['$correlationId'].presence || SecureRandom.uuid,
84
+ causation_id: event.id,
85
+ otl_contexts: event.metadata['otl_contexts']
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class TypeLookup
6
+ def self.type_for(type, context, obj_type = :command)
7
+ new(type, context, obj_type).type_for
8
+ end
9
+
10
+ def initialize(type, context, obj_type)
11
+ @type = type
12
+ @context = context
13
+ @obj_type = obj_type
14
+ end
15
+
16
+ def type_for # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
17
+ return @type if basic_event_type?
18
+
19
+ case @type
20
+ when :lat, :lng
21
+ @obj_type == :event ? :float : Yes::Core::Types::Coercible::Float
22
+ when :string
23
+ base = Yes::Core::Types::Coercible::String
24
+ return base if @obj_type == :event
25
+
26
+ base.prepend { |v| v.nil? ? raise(Dry::Types::CoercionError, 'nil is not a string') : v }
27
+ when :integer
28
+ Yes::Core::Types::Coercible::Integer
29
+ when :boolean
30
+ Yes::Core::Types::Strict::Bool
31
+ when :float
32
+ Yes::Core::Types::Coercible::Float
33
+ when :uuid
34
+ Yes::Core::Types::UUID
35
+ when :uuids
36
+ Yes::Core::Types::UUIDS
37
+ when :email
38
+ Yes::Core::Types::EMAIL
39
+ when :url
40
+ Yes::Core::Types::URL
41
+ when :optional_url
42
+ Yes::Core::Types::OPTIONAL_URL
43
+ when :hash
44
+ Yes::Core::Types::Hash
45
+ when :years
46
+ Yes::Core::Types::YEARS
47
+ when :locale
48
+ Yes::Core::Types::LOCALE
49
+ when :year
50
+ Yes::Core::Types::YEAR
51
+ when :year_date_hash
52
+ Yes::Core::Types::YEAR_DATE_HASH
53
+ when :emails
54
+ Yes::Core::Types::EMAILS
55
+ when :datetime
56
+ Yes::Core::Types::DATE_TIME
57
+ when :date_value
58
+ Yes::Core::Types::DateValue
59
+ when :period
60
+ Yes::Core::Types::PERIOD
61
+ when :dimensions
62
+ Yes::Core::Types::DIMENSIONS
63
+ when :array
64
+ Yes::Core::Types::Array
65
+ else
66
+ lookup_type(@type.to_s.upcase)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def basic_event_type?
73
+ [':string', ':integer', ':boolean', ':float'].include?(@type) && @obj_type == :event
74
+ end
75
+
76
+ def lookup_type(value)
77
+ return "#{@context}::Types::#{value}".constantize if Object.const_defined?("#{@context}::Types::#{value}")
78
+
79
+ return Yes::Core::Types.const_get(value) if Yes::Core::Types.const_defined?(value)
80
+
81
+ registered = Yes::Core::Types.lookup(value.downcase.to_sym)
82
+ return registered if registered
83
+
84
+ raise "Unknown type #{@type}"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'dry-types'
5
+
6
+ module Yes
7
+ module Core
8
+ # Type definitions for the Yes event sourcing framework.
9
+ #
10
+ # Provides generic types built on Dry::Types for use in commands, events,
11
+ # and attribute definitions. Domain-specific types can be registered by
12
+ # consuming applications via {.register}.
13
+ #
14
+ # @example Registering a custom type
15
+ # Yes::Core::Types.register(:team_role, Yes::Core::Types::String.enum('lead', 'member'))
16
+ module Types
17
+ include Dry.Types()
18
+
19
+ # @!group Constants
20
+
21
+ EMPTY_STRING = /\A\s*\z/
22
+
23
+ UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
24
+ UUID_REGEXP = /\A#{UUID_REGEXP_BASE}\z/i
25
+
26
+ DATE_TIME_REGEXP = /\A\d{4}-\d{1,2}-\d{1,2} ([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?\z/i
27
+
28
+ EMAIL_REGEXP = Regexp.union(::URI::MailTo::EMAIL_REGEXP, /^$/)
29
+
30
+ URL_REGEXP = Regexp.new("^#{URI::DEFAULT_PARSER.make_regexp(%w[https])}").freeze
31
+
32
+ # @!endgroup
33
+
34
+ # @!group Generic Types
35
+
36
+ UUID = Types::Strict::String.constrained(format: UUID_REGEXP)
37
+
38
+ ORDERED_ARRAY_ASC = ->(value) { value == value.sort }
39
+
40
+ UUIDS = Types::Array.of(UUID).constrained(case: ORDERED_ARRAY_ASC)
41
+
42
+ DATE_TIME = Types::Strict::String.constrained(format: DATE_TIME_REGEXP)
43
+
44
+ START_BEFORE_END = ->(value) { ::DateTime.parse(value[:start]) < ::DateTime.parse(value[:end]) }
45
+ PERIOD = Types::Hash.schema(start: DATE_TIME, end: DATE_TIME).constrained(case: START_BEFORE_END)
46
+
47
+ EMAIL = Types::Strict::String.constrained(format: EMAIL_REGEXP)
48
+ EMAILS = Types::Array.of(EMAIL).constrained(case: ORDERED_ARRAY_ASC)
49
+
50
+ URL = Types::Strict::String.constrained(format: URL_REGEXP)
51
+ OPTIONAL_URL = Types::Strict::String.constrained(format: Regexp.union(EMPTY_STRING, URL_REGEXP))
52
+
53
+ FileName = Types::String.constructor { |str| str ? str.strip.chomp : str }
54
+
55
+ DateValue = Types::String.constrained(format: /^\d{4}-\d{2}-\d{2}$/)
56
+
57
+ YEAR_FORMAT = ->(value) { value.to_s =~ /^\d{4}$/ }
58
+ YEAR_RANGE = ->(value) { value.to_s.to_i.between?(2000, 2050) }
59
+ YearAvailabilityRange = Types::Any.constrained(case: YEAR_FORMAT).constrained(case: YEAR_RANGE)
60
+
61
+ YEAR = YearAvailabilityRange
62
+ YEARS = Types::Array.of(YearAvailabilityRange).constrained(case: ORDERED_ARRAY_ASC)
63
+
64
+ YEAR_DATE_HASH_CONSTRAINT = lambda { |hash|
65
+ hash.keys.all? { |key| YearAvailabilityRange.valid?(key.to_s) } &&
66
+ hash.values.all? { |value| DateValue.valid?(value) }
67
+ }
68
+ PRESENT = ->(value) { value.size.positive? }
69
+ YEAR_DATE_HASH = Types::Hash.constrained(case: YEAR_DATE_HASH_CONSTRAINT).constrained(case: PRESENT)
70
+
71
+ LOCALE = Types::String.enum('de-CH', 'fr-CH', 'it-CH').constructor(&:to_s)
72
+
73
+ DimensionValue = Types::Coercible::Integer.constrained(gteq: 0)
74
+ DIMENSIONS = Types::Hash.schema(width: DimensionValue, height: DimensionValue)
75
+
76
+ PAYLOAD_STORE_TYPE = Types::String.constrained(
77
+ format: Regexp.new("\\Apayload-store:#{UUID_REGEXP_BASE}\\z", 'i')
78
+ )
79
+
80
+ # @!endgroup
81
+
82
+ class << self
83
+ # Registers a custom type that can be looked up by name.
84
+ #
85
+ # @param name [Symbol] the type name (e.g. :team_role)
86
+ # @param type [Dry::Types::Type] the type definition
87
+ # @return [void]
88
+ # @example
89
+ # Yes::Core::Types.register(:subscription_type, Yes::Core::Types::String.enum('premium', 'basic'))
90
+ def register(name, type)
91
+ custom_types[name] = type
92
+ const_set(name.to_s.upcase, type) unless const_defined?(name.to_s.upcase)
93
+ end
94
+
95
+ # Looks up a registered custom type by name.
96
+ #
97
+ # @param name [Symbol] the type name
98
+ # @return [Dry::Types::Type, nil] the type or nil if not found
99
+ def lookup(name)
100
+ custom_types[name]
101
+ end
102
+
103
+ # @return [Hash{Symbol => Dry::Types::Type}] all registered custom types
104
+ def custom_types
105
+ @custom_types ||= {}
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Provides convenient shortcuts for accessing aggregate classes in Rails console
7
+ # @example
8
+ # # Instead of: ApprenticeshipPresentation::Apprenticeship::Aggregate.new(id)
9
+ # # Use: AP::Appr.new(id)
10
+ class AggregateShortcuts
11
+ class << self
12
+ # Load aggregate shortcuts in Rails console
13
+ # Creates module aliases and constants for convenient access
14
+ def load!
15
+ return unless Yes::Core.configuration.aggregate_shortcuts
16
+
17
+ load_overrides
18
+ discover_aggregates
19
+ create_shortcuts
20
+ define_helper_method
21
+ end
22
+
23
+ # List all available shortcuts
24
+ # @param filter [String, nil] Optional filter to show only specific context
25
+ # @return [Hash] Hash of shortcuts and their full paths
26
+ # @example
27
+ # AggregateShortcuts.list
28
+ # AggregateShortcuts.list('AP')
29
+ def list(filter = nil)
30
+ results = @shortcuts || {}
31
+ results = results.select { |shortcut, _| shortcut.start_with?("#{filter}::") } if filter
32
+ results
33
+ end
34
+
35
+ # Display shortcuts in a formatted table
36
+ # @param filter [String, nil] Optional filter
37
+ def display(filter = nil)
38
+ shortcuts = list(filter)
39
+
40
+ if shortcuts.empty?
41
+ Rails.logger.debug { "No shortcuts found#{" for '#{filter}'" if filter}." }
42
+ return
43
+ end
44
+
45
+ max_shortcut_length = shortcuts.keys.map(&:length).max
46
+
47
+ Rails.logger.debug "\nAvailable Aggregate Shortcuts:"
48
+ Rails.logger.debug '=' * (max_shortcut_length + 70)
49
+ shortcuts.sort.each do |shortcut, full_path|
50
+ Rails.logger.debug "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
51
+ end
52
+ Rails.logger.debug '=' * (max_shortcut_length + 70)
53
+ Rails.logger.debug { "\nUsage: #{shortcuts.keys.first}.new(id)" } if shortcuts.any?
54
+ end
55
+
56
+ private
57
+
58
+ def load_overrides
59
+ @context_overrides = {}
60
+ @subject_overrides = {}
61
+
62
+ config_path = Rails.root.join('config/aggregate_shortcuts.yml')
63
+ return unless File.exist?(config_path)
64
+
65
+ config = YAML.load_file(config_path)
66
+ @context_overrides = config['contexts'] || {}
67
+ @subject_overrides = config['subjects'] || {}
68
+ rescue StandardError => e
69
+ Rails.logger.warn("Failed to load aggregate shortcuts config: #{e.message}")
70
+ end
71
+
72
+ def discover_aggregates
73
+ @aggregates = []
74
+
75
+ Rails.root.glob('app/contexts/**/aggregate.rb').each do |file|
76
+ require file
77
+ parts = file.to_s.split('contexts/').last.split('/')
78
+ context = parts[0]
79
+ subject = parts[1]
80
+
81
+ class_name = "#{context.camelize}::#{subject.camelize}::Aggregate"
82
+ klass = class_name.constantize
83
+
84
+ next unless klass < Yes::Core::Aggregate
85
+
86
+ @aggregates << {
87
+ context: context.camelize,
88
+ subject: subject.camelize,
89
+ class: klass,
90
+ class_name: class_name
91
+ }
92
+ rescue NameError, LoadError => e
93
+ Rails.logger.debug { "Skipping #{file}: #{e.message}" }
94
+ end
95
+ end
96
+
97
+ def create_shortcuts
98
+ @shortcuts = {}
99
+ context_modules = {}
100
+
101
+ @aggregates.each do |agg|
102
+ context_abbr = abbreviate_context(agg[:context])
103
+ subject_abbr = abbreviate_subject(agg[:subject])
104
+
105
+ # Create context module alias if not exists
106
+ unless context_modules[context_abbr]
107
+ if Object.const_defined?(context_abbr)
108
+ Rails.logger.warn("Shortcut conflict: #{context_abbr} already defined, skipping #{agg[:context]}")
109
+ next
110
+ end
111
+
112
+ context_module = agg[:context].constantize
113
+ Object.const_set(context_abbr, context_module)
114
+ context_modules[context_abbr] = context_module
115
+ end
116
+
117
+ # Create subject constant within context module
118
+ context_mod = context_modules[context_abbr]
119
+ if context_mod.const_defined?(subject_abbr)
120
+ Rails.logger.warn("Shortcut conflict: #{context_abbr}::#{subject_abbr} already defined")
121
+ next
122
+ end
123
+
124
+ context_mod.const_set(subject_abbr, agg[:class])
125
+
126
+ shortcut_name = "#{context_abbr}::#{subject_abbr}"
127
+ @shortcuts[shortcut_name] = agg[:class_name]
128
+ end
129
+ end
130
+
131
+ def abbreviate_context(context)
132
+ return @context_overrides[context] if @context_overrides[context]
133
+
134
+ # Extract capital letters from CamelCase
135
+ # ApprenticeshipPresentation → AP
136
+ # CompanyManagement → CM
137
+ context.scan(/[A-Z]/).join
138
+ end
139
+
140
+ def abbreviate_subject(subject)
141
+ return @subject_overrides[subject] if @subject_overrides[subject]
142
+
143
+ # First try capital letters
144
+ capitals = subject.scan(/[A-Z]/).join
145
+ return capitals if capitals.length > 1
146
+
147
+ # Otherwise use first 4 characters
148
+ subject[0..3]
149
+ end
150
+
151
+ def define_helper_method
152
+ # Define global helper method for console
153
+ Object.class_eval do
154
+ define_method(:shortcuts) do |filter = nil|
155
+ Yes::Core::Utils::AggregateShortcuts.display(filter)
156
+ nil # Don't return anything to avoid console clutter
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Utility module for handling caller-related operations
7
+ module CallerUtils
8
+ class << self
9
+ # Extracts a formatted origin string from a caller location
10
+ # @param caller [Thread::Backtrace::Location] caller location
11
+ # @return [String] origin of the command, derived from caller
12
+ def origin_from_caller(caller)
13
+ root_path = defined?(Rails) ? Rails.root.to_s : ''
14
+ # #absolute_path may be nil in case the code is run under irb for example. In this case - grab the script
15
+ # name by calling #path
16
+ caller_path = caller.absolute_path || caller.path
17
+ caller_path.
18
+ sub("#{root_path}/", '').
19
+ sub('.rb', '').
20
+ split('/').
21
+ map { |s| s.split('_').map(&:capitalize).join }.
22
+ join(' > ')
23
+ end
24
+
25
+ # Returns an origin string identifying the Rails console context, or nil if not in a console
26
+ # @return [String, nil] origin string like "CompanyManager production console"
27
+ def console_origin
28
+ return unless defined?(Rails::Console)
29
+
30
+ app_name = Rails.application.class.module_parent.name
31
+ "#{app_name} #{Rails.env} console"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end