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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
- data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
- data/lib/yes/core/aggregate/draftable.rb +205 -0
- data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
- data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
- data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
- data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
- data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
- data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
- data/lib/yes/core/aggregate/has_read_model.rb +169 -0
- data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
- data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
- data/lib/yes/core/aggregate.rb +404 -0
- data/lib/yes/core/authentication_error.rb +8 -0
- data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
- data/lib/yes/core/authorization/command_authorizer.rb +40 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
- data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
- data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
- data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
- data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
- data/lib/yes/core/command.rb +35 -0
- data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
- data/lib/yes/core/command_handling/command_executor.rb +171 -0
- data/lib/yes/core/command_handling/command_handler.rb +124 -0
- data/lib/yes/core/command_handling/event_publisher.rb +189 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
- data/lib/yes/core/command_handling/guard_runner.rb +76 -0
- data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
- data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
- data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
- data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
- data/lib/yes/core/command_handling/state_updater.rb +113 -0
- data/lib/yes/core/commands/bus.rb +46 -0
- data/lib/yes/core/commands/group.rb +135 -0
- data/lib/yes/core/commands/group_response.rb +13 -0
- data/lib/yes/core/commands/helper.rb +126 -0
- data/lib/yes/core/commands/notifier.rb +65 -0
- data/lib/yes/core/commands/processor.rb +137 -0
- data/lib/yes/core/commands/response.rb +63 -0
- data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
- data/lib/yes/core/commands/stateless/group_response.rb +15 -0
- data/lib/yes/core/commands/stateless/handler.rb +292 -0
- data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
- data/lib/yes/core/commands/stateless/response.rb +14 -0
- data/lib/yes/core/commands/stateless/subject.rb +41 -0
- data/lib/yes/core/commands/validator.rb +28 -0
- data/lib/yes/core/configuration.rb +432 -0
- data/lib/yes/core/data_decryptor.rb +59 -0
- data/lib/yes/core/data_encryptor.rb +60 -0
- data/lib/yes/core/encryption_metadata.rb +33 -0
- data/lib/yes/core/error.rb +14 -0
- data/lib/yes/core/error_messages.rb +37 -0
- data/lib/yes/core/event.rb +222 -0
- data/lib/yes/core/event_class_resolver.rb +40 -0
- data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
- data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
- data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
- data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
- data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
- data/lib/yes/core/middlewares/encryptor.rb +48 -0
- data/lib/yes/core/middlewares/timestamp.rb +29 -0
- data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
- data/lib/yes/core/models/application_record.rb +9 -0
- data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
- data/lib/yes/core/open_telemetry/trackable.rb +101 -0
- data/lib/yes/core/payload_store/base.rb +33 -0
- data/lib/yes/core/payload_store/errors.rb +13 -0
- data/lib/yes/core/payload_store/lookup.rb +44 -0
- data/lib/yes/core/process_managers/access_token_client.rb +107 -0
- data/lib/yes/core/process_managers/base.rb +40 -0
- data/lib/yes/core/process_managers/command_runner.rb +109 -0
- data/lib/yes/core/process_managers/service_client.rb +57 -0
- data/lib/yes/core/process_managers/state.rb +118 -0
- data/lib/yes/core/railtie.rb +58 -0
- data/lib/yes/core/read_model/builder.rb +267 -0
- data/lib/yes/core/read_model/event_handler.rb +64 -0
- data/lib/yes/core/read_model/filter.rb +118 -0
- data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
- data/lib/yes/core/serializer.rb +21 -0
- data/lib/yes/core/subscriptions.rb +94 -0
- data/lib/yes/core/test_support/event_helpers.rb +27 -0
- data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
- data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
- data/lib/yes/core/test_support/test_helper.rb +27 -0
- data/lib/yes/core/test_support.rb +5 -0
- data/lib/yes/core/transaction_details.rb +90 -0
- data/lib/yes/core/type_lookup.rb +88 -0
- data/lib/yes/core/types.rb +110 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
- data/lib/yes/core/utils/caller_utils.rb +37 -0
- data/lib/yes/core/utils/command_utils.rb +226 -0
- data/lib/yes/core/utils/error_notifier.rb +101 -0
- data/lib/yes/core/utils/event_name_resolver.rb +67 -0
- data/lib/yes/core/utils/exponential_retrier.rb +180 -0
- data/lib/yes/core/utils/hash_utils.rb +63 -0
- data/lib/yes/core/version.rb +7 -0
- data/lib/yes/core.rb +85 -0
- data/lib/yes.rb +0 -0
- 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,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
|