yes-core 1.0.0 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c43247d2dbf4c79fac0069c1fb4887a9fba4b38f845736b67dbb9ee2f213e96f
4
- data.tar.gz: f78fa82e84433fb209a0e0dc5258ee7569e1993af11436112c590c7094f73b9c
3
+ metadata.gz: 684a9c20dfd17ba779b196a7b294b93e04bf531bcc3d31a2adc9f8a4d645b7dd
4
+ data.tar.gz: 43e482658ce60a8c7fe4eb5f73f3bf084169857bd06a57a01aadf5ab7cf98e76
5
5
  SHA512:
6
- metadata.gz: 1c82e17ddceef537ce4f473577e318032b71c46c090ae70b44ea0264e29ad28b6eedef7115db1aafba3f1878a238a7269d533eb0b06ba07de17422b5e2ce9d70
7
- data.tar.gz: 1ed596dba1c9b64292a3a517f823163fb2d1e36261b35fc96812be85abd919ab6b4d537da353114f45f799e0d33a47edd313d0731ec35c014d7cabd86ea8b396
6
+ metadata.gz: 335785676b2baef08117225b60c3a3b170bb39b0c3fe8ffdfa2781c158944b562f53d00884f409fb0c07c15ace4b513c02bd042973da57118ec325701a258acb
7
+ data.tar.gz: 72bc5b16b03b00ddc6211806d2566ad5213a25e452836e1ffd09f1e89c10154e98e47edb5ac75573092b1c21ea13cc38f5f49f379f418c2e08c2532d2e498670
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-04-30
4
+
5
+ - See root CHANGELOG.md for details.
6
+
7
+ ## [1.1.0] - 2026-04-28
8
+
9
+ - See root CHANGELOG.md for details.
10
+
3
11
  ## [1.0.0] - 2026-03-21
4
12
 
5
13
  - Initial open-source release (see root CHANGELOG.md for details)
@@ -15,6 +15,7 @@ module Yes
15
15
  included do
16
16
  class << self
17
17
  attr_accessor :_draft_context, :_draft_aggregate, :_changes_read_model_name,
18
+ :_changes_read_model_explicit,
18
19
  :_draft_foreign_key, :_is_draftable, :_changes_read_model_public
19
20
  end
20
21
  end
@@ -52,7 +53,8 @@ module Yes
52
53
  self._draft_context = draft_config[:context] || context
53
54
  self._draft_aggregate = draft_config[:aggregate] || "#{aggregate}Draft"
54
55
 
55
- self._changes_read_model_name = if changes_read_model
56
+ self._changes_read_model_explicit = changes_read_model.present?
57
+ self._changes_read_model_name = if changes_read_model.present?
56
58
  changes_read_model.to_s
57
59
  else
58
60
  "#{read_model_name}_change"
@@ -12,7 +12,7 @@ module Yes
12
12
  class CommandData
13
13
  attr_reader :name, :context_name, :aggregate_name, :aggregate_class
14
14
  attr_accessor :event_name, :payload_attributes, :update_state_block, :guard_names, :authorizer_block,
15
- :encrypted_attributes
15
+ :encrypted_attributes, :skip_default_guards
16
16
 
17
17
  # @param name [Symbol] The name of the command
18
18
  # @param aggregate_class [Class] The aggregate class this command belongs to
@@ -21,6 +21,7 @@ module Yes
21
21
  # @option options [String] :aggregate The aggregate name
22
22
  # @option options [String] :event_name The event name for the command
23
23
  # @option options [Hash] :payload_attributes The payload attributes for the command
24
+ # @option options [Array<Symbol>] :skip_default_guards Default guards (e.g. :not_removed) that should not be auto-applied
24
25
  def initialize(name, aggregate_class, options = {})
25
26
  @name = name
26
27
  @aggregate_class = aggregate_class
@@ -38,6 +39,9 @@ module Yes
38
39
 
39
40
  # Track encrypted attributes for event schema generation
40
41
  @encrypted_attributes = []
42
+
43
+ # Default guards to opt out of (e.g. :not_removed for the :remove command itself)
44
+ @skip_default_guards = options.delete(:skip_default_guards) || []
41
45
  end
42
46
 
43
47
  # Add a guard name to the list of guards
@@ -76,8 +76,15 @@ module Yes
76
76
  #
77
77
  # @param name [Symbol] The name of the parent.
78
78
  # @param options [Hash] Options for configuring the parent.
79
+ # @option options [Boolean] :command (true) When false, skips defining the `assign_<name>` command.
80
+ # @option options [Array<Symbol>] :skip_default_guards ([]) Default guards (e.g. `:not_removed`)
81
+ # that should not be auto-applied to the generated `assign_<name>` command. See
82
+ # {.removable} for context on the `:not_removed` auto-block.
79
83
  # @yield Block for defining guards and other attribute configurations.
80
84
  # @return [void]
85
+ #
86
+ # @example Skip the auto-injected :not_removed guard on a parent's assign command
87
+ # parent :tenant, skip_default_guards: %i[not_removed]
81
88
  def parent(name, **options, &)
82
89
  parent_aggregates[name] = options
83
90
 
@@ -85,7 +92,9 @@ module Yes
85
92
 
86
93
  return unless options.fetch(:command, true)
87
94
 
88
- command :"assign_#{name}" do
95
+ skip_default_guards = options[:skip_default_guards] || []
96
+
97
+ command :"assign_#{name}", skip_default_guards: do
89
98
  payload "#{name}_id": :uuid
90
99
 
91
100
  guard(:no_change) { public_send(:"#{name}_id") != payload.public_send(:"#{name}_id") }
@@ -103,7 +112,26 @@ module Yes
103
112
 
104
113
  # Defines a default removal behavior for the aggregate.
105
114
  #
115
+ # In addition to defining the `:remove` command, `removable` records aggregate-level
116
+ # configuration that the {Yes::Core::CommandHandling::GuardEvaluator} reads at runtime
117
+ # to **auto-block every other command on the aggregate** while the removal attribute is
118
+ # set. The auto-block fires before any registered guard (including the auto-injected
119
+ # `:no_change`), so post-remove mutations consistently raise
120
+ # `GuardEvaluator::InvalidTransition` with the i18n message under
121
+ # `aggregates.<context>.<aggregate>.commands.<command>.guards.not_removed.error`.
122
+ # The `:remove` command itself is exempt and remains gated only by `:no_change`.
123
+ #
124
+ # The auto-block is order-independent: `removable` may be declared before or after the
125
+ # other commands on the aggregate.
126
+ #
127
+ # `attr_name` must correspond to an attribute readable on the aggregate (the macro
128
+ # auto-defines it as `:datetime` when missing).
129
+ #
106
130
  # @param attr_name [Symbol] the attribute name to use for marking removal
131
+ # @param not_removed_guards [Boolean] when true (default), every non-`:remove` command on
132
+ # the aggregate auto-blocks while `attr_name` is set. Pass `false` to disable the
133
+ # auto-block aggregate-wide; individual commands can still opt in by defining their
134
+ # own `guard(:not_removed)`.
107
135
  # @yield Block for defining additional guards and other removal configurations
108
136
  # @return [void]
109
137
  #
@@ -124,16 +152,30 @@ module Yes
124
152
  # removable(attr_name: :deleted_at)
125
153
  # end
126
154
  #
127
- def removable(attr_name: :removed_at, &)
155
+ # @example Disable the :not_removed auto-block aggregate-wide
156
+ # class UserAggregate < Yes::Core::Aggregate
157
+ # removable(not_removed_guards: false)
158
+ # end
159
+ #
160
+ def removable(attr_name: :removed_at, not_removed_guards: true, &)
128
161
  attribute attr_name, :datetime unless attributes.key?(attr_name)
162
+ @removable_config = { attr_name:, not_removed_guards: }
129
163
 
130
- command :remove do
164
+ command :remove, skip_default_guards: %i[not_removed] do
131
165
  guard(:no_change) { !public_send(attr_name) }
132
166
  update_state { method(attr_name).call { Time.current } }
133
167
  instance_eval(&) if block_given?
134
168
  end
135
169
  end
136
170
 
171
+ # Returns the removable configuration for the aggregate, or nil if {.removable} was
172
+ # never called.
173
+ #
174
+ # @return [Hash{Symbol => Object}, nil] hash with two keys when set:
175
+ # * `:attr_name` [Symbol] — the attribute that marks removal (default `:removed_at`).
176
+ # * `:not_removed_guards` [Boolean] — whether the auto-block is enabled.
177
+ attr_reader :removable_config
178
+
137
179
  # Sets the primary context for the aggregate.
138
180
  #
139
181
  # @param context [String] The primary context to set.
@@ -239,12 +281,24 @@ module Yes
239
281
  # @example Define publish command an published attribute
240
282
  # command :publish
241
283
  #
242
- def command(*args, **, &)
243
- return handle_command_shortcut(*args, **, &) unless Dsl::CommandShortcutExpander.base_case?(*args, **, &)
284
+ # @example Skip the auto-injected :not_removed guard for a single command
285
+ # command :restore, skip_default_guards: %i[not_removed] do
286
+ # guard(:no_change) { removed_at.present? }
287
+ # update_state { removed_at { nil } }
288
+ # end
289
+ #
290
+ # All overloads accept a `skip_default_guards:` keyword argument carrying an array of
291
+ # default-guard symbols (currently only `:not_removed` — see {.removable}) that should
292
+ # not be auto-applied to the command. Defaults to `[]`.
293
+ #
294
+ def command(*args, **kwargs, &)
295
+ skip_default_guards = kwargs.delete(:skip_default_guards) || []
296
+ base_case = Dsl::CommandShortcutExpander.base_case?(*args, **kwargs, &)
297
+ return handle_command_shortcut(*args, skip_default_guards:, **kwargs, &) unless base_case
244
298
 
245
299
  name = args.first
246
300
  @commands ||= {}
247
- command_data = Dsl::CommandData.new(name, self, { context:, aggregate: })
301
+ command_data = Dsl::CommandData.new(name, self, { context:, aggregate:, skip_default_guards: })
248
302
  @commands[name] = command_data
249
303
 
250
304
  Dsl::CommandDefiner.new(command_data).call(&)
@@ -295,8 +349,8 @@ module Yes
295
349
  #
296
350
  # @return [void]
297
351
  #
298
- def handle_command_shortcut(...)
299
- expanded = Dsl::CommandShortcutExpander.new(...).call
352
+ def handle_command_shortcut(*, skip_default_guards: [], **, &)
353
+ expanded = Dsl::CommandShortcutExpander.new(*, **, &).call
300
354
 
301
355
  expanded.attributes.each do |specification|
302
356
  next if attributes.key?(specification.name)
@@ -305,7 +359,7 @@ module Yes
305
359
  end
306
360
 
307
361
  expanded.commands.each do |specification|
308
- command(specification.name, &specification.block)
362
+ command(specification.name, skip_default_guards:, &specification.block)
309
363
  end
310
364
  end
311
365
  end
@@ -52,6 +52,7 @@ module Yes
52
52
  # @raise [InvalidTransition] When a guard fails with an invalid transition
53
53
  # @raise [NoChangeTransition] When a guard fails with a no change transition
54
54
  def call
55
+ check_not_removed!
55
56
  self.class.guards.each do |name, guard_data|
56
57
  evaluate_guard(name, error_extra: guard_data[:error_extra], block: guard_data[:block])
57
58
  end
@@ -64,6 +65,28 @@ module Yes
64
65
 
65
66
  attr_reader :raw_payload, :raw_metadata, :payload, :aggregate, :aggregate_tracker, :command_name
66
67
 
68
+ # Pre-check that fires before any registered guard. Blocks every command on a removable
69
+ # aggregate once `removed_at` (or the configured attr) has been set, except where the
70
+ # command opts out via `skip_default_guards: %i[not_removed]`.
71
+ #
72
+ # The check runs ahead of the user-defined guards (including the auto-injected `:no_change`)
73
+ # so post-remove mutations consistently surface "X has been removed" instead of misleading
74
+ # downstream errors like "X does not change".
75
+ #
76
+ # @return [void]
77
+ # @raise [InvalidTransition] When the aggregate has been removed and the command is not opted out.
78
+ def check_not_removed!
79
+ config = aggregate.class.respond_to?(:removable_config) ? aggregate.class.removable_config : nil
80
+ return unless config && config[:not_removed_guards]
81
+
82
+ command_data = aggregate.class.commands[command_name]
83
+ return if command_data&.skip_default_guards&.include?(:not_removed)
84
+
85
+ return if aggregate.public_send(config[:attr_name]).blank?
86
+
87
+ raise InvalidTransition, error_message(:not_removed)
88
+ end
89
+
67
90
  # Evaluates a single guard and raises appropriate error if it fails
68
91
  #
69
92
  # @param name [Symbol] The name of the guard
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Middlewares
6
+ class << self
7
+ # Returns middleware keys excluding the specified one.
8
+ #
9
+ # @param middleware_name [Symbol] the middleware key to exclude
10
+ # @return [Array<Symbol>] remaining middleware keys
11
+ def without(middleware_name)
12
+ PgEventstore.config.middlewares.except(middleware_name).keys
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module TestSupport
6
+ module Aggregate
7
+ # DSL for writing concise aggregate command specs.
8
+ #
9
+ # Provides `command`, `success`, `invalid`, `no_change`, and `setup` methods
10
+ # that generate RSpec describe/context blocks with appropriate shared examples.
11
+ #
12
+ # @example
13
+ # RSpec.describe MyContext::MyAggregate::Aggregate, type: :aggregate do
14
+ # command 'do_something' do
15
+ # let(:command_data) { { name: 'test' } }
16
+ # let(:success_attributes) { { name: 'test' } }
17
+ #
18
+ # success
19
+ # invalid 'when precondition not met'
20
+ # no_change
21
+ # end
22
+ # end
23
+ module CommandTestDsl
24
+ # Defines a test block for a command
25
+ #
26
+ # @param command_name [String, Symbol] the name of the command to test
27
+ # @param options [Array<Hash>] additional options (e.g., `draft: true`, VCR cassettes)
28
+ # @yield block for configuring test cases with success/invalid/no_change
29
+ def command(command_name, *options, &block)
30
+ describe command_name.to_s, *options do
31
+ let(:draft) { options.first&.dig(:draft) }
32
+
33
+ let(:aggregate) { described_class.new(draft:) } unless method_defined?(:aggregate)
34
+
35
+ subject { aggregate.public_send(command, command_data, guards: !draft) }
36
+
37
+ let(:command) { command_name.to_sym }
38
+ let(:aggregate_class) { aggregate.class }
39
+ let(:command_data_with_id) do
40
+ { "#{aggregate_class.aggregate.underscore}_id" => aggregate.id }.merge(command_data)
41
+ end
42
+ let(:command_data) { {} }
43
+ let(:expected_event_type) do
44
+ "#{aggregate_class.context}::#{aggregate_class.aggregate}" \
45
+ "#{'Draft' if draft}#{aggregate_class.commands[command].event_name.to_s.classify}"
46
+ end
47
+ let(:expected_event_data) { command_data_with_id }
48
+ let(:expected_event_metadata) { nil }
49
+ let(:success_attributes) { command_data.without(:locale) } unless method_defined?(:success_attributes)
50
+
51
+ class_eval(&block) if block_given?
52
+ end
53
+ end
54
+
55
+ # Defines a test case for a successful command execution
56
+ #
57
+ # @param description [String] optional description
58
+ # @param options [Hash] additional options (e.g., VCR cassettes)
59
+ # @yield optional block for additional setup or custom assertions
60
+ def success(description = 'when successfully executing command', options = {}, &block)
61
+ context description, options do
62
+ instance_eval(&block) if block_given?
63
+
64
+ it_behaves_like 'successful command'
65
+ end
66
+ end
67
+
68
+ # Defines a test case for a command that causes no state change
69
+ #
70
+ # @param description [String] optional description
71
+ # @param options [Hash] additional options
72
+ # @yield optional block for additional setup
73
+ def no_change(description = 'when command causes no change', options = {}, &block)
74
+ context description.to_s, options do
75
+ instance_eval(&block) if block_given?
76
+
77
+ before { aggregate.public_send(command, command_data) }
78
+
79
+ it_behaves_like 'no change transition'
80
+ end
81
+ end
82
+
83
+ # Defines a test case for an invalid transition
84
+ #
85
+ # @param description [String] description of the invalid scenario
86
+ # @param options [Hash] additional options
87
+ # @yield optional block for additional setup
88
+ def invalid(description, options = {}, &block)
89
+ context "when #{description}", options do
90
+ instance_eval(&block) if block_given?
91
+
92
+ it_behaves_like 'invalid transition'
93
+ end
94
+ end
95
+
96
+ # Alias for `before` — used for readable aggregate setup within command blocks
97
+ #
98
+ # @yield block for setup actions
99
+ def setup(&)
100
+ before(&)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ if defined?(RSpec)
109
+ RSpec.configure do |config|
110
+ config.extend Yes::Core::TestSupport::Aggregate::CommandTestDsl, type: :aggregate
111
+ end
112
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec::Matchers.define :have_authorizer do
4
+ match do |actual|
5
+ actual&.authorizer_class&.< Yes::Core::Authorization::CommandAuthorizer
6
+ end
7
+
8
+ description do
9
+ 'have an authorizer'
10
+ end
11
+ end
12
+
13
+ RSpec::Matchers.define :have_read_model_class do |read_model_class|
14
+ match do |aggregate|
15
+ aggregate.read_model_class == read_model_class
16
+ end
17
+
18
+ failure_message do |aggregate|
19
+ "expected #{aggregate} to have read model class #{read_model_class}" \
20
+ "\n actual read model class is #{aggregate.read_model_class}"
21
+ end
22
+ end
23
+
24
+ RSpec::Matchers.define :have_cerbos_authorizer do
25
+ match do |aggregate|
26
+ next false unless aggregate.authorizer_class&.< Yes::Core::Authorization::CommandCerbosAuthorizer
27
+
28
+ next false if @read_model_class && aggregate.authorizer_options&.read_model_class != @read_model_class
29
+
30
+ next false if @resource_name && aggregate.authorizer_options&.resource_name != @resource_name
31
+
32
+ true
33
+ end
34
+
35
+ chain :with_read_model_class do |read_model_class|
36
+ @read_model_class = read_model_class
37
+ end
38
+
39
+ chain :with_resource_name do |resource_name|
40
+ @resource_name = resource_name
41
+ end
42
+
43
+ description do
44
+ msg = 'have a Cerbos authorizer'
45
+ msg += " with read model class #{@read_model_class}" if @read_model_class
46
+ msg += " with resource name #{@resource_name}" if @resource_name
47
+ msg
48
+ end
49
+
50
+ failure_message do |aggregate|
51
+ msg = "expected #{aggregate} to have a Cerbos authorizer"
52
+ msg += "\n with read model class #{@read_model_class}" if @read_model_class
53
+ msg += "\n with resource name #{@resource_name}" if @resource_name
54
+ msg += "\n actual read model class is #{aggregate.authorizer_options&.read_model_class}" if @read_model_class
55
+ msg += "\n actual resource name is #{aggregate.authorizer_options&.resource_name}" if @resource_name
56
+ msg
57
+ end
58
+ end
59
+
60
+ RSpec::Matchers.define :have_parent do |parent_name|
61
+ match do |aggregate|
62
+ @parent = aggregate.parent_aggregates.with_indifferent_access[parent_name]
63
+
64
+ return false unless @parent
65
+
66
+ return true unless @context
67
+
68
+ @parent[:context] == @context
69
+ end
70
+
71
+ chain :with_context do |context|
72
+ @context = context
73
+ end
74
+
75
+ failure_message do |aggregate|
76
+ msg = "expected #{aggregate} to have parent #{parent_name}"
77
+ msg += "\n with context #{@context}" if @context
78
+ msg += "\n actual context is #{@parent[:context].presence || aggregate.context}" if @parent && @context
79
+ msg
80
+ end
81
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context 'with given events' do
4
+ before do
5
+ given_events.each do |event_data|
6
+ event = event_instance(event_data)
7
+ stream = event_stream(event_data)
8
+ append_event(stream, event)
9
+ end
10
+ end
11
+ end
12
+
13
+ RSpec.shared_examples 'successful command' do
14
+ let(:event) { aggregate.events.map(&:last).last }
15
+ let(:expected_event_metadata) { nil }
16
+
17
+ it 'correctly changes the aggregate state' do
18
+ if success_attributes.any?
19
+ expect { subject }.to change {
20
+ aggregate.read_model.attributes.to_h.symbolize_keys.slice(*success_attributes.keys)
21
+ }.to(success_attributes)
22
+ end
23
+ end
24
+
25
+ it 'publishes expected event' do
26
+ subject
27
+ aggregate_failures do
28
+ expect(event.type).to eq(expected_event_type)
29
+ expect(event.data).to eq(expected_event_data.deep_stringify_keys)
30
+ expect(event.metadata).to include(expected_event_metadata.deep_stringify_keys) if expected_event_metadata
31
+ end
32
+ end
33
+ end
34
+
35
+ RSpec.shared_examples 'invalid transition' do
36
+ it 'raises InvalidTransition error' do
37
+ expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition)
38
+ end
39
+
40
+ it 'does not change the aggregate state' do
41
+ success_attributes.each_key do |attribute|
42
+ expect { subject }.not_to(change { aggregate.public_send(attribute) })
43
+ end
44
+ end
45
+
46
+ it 'does not publish event' do
47
+ expect { subject }.not_to(
48
+ change do
49
+ aggregate.events.map(&:flatten).flatten.count
50
+ rescue PgEventstore::StreamNotFoundError
51
+ nil
52
+ end
53
+ )
54
+ end
55
+ end
56
+
57
+ RSpec.shared_examples 'no change transition' do
58
+ it 'raises NoChangeTransition error' do
59
+ expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition)
60
+ end
61
+
62
+ it 'does not change the aggregate state' do
63
+ success_attributes.each_key do |attribute|
64
+ expect { subject }.not_to(change { aggregate.public_send(attribute) })
65
+ end
66
+ end
67
+
68
+ it 'does not publish event' do
69
+ expect { subject }.not_to(
70
+ change do
71
+ aggregate.events.map(&:flatten).flatten.count
72
+ rescue PgEventstore::StreamNotFoundError
73
+ nil
74
+ end
75
+ )
76
+ end
77
+ end
@@ -4,16 +4,33 @@ module Yes
4
4
  module Core
5
5
  module TestSupport
6
6
  # Helpers for working with PgEventstore events in tests.
7
+ #
8
+ # @example Include in RSpec
9
+ # RSpec.configure do |config|
10
+ # config.include Yes::Core::TestSupport::EventHelpers
11
+ # end
7
12
  module EventHelpers
13
+ # Appends an event to a stream
14
+ #
15
+ # @param stream [PgEventstore::Stream]
16
+ # @param event [Yes::Core::Event]
17
+ # @return [void]
18
+ def append_event(stream, event)
19
+ PgEventstore.client.append_to_stream(stream, event)
20
+ end
21
+
22
+ # Appends an event to a stream and reloads it
23
+ #
8
24
  # @param stream [PgEventstore::Stream]
9
25
  # @param event [Yes::Core::Event]
10
26
  # @return [Yes::Core::Event]
11
27
  def append_and_reload_event(stream, event)
12
- PgEventstore.client.append_to_stream(stream, event)
28
+ append_event(stream, event)
13
29
  PgEventstore.client.read(stream, options: { max_count: 1, direction: :desc }).first
14
30
  end
15
31
 
16
32
  # Reads eventstore and returns events from the stream or an empty array if stream does not exist
33
+ #
17
34
  # @param stream [PgEventstore::Stream]
18
35
  # @return [Array<Yes::Core::Event>]
19
36
  def safe_read(stream)
@@ -21,6 +38,61 @@ module Yes
21
38
  rescue PgEventstore::StreamNotFoundError
22
39
  []
23
40
  end
41
+
42
+ alias read_events safe_read
43
+
44
+ # Creates events from a block and appends them to the eventstore
45
+ #
46
+ # @yield block that returns an array of event attribute hashes
47
+ # @yieldreturn [Array<Hash>] each hash should have :context, :aggregate, :event, :data keys
48
+ # @return [void]
49
+ #
50
+ # @example
51
+ # given_events do
52
+ # [{ context: 'MyContext', aggregate: 'MyAggregate', event: 'Created', data: { id: '123' } }]
53
+ # end
54
+ def given_events(&)
55
+ events_data = yield
56
+ events_data.each do |event_data|
57
+ event = event_instance(event_data)
58
+ stream = event_stream(event_data)
59
+ append_event(stream, event)
60
+ end
61
+ end
62
+
63
+ # Builds a Yes::Core::Event from a hash of event attributes
64
+ #
65
+ # @param event_attrs [Hash] event attributes with :context, :aggregate, :event, :data keys
66
+ # @return [Yes::Core::Event]
67
+ def event_instance(event_attrs)
68
+ Yes::Core::Event.new(
69
+ type: event_type(event_attrs),
70
+ data: (event_attrs[:data] || {}).with_indifferent_access
71
+ )
72
+ end
73
+
74
+ # Builds a PgEventstore::Stream from event attributes
75
+ #
76
+ # @param event_attrs [Hash] event attributes with :context, :aggregate, :data keys
77
+ # @return [PgEventstore::Stream]
78
+ def event_stream(event_attrs)
79
+ return event_attrs[:stream] if event_attrs[:stream]
80
+
81
+ PgEventstore::Stream.new(
82
+ context: event_attrs[:context],
83
+ stream_name: event_attrs[:aggregate],
84
+ stream_id: event_attrs[:data].first.last
85
+ )
86
+ end
87
+
88
+ # Constructs an event type string from event attributes
89
+ #
90
+ # @param event_attrs [Hash] event attributes with :context, :aggregate/:subject, :event keys
91
+ # @return [String]
92
+ def event_type(event_attrs)
93
+ aggregate_or_subject = event_attrs[:aggregate] || event_attrs[:subject]
94
+ "#{event_attrs[:context]}::#{aggregate_or_subject}#{event_attrs[:event]}"
95
+ end
24
96
  end
25
97
  end
26
98
  end
@@ -3,3 +3,6 @@
3
3
  require_relative 'test_support/event_helpers'
4
4
  require_relative 'test_support/jwt_helpers'
5
5
  require_relative 'test_support/test_helper'
6
+ require_relative 'test_support/aggregate/command_test_dsl'
7
+ require_relative 'test_support/aggregate/shared_examples'
8
+ require_relative 'test_support/aggregate/matchers'
@@ -3,14 +3,20 @@
3
3
  module Yes
4
4
  module Core
5
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)
6
+ # Provides convenient shortcuts for accessing aggregate classes in Rails console.
7
+ # @example Multi-capital subjects use capitals-only abbreviations
8
+ # # Instead of: ApprenticeshipPresentation::ContactInfo::Aggregate.new(id)
9
+ # # Use: AP::CI.new(id)
10
+ # @example Single-capital subjects keep the full name
11
+ # # Instead of: TaskFlow::Board::Aggregate.new(id)
12
+ # # Use: TF::Board.new(id)
10
13
  class AggregateShortcuts
11
14
  class << self
12
- # Load aggregate shortcuts in Rails console
13
- # Creates module aliases and constants for convenient access
15
+ # Load aggregate shortcuts in Rails console.
16
+ # Creates fresh shortcut modules (e.g. TF) and assigns aggregate classes
17
+ # as constants on them. Shortcut modules are NOT aliases of the real
18
+ # context modules, so shortcut constants cannot collide with the
19
+ # aggregates' own namespace modules.
14
20
  def load!
15
21
  return unless Yes::Core.configuration.aggregate_shortcuts
16
22
 
@@ -32,26 +38,35 @@ module Yes
32
38
  results
33
39
  end
34
40
 
35
- # Display shortcuts in a formatted table
41
+ # Display shortcuts in a formatted table.
42
+ #
43
+ # Writes directly to STDOUT (via Kernel#puts) rather than the Rails logger
44
+ # so the output is readable in any environment — Rails consoles in
45
+ # production typically configure structured / JSON loggers that would
46
+ # otherwise wrap each line in a JSON envelope.
47
+ #
36
48
  # @param filter [String, nil] Optional filter
49
+ # rubocop:disable Rails/Output
37
50
  def display(filter = nil)
38
51
  shortcuts = list(filter)
39
52
 
40
53
  if shortcuts.empty?
41
- Rails.logger.debug { "No shortcuts found#{" for '#{filter}'" if filter}." }
54
+ puts "No shortcuts found#{" for '#{filter}'" if filter}."
42
55
  return
43
56
  end
44
57
 
45
58
  max_shortcut_length = shortcuts.keys.map(&:length).max
59
+ separator = '=' * (max_shortcut_length + 70)
46
60
 
47
- Rails.logger.debug "\nAvailable Aggregate Shortcuts:"
48
- Rails.logger.debug '=' * (max_shortcut_length + 70)
61
+ puts "\nAvailable Aggregate Shortcuts:"
62
+ puts separator
49
63
  shortcuts.sort.each do |shortcut, full_path|
50
- Rails.logger.debug "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
64
+ puts "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
51
65
  end
52
- Rails.logger.debug '=' * (max_shortcut_length + 70)
53
- Rails.logger.debug { "\nUsage: #{shortcuts.keys.first}.new(id)" } if shortcuts.any?
66
+ puts separator
67
+ puts "\nUsage: #{shortcuts.keys.first}.new(id)"
54
68
  end
69
+ # rubocop:enable Rails/Output
55
70
 
56
71
  private
57
72
 
@@ -102,19 +117,22 @@ module Yes
102
117
  context_abbr = abbreviate_context(agg[:context])
103
118
  subject_abbr = abbreviate_subject(agg[:subject])
104
119
 
105
- # Create context module alias if not exists
120
+ # Build (or reuse) a fresh container module for the context shortcut.
121
+ # We deliberately do NOT alias the real context module: doing so would
122
+ # mean shortcut constants (e.g. TF::Board) collide with the real
123
+ # namespace modules of the aggregates themselves (TaskFlow::Board).
106
124
  unless context_modules[context_abbr]
107
125
  if Object.const_defined?(context_abbr)
108
126
  Rails.logger.warn("Shortcut conflict: #{context_abbr} already defined, skipping #{agg[:context]}")
109
127
  next
110
128
  end
111
129
 
112
- context_module = agg[:context].constantize
113
- Object.const_set(context_abbr, context_module)
114
- context_modules[context_abbr] = context_module
130
+ shortcut_module = Module.new
131
+ Object.const_set(context_abbr, shortcut_module)
132
+ context_modules[context_abbr] = shortcut_module
115
133
  end
116
134
 
117
- # Create subject constant within context module
135
+ # Create subject constant within the shortcut container.
118
136
  context_mod = context_modules[context_abbr]
119
137
  if context_mod.const_defined?(subject_abbr)
120
138
  Rails.logger.warn("Shortcut conflict: #{context_abbr}::#{subject_abbr} already defined")
@@ -140,12 +158,15 @@ module Yes
140
158
  def abbreviate_subject(subject)
141
159
  return @subject_overrides[subject] if @subject_overrides[subject]
142
160
 
143
- # First try capital letters
161
+ # Multi-capital CamelCase names get a capitals-only abbreviation
162
+ # (ContactInfo → CI). Single-capital names (Task, Board, Location)
163
+ # use the full subject name to avoid awkward truncations like
164
+ # "Boar" or shortcut collisions when the truncation matches the
165
+ # subject's own namespace module (e.g. TaskFlow::Task).
144
166
  capitals = subject.scan(/[A-Z]/).join
145
167
  return capitals if capitals.length > 1
146
168
 
147
- # Otherwise use first 4 characters
148
- subject[0..3]
169
+ subject
149
170
  end
150
171
 
151
172
  def define_helper_method
@@ -68,7 +68,7 @@ module Yes
68
68
  def build_event(command_name:, payload:, metadata: {})
69
69
  event_class = Yes::Core.configuration.event_classes_for_command(context, aggregate, command_name).first
70
70
  event_class.new(
71
- type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata)}#{event_class.name.demodulize}",
71
+ type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata, context:)}#{event_class.name.demodulize}",
72
72
  data: payload,
73
73
  metadata:
74
74
  )
@@ -83,7 +83,7 @@ module Yes
83
83
  def build_stream(context: @context, name: @aggregate, id: @aggregate_id, metadata: {})
84
84
  PgEventstore::Stream.new(
85
85
  context:,
86
- stream_name: aggregate_name_with_draft_suffix(name, metadata),
86
+ stream_name: aggregate_name_with_draft_suffix(name, metadata, context:),
87
87
  stream_id: id
88
88
  )
89
89
  end
@@ -209,16 +209,33 @@ module Yes
209
209
  payload.merge(locale: I18n.locale.to_s)
210
210
  end
211
211
 
212
- # Builds the aggregate name with the draft suffix
212
+ # Builds the aggregate name with the draft suffix.
213
+ #
214
+ # When the aggregate class is configured with `draftable changes_read_model:` (explicitly),
215
+ # the camelized changes_read_model is used as the stream / event-type prefix so the DSL
216
+ # config decides where draft events land. This lets an aggregate share an edit-template
217
+ # stream with a sibling (e.g. `Recruiter` writes to `UserEditTemplate` via
218
+ # `changes_read_model: :user_edit_template`).
219
+ #
220
+ # For non-draftable aggregates, and for draftable aggregates that rely on the default
221
+ # `<read_model>_change` changes_read_model name, the legacy hard-coded `<Aggregate>Draft`
222
+ # / `<Aggregate>EditTemplate` suffix is used.
213
223
  #
214
224
  # @param aggregate_name [String] The name of the aggregate
215
225
  # @param metadata [Hash] The command metadata
216
- # @return [String] The stream name
217
- def aggregate_name_with_draft_suffix(aggregate_name, metadata = {})
218
- return "#{aggregate_name}Draft" if metadata&.dig(:draft)
219
- return "#{aggregate_name}EditTemplate" if metadata&.dig(:edit_template_command)
220
-
221
- aggregate_name
226
+ # @param context [String] The aggregate's context (defaults to @context)
227
+ # @return [String] The stream / event-type name component
228
+ def aggregate_name_with_draft_suffix(aggregate_name, metadata = {}, context: @context)
229
+ return aggregate_name unless metadata&.dig(:draft) || metadata&.dig(:edit_template_command)
230
+
231
+ klass = "#{context}::#{aggregate_name}::Aggregate".safe_constantize
232
+ if klass.respond_to?(:_changes_read_model_explicit) && klass._changes_read_model_explicit
233
+ klass.changes_read_model_name.camelize
234
+ elsif metadata&.dig(:edit_template_command)
235
+ "#{aggregate_name}EditTemplate"
236
+ else
237
+ "#{aggregate_name}Draft"
238
+ end
222
239
  end
223
240
  end
224
241
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '1.0.0'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
data/lib/yes/core.rb CHANGED
@@ -19,6 +19,7 @@ module Yes
19
19
  loader.push_dir(File.expand_path('..', __dir__))
20
20
  loader.ignore("#{__dir__}/core/version.rb")
21
21
  loader.ignore("#{__dir__}/core/test_support")
22
+ loader.ignore("#{__dir__}/core/test_support.rb")
22
23
  loader.collapse("#{__dir__}/core/models")
23
24
  loader.setup
24
25
  loader
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yes-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nico Ritsche
@@ -259,6 +259,7 @@ files:
259
259
  - lib/yes/core/generators/read_models/templates/migration.rb.erb
260
260
  - lib/yes/core/generators/read_models/update_generator.rb
261
261
  - lib/yes/core/jobs/read_model_recovery_job.rb
262
+ - lib/yes/core/middlewares.rb
262
263
  - lib/yes/core/middlewares/encryptor.rb
263
264
  - lib/yes/core/middlewares/timestamp.rb
264
265
  - lib/yes/core/middlewares/with_indifferent_access.rb
@@ -281,6 +282,9 @@ files:
281
282
  - lib/yes/core/serializer.rb
282
283
  - lib/yes/core/subscriptions.rb
283
284
  - lib/yes/core/test_support.rb
285
+ - lib/yes/core/test_support/aggregate/command_test_dsl.rb
286
+ - lib/yes/core/test_support/aggregate/matchers.rb
287
+ - lib/yes/core/test_support/aggregate/shared_examples.rb
284
288
  - lib/yes/core/test_support/event_helpers.rb
285
289
  - lib/yes/core/test_support/jwt_helpers.rb
286
290
  - lib/yes/core/test_support/subscriptions_helper.rb