yes-core 1.1.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: b3e28057d0c8b0fd6c0bc4f4692c55609b353258637320027efe1d2bcbb02841
4
- data.tar.gz: fdb462463528fdbecf3df473f5c0f98601a8d301ec7e4d52f98ec0b2aabdc0f3
3
+ metadata.gz: 684a9c20dfd17ba779b196a7b294b93e04bf531bcc3d31a2adc9f8a4d645b7dd
4
+ data.tar.gz: 43e482658ce60a8c7fe4eb5f73f3bf084169857bd06a57a01aadf5ab7cf98e76
5
5
  SHA512:
6
- metadata.gz: 415e51919c284f2207fbd6ee6dfd9a9427830a6f20ab88986a69b94c14c62b15a75d0fe19360d58088a3d4de8beec953fa78e69c180458874c1d639d677fd9e0
7
- data.tar.gz: 85a8557a897a3c2d02b315bf1e637f2bd6837300bf65b71430c314d9a047b9663cf8d33cfaeb7a681ea2bb5eaaf645a4297e351c3304abd56c8026a03d978792
6
+ metadata.gz: 335785676b2baef08117225b60c3a3b170bb39b0c3fe8ffdfa2781c158944b562f53d00884f409fb0c07c15ace4b513c02bd042973da57118ec325701a258acb
7
+ data.tar.gz: 72bc5b16b03b00ddc6211806d2566ad5213a25e452836e1ffd09f1e89c10154e98e47edb5ac75573092b1c21ea13cc38f5f49f379f418c2e08c2532d2e498670
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-04-30
4
+
5
+ - See root CHANGELOG.md for details.
6
+
3
7
  ## [1.1.0] - 2026-04-28
4
8
 
5
9
  - See root CHANGELOG.md for details.
@@ -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
@@ -38,26 +38,35 @@ module Yes
38
38
  results
39
39
  end
40
40
 
41
- # 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
+ #
42
48
  # @param filter [String, nil] Optional filter
49
+ # rubocop:disable Rails/Output
43
50
  def display(filter = nil)
44
51
  shortcuts = list(filter)
45
52
 
46
53
  if shortcuts.empty?
47
- Rails.logger.debug { "No shortcuts found#{" for '#{filter}'" if filter}." }
54
+ puts "No shortcuts found#{" for '#{filter}'" if filter}."
48
55
  return
49
56
  end
50
57
 
51
58
  max_shortcut_length = shortcuts.keys.map(&:length).max
59
+ separator = '=' * (max_shortcut_length + 70)
52
60
 
53
- Rails.logger.debug "\nAvailable Aggregate Shortcuts:"
54
- Rails.logger.debug '=' * (max_shortcut_length + 70)
61
+ puts "\nAvailable Aggregate Shortcuts:"
62
+ puts separator
55
63
  shortcuts.sort.each do |shortcut, full_path|
56
- Rails.logger.debug "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
64
+ puts "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
57
65
  end
58
- Rails.logger.debug '=' * (max_shortcut_length + 70)
59
- Rails.logger.debug { "\nUsage: #{shortcuts.keys.first}.new(id)" } if shortcuts.any?
66
+ puts separator
67
+ puts "\nUsage: #{shortcuts.keys.first}.new(id)"
60
68
  end
69
+ # rubocop:enable Rails/Output
61
70
 
62
71
  private
63
72
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
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.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nico Ritsche