hecks 1.0.2 → 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 +4 -4
- data/lib/hecks/adapters/driven/claude_code.rb +3 -3
- data/lib/hecks/adapters/driven/d1.rb +48 -28
- data/lib/hecks/adapters/driven/folder.rb +10 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +3 -5
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
- data/lib/hecks/adapters/driven/lambda.rb +2 -2
- data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
- data/lib/hecks/adapters/driven/local_storage.rb +120 -0
- data/lib/hecks/adapters/driven/memory.rb +48 -3
- data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
- data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
- data/lib/hecks/adapters/driven/postgres.rb +45 -31
- data/lib/hecks/adapters/driven/prism.rb +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
- data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
- data/lib/hecks/adapters/driven/sqlite.rb +87 -8
- data/lib/hecks/adapters/driven.rb +4 -0
- data/lib/hecks/adapters.rb +4 -0
- data/lib/hecks/behaviors/dsl.rb +7 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/behaviors/rspec.rb +2 -0
- data/lib/hecks/behaviors/runner.rb +4 -0
- data/lib/hecks/bluebook/aggregate.rb +6 -5
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
- data/lib/hecks/bluebook/assembly/build.rb +6 -3
- data/lib/hecks/bluebook/assembly/contract.rb +5 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
- data/lib/hecks/bluebook/assembly/marks.rb +15 -4
- data/lib/hecks/bluebook/assembly.rb +1 -3
- data/lib/hecks/bluebook/attribute.rb +6 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
- data/lib/hecks/bluebook/command.rb +6 -5
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
- data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
- data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
- data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
- data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
- data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
- data/lib/hecks/bluebook/entity.rb +5 -3
- data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
- data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
- data/lib/hecks/bluebook/expression/projection.json +12 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
- data/lib/hecks/bluebook/expression/resolver.rb +83 -11
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +10 -0
- data/lib/hecks/bluebook/lifecycle.rb +9 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
- data/lib/hecks/bluebook/meta_validator.rb +5 -5
- data/lib/hecks/bluebook/model_check.rb +119 -82
- data/lib/hecks/bluebook/pattern_subset.rb +9 -0
- data/lib/hecks/bluebook/policy.rb +2 -1
- data/lib/hecks/bluebook/process_manager.rb +6 -0
- data/lib/hecks/bluebook/project_register.rb +45 -25
- data/lib/hecks/bluebook/query.rb +4 -0
- data/lib/hecks/bluebook/read_model.rb +2 -2
- data/lib/hecks/bluebook/smoke_test.rb +5 -0
- data/lib/hecks/bluebook/synthesizer.rb +2 -1
- data/lib/hecks/bluebook/translation.rb +3 -0
- data/lib/hecks/bluebook/value_object.rb +2 -2
- data/lib/hecks/codemod.rb +13 -0
- data/lib/hecks/doc/reference.rb +17 -11
- data/lib/hecks/facade/cli_runner.rb +2 -2
- data/lib/hecks/facade/command_request.rb +11 -11
- data/lib/hecks/facade/handle.rb +3 -3
- data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
- data/lib/hecks/facade.rb +4 -0
- data/lib/hecks/forms/app.rb +10 -6
- data/lib/hecks/forms/command_form_renderer.rb +3 -3
- data/lib/hecks/forms/field_renderer.rb +14 -9
- data/lib/hecks/forms/field_shape.rb +7 -2
- data/lib/hecks/forms/html.rb +5 -2
- data/lib/hecks/forms/index_renderer.rb +1 -1
- data/lib/hecks/forms/page.rb +7 -3
- data/lib/hecks/forms/query_form_renderer.rb +3 -2
- data/lib/hecks/forms/record_renderer.rb +22 -16
- data/lib/hecks/forms.rb +4 -0
- data/lib/hecks/fqn.rb +21 -9
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
- data/lib/hecks/fuzzing/properties/guards.rb +251 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
- data/lib/hecks/fuzzing/properties/querying.rb +206 -0
- data/lib/hecks/fuzzing/properties.rb +41 -1065
- data/lib/hecks/fuzzing/replay.rb +60 -16
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
- data/lib/hecks/fuzzing/value_generator.rb +2 -2
- data/lib/hecks/grammar/evolve.rb +34 -10
- data/lib/hecks/grammar/expression_operators.json +124 -0
- data/lib/hecks/grammar.rb +15 -5
- data/lib/hecks/ir.rb +9 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
- data/lib/hecks/language/bluebook/command.bluebook +2 -1
- data/lib/hecks/language/bluebook/entity.bluebook +2 -1
- data/lib/hecks/language/bluebook/projection.bluebook +2 -1
- data/lib/hecks/language/bluebook/query.bluebook +4 -2
- data/lib/hecks/language/bluebook/shape.bluebook +2 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
- data/lib/hecks/literal.rb +18 -8
- data/lib/hecks/naming.rb +6 -3
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.rb +5 -3
- data/lib/hecks/ports/authentication.rb +3 -2
- data/lib/hecks/ports/authorization.rb +1 -1
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.rb +5 -1
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/loading.rb +4 -1
- data/lib/hecks/ports/persistence/append_only.rb +51 -4
- data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
- data/lib/hecks/ports/persistence/plugin.rb +3 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
- data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
- data/lib/hecks/ports/persistence.rb +7 -3
- data/lib/hecks/ports/projection.rb +13 -5
- data/lib/hecks/ports/query/in_memory.rb +8 -2
- data/lib/hecks/ports/query.rb +1 -3
- data/lib/hecks/ports.rb +2 -0
- data/lib/hecks/projections/diagrams.rb +11 -3
- data/lib/hecks/projections/glossary.rb +374 -0
- data/lib/hecks/projections/ir.rb +2 -0
- data/lib/hecks/projections/model/deviations.rb +2 -1
- data/lib/hecks/projections/model.rb +15 -10
- data/lib/hecks/projections/vocabulary.rb +1 -3
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +78 -42
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +34 -1
- data/lib/hecks/projector/narrate_projector.rb +83 -25
- data/lib/hecks/projector.rb +2 -2
- data/lib/hecks/query_ir.rb +60 -36
- data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
- data/lib/hecks/query_specification/common/comparators.rb +4 -0
- data/lib/hecks/query_specification/common/comparison.rb +8 -1
- data/lib/hecks/query_specification/common/dsl.rb +5 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/query_specification/field_path.rb +4 -4
- data/lib/hecks/query_specification/read_model/specification.rb +5 -2
- data/lib/hecks/router/namespace_installer.rb +22 -10
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
- data/lib/hecks/runtime/command_interpreter.rb +104 -65
- data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
- data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
- data/lib/hecks/runtime/dependency_planning.rb +33 -21
- data/lib/hecks/runtime/dispatcher.rb +41 -7
- data/lib/hecks/runtime/entity_element.rb +25 -6
- data/lib/hecks/runtime/entity_interpreter.rb +28 -15
- data/lib/hecks/runtime/errors.rb +28 -2
- data/lib/hecks/runtime/instance.rb +9 -3
- data/lib/hecks/runtime/interpreting.rb +45 -9
- data/lib/hecks/runtime/loader.rb +21 -0
- data/lib/hecks/runtime/outbox.rb +365 -0
- data/lib/hecks/runtime/policy_interpreter.rb +32 -17
- data/lib/hecks/runtime/query_interpreter.rb +36 -16
- data/lib/hecks/runtime/reaction_invocation.rb +35 -22
- data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
- data/lib/hecks/runtime/reference_hop.rb +1 -1
- data/lib/hecks/runtime/refusal_wording.rb +55 -49
- data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
- data/lib/hecks/runtime/registry/verification.rb +39 -2
- data/lib/hecks/runtime/registry.rb +21 -11
- data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
- data/lib/hecks/runtime/routing.rb +52 -39
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
- data/lib/hecks/runtime/saga_interpreter.rb +119 -90
- data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
- data/lib/hecks/runtime/tenant_scope.rb +7 -0
- data/lib/hecks/runtime/value/coercion.rb +271 -49
- data/lib/hecks/runtime/value.rb +1 -1
- data/lib/hecks/runtime.rb +7 -2
- data/lib/hecks/storehouse.rb +15 -8
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -0
- data/lib/hecks.rb +17 -13
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
- metadata +16 -2
|
@@ -111,9 +111,7 @@ module Hecks
|
|
|
111
111
|
op = sign.positive? ? "increment" : "decrement"
|
|
112
112
|
current ||= 0
|
|
113
113
|
|
|
114
|
-
if current.is_a?(Value) && amount.is_a?(Value)
|
|
115
|
-
return arithmetic_value_object(current, amount, target, sign, op)
|
|
116
|
-
end
|
|
114
|
+
return arithmetic_value_object(current, amount, target, sign, op) if current.is_a?(Value) && amount.is_a?(Value)
|
|
117
115
|
|
|
118
116
|
# `current` genuinely absent (no declared default, never set) and
|
|
119
117
|
# `amount` arrives VO-wrapped — a real command argument typed the
|
|
@@ -145,10 +143,30 @@ module Hecks
|
|
|
145
143
|
op: op, target: target, offered: Rendering.describe(current))
|
|
146
144
|
end
|
|
147
145
|
|
|
148
|
-
current + (sign * amount)
|
|
146
|
+
bounded(current + (sign * amount), op, current, sign * amount, sign.positive? ? "+" : "-")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# C3.3/C3.4 — an effect's arithmetic is held to the same value
|
|
150
|
+
# model an expression's is: Integer is signed 64-bit, Float is
|
|
151
|
+
# finite. A result outside that is an evaluation FAULT (never a
|
|
152
|
+
# refusal, C8.3), worded as the Rust kernel's own generated
|
|
153
|
+
# `checked_add`/`checked_sub`/`checked_mul` word it.
|
|
154
|
+
INT64_RANGE = (-(2**63))..((2**63) - 1)
|
|
155
|
+
|
|
156
|
+
def bounded(result, oper, lhs, rhs, symbol)
|
|
157
|
+
if result.is_a?(Integer)
|
|
158
|
+
return result if INT64_RANGE.cover?(result)
|
|
159
|
+
|
|
160
|
+
raise Bluebook::Expression::EvaluationError,
|
|
161
|
+
"#{oper} overflowed: #{lhs} #{symbol} #{rhs.abs} does not fit in a 64-bit integer"
|
|
162
|
+
end
|
|
163
|
+
return result unless result.is_a?(Float) && !result.finite?
|
|
164
|
+
|
|
165
|
+
raise Bluebook::Expression::EvaluationError,
|
|
166
|
+
"#{oper} overflowed: #{lhs} #{symbol} #{rhs.abs} is not a finite number"
|
|
149
167
|
end
|
|
150
168
|
|
|
151
|
-
def arithmetic_value_object(current, amount, target, sign,
|
|
169
|
+
def arithmetic_value_object(current, amount, target, sign, oper)
|
|
152
170
|
current_fields = current.to_h
|
|
153
171
|
amount_fields = amount.to_h
|
|
154
172
|
# Widened from Integer to Numeric -- see #arithmetic's own
|
|
@@ -162,11 +180,12 @@ module Hecks
|
|
|
162
180
|
end
|
|
163
181
|
unless shared_numeric.size == 1
|
|
164
182
|
raise TypeMismatch,
|
|
165
|
-
RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op:
|
|
183
|
+
RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: oper, target: target)
|
|
166
184
|
end
|
|
167
185
|
|
|
168
186
|
field = shared_numeric.first
|
|
169
|
-
current.with(field, current[field] + (sign * amount[field])
|
|
187
|
+
current.with(field, bounded(current[field] + (sign * amount[field]), oper,
|
|
188
|
+
current[field], amount[field], sign.positive? ? "+" : "-"))
|
|
170
189
|
end
|
|
171
190
|
|
|
172
191
|
# Not a bare `.find(...)&.sign || -1` — that silently answered
|
|
@@ -180,9 +199,9 @@ module Hecks
|
|
|
180
199
|
# raise is not a real runtime path yet — it is the same
|
|
181
200
|
# backstop one level down, in case a future caller reaches
|
|
182
201
|
# #sign_of directly for an op that was never meant to have one.
|
|
183
|
-
def sign_of(
|
|
184
|
-
MUTATION_OPS.find { |candidate| candidate.name ==
|
|
185
|
-
raise(WiringError, "no sign declared for mutation op #{
|
|
202
|
+
def sign_of(oper)
|
|
203
|
+
MUTATION_OPS.find { |candidate| candidate.name == oper.to_s }&.sign ||
|
|
204
|
+
raise(WiringError, "no sign declared for mutation op #{oper.inspect} — add one before calling #sign_of")
|
|
186
205
|
end
|
|
187
206
|
|
|
188
207
|
# Vendored addition, not (yet) upstream hecks (migration plan
|
|
@@ -196,7 +215,9 @@ module Hecks
|
|
|
196
215
|
current ||= 0
|
|
197
216
|
|
|
198
217
|
if current.is_a?(Value) && amount.is_a?(Value)
|
|
199
|
-
return combine_value_object(current, amount, target, "multiply")
|
|
218
|
+
return combine_value_object(current, amount, target, "multiply") do |c, a|
|
|
219
|
+
bounded(c * a, "multiply", c, a, "*")
|
|
220
|
+
end
|
|
200
221
|
end
|
|
201
222
|
|
|
202
223
|
# Same absent-`current`, VO-wrapped-`amount` gap as `#arithmetic`
|
|
@@ -209,7 +230,7 @@ module Hecks
|
|
|
209
230
|
offered: Rendering.describe(current.is_a?(Numeric) ? amount : current))
|
|
210
231
|
end
|
|
211
232
|
|
|
212
|
-
current * amount
|
|
233
|
+
bounded(current * amount, "multiply", current, amount, "*")
|
|
213
234
|
end
|
|
214
235
|
|
|
215
236
|
# Vendored addition, not (yet) upstream hecks (migration plan
|
|
@@ -270,7 +291,7 @@ module Hecks
|
|
|
270
291
|
fields[numeric_fields.first]
|
|
271
292
|
end
|
|
272
293
|
|
|
273
|
-
def combine_value_object(current, amount, target,
|
|
294
|
+
def combine_value_object(current, amount, target, oper)
|
|
274
295
|
current_fields = current.to_h
|
|
275
296
|
amount_fields = amount.to_h
|
|
276
297
|
shared_numeric = current_fields.keys.select do |field|
|
|
@@ -278,7 +299,7 @@ module Hecks
|
|
|
278
299
|
end
|
|
279
300
|
unless shared_numeric.size == 1
|
|
280
301
|
raise TypeMismatch,
|
|
281
|
-
RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op:
|
|
302
|
+
RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: oper, target: target)
|
|
282
303
|
end
|
|
283
304
|
|
|
284
305
|
field = shared_numeric.first
|
|
@@ -31,6 +31,10 @@ module Hecks
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Walks a canonical expression's parsed nodes (the same AST
|
|
35
|
+
# Bluebook::Expression::Evaluator evaluates) and collects the
|
|
36
|
+
# dotted paths it reads — used by Analyzer to classify a
|
|
37
|
+
# given/ensures/invariant rule's dependencies without evaluating it.
|
|
34
38
|
module ExpressionReads
|
|
35
39
|
module_function
|
|
36
40
|
|
|
@@ -59,6 +63,13 @@ module Hecks
|
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
# Static, correctness-first dependency analysis for one command:
|
|
67
|
+
# walks its mutations, lifecycle transitions, and given/ensures/
|
|
68
|
+
# invariant rules to derive a Plan (read_set/write_set/
|
|
69
|
+
# complete_state?/state_independent?) describing what the command
|
|
70
|
+
# touches without executing it. `Analyzer.call` is what
|
|
71
|
+
# CommandInterpreter and EntityInterpreter both consult before
|
|
72
|
+
# choosing a dispatch strategy.
|
|
62
73
|
class Analyzer
|
|
63
74
|
STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
|
|
64
75
|
|
|
@@ -101,11 +112,11 @@ module Hecks
|
|
|
101
112
|
# projected field just as easily as one of its real attributes
|
|
102
113
|
# (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
|
|
103
114
|
# status`, ATMCard's projected field, is a real, live example).
|
|
104
|
-
if aggregate.respond_to?(:projected_fields)
|
|
105
|
-
aggregate.projected_fields.each { |field| @owner_fields << field.name }
|
|
106
|
-
end
|
|
115
|
+
aggregate.projected_fields.each { |field| @owner_fields << field.name } if aggregate.respond_to?(:projected_fields)
|
|
107
116
|
if root_aggregate.respond_to?(:projected_fields)
|
|
108
|
-
root_aggregate.projected_fields.each
|
|
117
|
+
root_aggregate.projected_fields.each do |field|
|
|
118
|
+
@root_owner_fields << field.name
|
|
119
|
+
end
|
|
109
120
|
end
|
|
110
121
|
@payload_fields = command.attributes.to_set(&:name)
|
|
111
122
|
@state_reads = Set.new
|
|
@@ -210,9 +221,7 @@ module Hecks
|
|
|
210
221
|
lifecycle = aggregate.lifecycle
|
|
211
222
|
return unless lifecycle
|
|
212
223
|
|
|
213
|
-
if command.from
|
|
214
|
-
state_reads << lifecycle.field
|
|
215
|
-
end
|
|
224
|
+
state_reads << lifecycle.field if command.from
|
|
216
225
|
|
|
217
226
|
return if lifecycle.transitions_for(command.hecks_name).empty?
|
|
218
227
|
|
|
@@ -223,8 +232,8 @@ module Hecks
|
|
|
223
232
|
def analyze_rules(rules, phase:)
|
|
224
233
|
rules.each do |rule|
|
|
225
234
|
ExpressionReads.paths(rule.canonical).each { |path| classify_path(path, phase) }
|
|
226
|
-
rescue ArgumentError =>
|
|
227
|
-
unresolved << "expression #{rule.canonical.inspect} could not be analyzed: #{
|
|
235
|
+
rescue ArgumentError => e
|
|
236
|
+
unresolved << "expression #{rule.canonical.inspect} could not be analyzed: #{e.message}"
|
|
228
237
|
end
|
|
229
238
|
end
|
|
230
239
|
|
|
@@ -249,7 +258,6 @@ module Hecks
|
|
|
249
258
|
name = head.to_sym
|
|
250
259
|
|
|
251
260
|
if name == :parent
|
|
252
|
-
parent_field = nested.to_s.split(".", 2).first
|
|
253
261
|
# `root_owner_fields` — NOT `owner_fields`. For an entity-owned
|
|
254
262
|
# command `owner_fields` is the ENTITY's own attribute set;
|
|
255
263
|
# `parent.X` always means the ROOT aggregate's own field, a
|
|
@@ -257,18 +265,9 @@ module Hecks
|
|
|
257
265
|
# above, has the full bug this fixes). Identical for a plain
|
|
258
266
|
# aggregate command, where root_aggregate defaults to aggregate
|
|
259
267
|
# itself and the two sets are the same set.
|
|
260
|
-
|
|
261
|
-
unresolved << "#{path} does not name parent aggregate state"
|
|
262
|
-
else
|
|
263
|
-
state_reads << parent_field.to_sym
|
|
264
|
-
end
|
|
268
|
+
resolve_nested_state_read!(path, nested, root_owner_fields, "does not name parent aggregate state")
|
|
265
269
|
elsif name == :old
|
|
266
|
-
|
|
267
|
-
if old_field.empty? || !owner_fields.include?(old_field.to_sym)
|
|
268
|
-
unresolved << "#{path} does not name prior aggregate state"
|
|
269
|
-
else
|
|
270
|
-
state_reads << old_field.to_sym
|
|
271
|
-
end
|
|
270
|
+
resolve_nested_state_read!(path, nested, owner_fields, "does not name prior aggregate state")
|
|
272
271
|
elsif payload_fields.include?(name)
|
|
273
272
|
payload_reads << name
|
|
274
273
|
elsif owner_fields.include?(name)
|
|
@@ -278,6 +277,19 @@ module Hecks
|
|
|
278
277
|
end
|
|
279
278
|
end
|
|
280
279
|
|
|
280
|
+
# Shared shape behind the `:parent`/`:old` branches above: read the
|
|
281
|
+
# nested field name, check it against the given owner field set
|
|
282
|
+
# (deliberately different sets for `parent`/`old` — see the caller),
|
|
283
|
+
# and either record it as a state read or refuse with `message`.
|
|
284
|
+
def resolve_nested_state_read!(path, nested, field_set, message)
|
|
285
|
+
field = nested.to_s.split(".", 2).first
|
|
286
|
+
if field.empty? || !field_set.include?(field.to_sym)
|
|
287
|
+
unresolved << "#{path} #{message}"
|
|
288
|
+
else
|
|
289
|
+
state_reads << field.to_sym
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
281
293
|
# A partial mutation must preserve every untouched field on the
|
|
282
294
|
# correctness path. Those prior values are real reads even when no rule
|
|
283
295
|
# names them. A deterministic write needs no preservation read.
|
|
@@ -9,10 +9,18 @@ require_relative "query_interpreter"
|
|
|
9
9
|
require_relative "read_model_interpreter"
|
|
10
10
|
require_relative "policy_interpreter"
|
|
11
11
|
require_relative "saga_interpreter"
|
|
12
|
+
require_relative "outbox"
|
|
12
13
|
require_relative "../naming"
|
|
13
14
|
|
|
14
15
|
module Hecks
|
|
15
16
|
module Runtime
|
|
17
|
+
# The runtime's single dispatch entry point for one booted registry:
|
|
18
|
+
# routes a "Domain::Aggregate.Command"-shaped verb to the right
|
|
19
|
+
# sub-interpreter (command, entity, port operation, query, read
|
|
20
|
+
# model), then runs every policy and saga reaction the resulting
|
|
21
|
+
# events trigger. Also the door reactions dispatch back through
|
|
22
|
+
# (#reenter), and the one place reaction-cascade depth is tracked, to
|
|
23
|
+
# keep a policy/saga loop from re-triggering itself without bound.
|
|
16
24
|
class Dispatcher
|
|
17
25
|
MAX_REACTION_DEPTH = 5
|
|
18
26
|
|
|
@@ -48,8 +56,19 @@ module Hecks
|
|
|
48
56
|
@read_models = ReadModelInterpreter.new(registry)
|
|
49
57
|
@policies = PolicyInterpreter.new(registry, door: self)
|
|
50
58
|
@sagas = SagaInterpreter.new(registry, door: self)
|
|
59
|
+
# THE RELAY IS THE REGISTRY'S, NOT THIS DISPATCHER'S — the
|
|
60
|
+
# interpreters enqueue through `@registry.outbox` from inside
|
|
61
|
+
# the save transaction, and this dispatcher drains through the
|
|
62
|
+
# same object, so there is exactly one relay per registry no
|
|
63
|
+
# matter how many dispatchers front it. What it borrows from
|
|
64
|
+
# here is the pair of interpreters a consumer runs through.
|
|
65
|
+
@registry.outbox.attach(policies: @policies, sagas: @sagas)
|
|
51
66
|
end
|
|
52
67
|
|
|
68
|
+
# `runtime.outbox.rows`, `.rows(status: "claimed")`, `.redrive!`,
|
|
69
|
+
# `.log` — see `Runtime::Outbox`.
|
|
70
|
+
def outbox = @registry.outbox
|
|
71
|
+
|
|
53
72
|
def events = @registry.event_log
|
|
54
73
|
|
|
55
74
|
def reactions = @registry.reaction_log
|
|
@@ -63,7 +82,7 @@ module Hecks
|
|
|
63
82
|
domain, aggregate_name, command_name = parse(verb)
|
|
64
83
|
aggregate = resolve_aggregate(domain, aggregate_name, verb)
|
|
65
84
|
|
|
66
|
-
instance, announced, execution_plan, persistence_outcome =
|
|
85
|
+
instance, announced, execution_plan, persistence_outcome, outbox_rows =
|
|
67
86
|
if command_name.include?(".")
|
|
68
87
|
head, sub = command_name.split(".", 2)
|
|
69
88
|
port = aggregate.port(head)
|
|
@@ -83,7 +102,7 @@ module Hecks
|
|
|
83
102
|
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "port_no_operation",
|
|
84
103
|
port: head, operation: sub.inspect))
|
|
85
104
|
route, args = port_invocation(aggregate, operation, to: to, with: with, legacy: legacy_args)
|
|
86
|
-
[nil, @port_ops.call(domain, aggregate, operation, args, route: route), nil, nil]
|
|
105
|
+
[nil, @port_ops.call(domain, aggregate, operation, args, route: route), nil, nil, :enqueue]
|
|
87
106
|
else
|
|
88
107
|
entity_depth = command_name.split(".").size - 1
|
|
89
108
|
route = Routing.envelope(to, entity_depth: entity_depth)
|
|
@@ -109,14 +128,30 @@ module Hecks
|
|
|
109
128
|
# `announced` events within this very call, and finds the
|
|
110
129
|
# correlation already there because it was never absent.
|
|
111
130
|
|
|
112
|
-
announced
|
|
113
|
-
|
|
114
|
-
announced.each { |event| @sagas.advance(event, domain) }
|
|
131
|
+
react(announced, domain, aggregate, outbox_rows)
|
|
115
132
|
|
|
116
133
|
Result.new(verb: verb, instance: instance, events: announced,
|
|
117
134
|
execution_plan: execution_plan, persistence_outcome: persistence_outcome)
|
|
118
135
|
end
|
|
119
136
|
|
|
137
|
+
# EVERYTHING OWED BECAUSE `announced` COMMITTED — policies first,
|
|
138
|
+
# then sagas, the order this method always ran them in. The
|
|
139
|
+
# command/entity interpreters hand back the outbox rows they
|
|
140
|
+
# enqueued inside the save transaction (`Interpreting#
|
|
141
|
+
# run_dispatch_order`); a port operation saves nothing, so its
|
|
142
|
+
# rows are enqueued here, after the fact (`:enqueue`) — durable
|
|
143
|
+
# still, just not transactional with anything, because there is
|
|
144
|
+
# nothing for them to be transactional with. `nil` rows mean the
|
|
145
|
+
# repository has no outbox: react directly, exactly as before.
|
|
146
|
+
def react(announced, domain, aggregate, outbox_rows)
|
|
147
|
+
return if announced.empty?
|
|
148
|
+
|
|
149
|
+
repository = @registry.repository(domain, aggregate)
|
|
150
|
+
outbox_rows = outbox.enqueue(repository, announced, domain) if outbox_rows == :enqueue
|
|
151
|
+
outbox.deliver(outbox_rows, announced, domain, repository)
|
|
152
|
+
end
|
|
153
|
+
private :react
|
|
154
|
+
|
|
120
155
|
# "IF THIS WERE DISPATCHED RIGHT NOW, WOULD IT SUCCEED" — the same
|
|
121
156
|
# pipeline #dispatch itself runs (arguments coerced, givens checked,
|
|
122
157
|
# mutations applied IN MEMORY, ensures checked against the settled
|
|
@@ -180,8 +215,7 @@ module Hecks
|
|
|
180
215
|
route, args = port_invocation(aggregate, operation, to: to, with: with, legacy: legacy_args)
|
|
181
216
|
announced = @port_ops.call(domain, aggregate, operation, args, route: route)
|
|
182
217
|
|
|
183
|
-
announced
|
|
184
|
-
announced.each { |event| @sagas.advance(event, domain) }
|
|
218
|
+
react(announced, domain, aggregate, :enqueue)
|
|
185
219
|
|
|
186
220
|
announced
|
|
187
221
|
end
|
|
@@ -63,6 +63,14 @@ module Hecks
|
|
|
63
63
|
# which element it means, so re-deriving `wants` from `args` would be
|
|
64
64
|
# redundant at best and wrong if `args` no longer carries that identity
|
|
65
65
|
# at all.
|
|
66
|
+
# Locate, then copy-before-mutate, in that order — see the "ONE
|
|
67
|
+
# LEVEL DEEPER" comment below on why the copy has to happen exactly
|
|
68
|
+
# where it does (aliasing the adapter's own record otherwise).
|
|
69
|
+
# Splitting resolution from the copy/write-back would separate two
|
|
70
|
+
# halves of one aliasing-safety invariant across method boundaries.
|
|
71
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
72
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
73
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
66
74
|
def element_of(root_aggregate, owner, entity, command_name, container, args, routed_identity = nil)
|
|
67
75
|
entity_name = entity.hecks_name
|
|
68
76
|
list_attr = owner.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
|
|
@@ -158,32 +166,43 @@ module Hecks
|
|
|
158
166
|
# method's own fixture (TaggedList.Bump, a VO-typed `count` with
|
|
159
167
|
# `default: 0`) raised exactly this TypeMismatch on its first
|
|
160
168
|
# real run.
|
|
161
|
-
|
|
169
|
+
# A case dispatching over a closed, declared set of mutation ops —
|
|
170
|
+
# deliberately kept byte-for-byte parallel to its aggregate-level
|
|
171
|
+
# twin, MutationApplier#apply (see this method's own comment
|
|
172
|
+
# above and each branch's "own entity-scoped twin" cross-reference):
|
|
173
|
+
# extracting the near-duplicate increment/decrement/multiply
|
|
174
|
+
# shape here without doing the same there would break that
|
|
175
|
+
# intentional mirroring, which is what lets the two be diffed
|
|
176
|
+
# against each other when one gets a fix the other needs too.
|
|
177
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
178
|
+
# `pre` — the element as it was before this command (C4.2): every
|
|
179
|
+
# read below goes through it, every write lands on `element`.
|
|
180
|
+
def apply_to_element(rules, aggregate, entity, element, mutation, args, pre = element)
|
|
162
181
|
case mutation.op
|
|
163
182
|
when :set
|
|
164
183
|
value = rules.resolve_source(mutation.source, args)
|
|
165
184
|
attribute = entity.attribute(mutation.target)
|
|
166
185
|
element[mutation.target] = attribute ? Value.for_attribute(aggregate, attribute, value) : value
|
|
167
186
|
when :append
|
|
168
|
-
element[mutation.target] = appended_to_element(aggregate, entity,
|
|
187
|
+
element[mutation.target] = appended_to_element(aggregate, entity, pre, mutation, args)
|
|
169
188
|
when :remove
|
|
170
|
-
element[mutation.target] = removed_from_element(rules, aggregate, entity,
|
|
189
|
+
element[mutation.target] = removed_from_element(rules, aggregate, entity, pre, mutation, args)
|
|
171
190
|
when :increment, :decrement
|
|
172
191
|
attribute = entity.attribute(mutation.target)
|
|
173
192
|
amount = rules.resolve_source(mutation.source, args)
|
|
174
|
-
current =
|
|
193
|
+
current = pre[mutation.target]
|
|
175
194
|
amount = Value.for_attribute(aggregate, attribute, amount) if attribute && current.is_a?(Value)
|
|
176
195
|
result = rules.arithmetic(current, amount, mutation.target, rules.sign_of(mutation.op))
|
|
177
196
|
element[mutation.target] = rewrap_arithmetic_result(aggregate, attribute, current, result)
|
|
178
197
|
when :multiply
|
|
179
198
|
attribute = entity.attribute(mutation.target)
|
|
180
199
|
amount = rules.resolve_source(mutation.source, args)
|
|
181
|
-
current =
|
|
200
|
+
current = pre[mutation.target]
|
|
182
201
|
amount = Value.for_attribute(aggregate, attribute, amount) if attribute && current.is_a?(Value)
|
|
183
202
|
result = rules.multiply(current, amount, mutation.target)
|
|
184
203
|
element[mutation.target] = rewrap_arithmetic_result(aggregate, attribute, current, result)
|
|
185
204
|
when :clamp
|
|
186
|
-
element[mutation.target] = rules.clamp(
|
|
205
|
+
element[mutation.target] = rules.clamp(pre[mutation.target], mutation.source, mutation.target)
|
|
187
206
|
else
|
|
188
207
|
# The aggregate-level twin's own backstop
|
|
189
208
|
# (MutationApplier#apply), for the same reason: applying
|
|
@@ -14,6 +14,13 @@ require_relative "command_interpreter/argument_gate"
|
|
|
14
14
|
|
|
15
15
|
module Hecks
|
|
16
16
|
module Runtime
|
|
17
|
+
# Interprets a command dispatched against a nested entity (a dotted
|
|
18
|
+
# verb like "Handler.Dispatch.Bind") rather than an aggregate root
|
|
19
|
+
# directly: walks the entity chain off the parent aggregate, applies
|
|
20
|
+
# the command's own DISPATCH_ORDER of steps against the located
|
|
21
|
+
# element, then saves and emits through the same parent record a
|
|
22
|
+
# plain aggregate command would. CommandInterpreter's sibling for
|
|
23
|
+
# entity-owned commands.
|
|
17
24
|
class EntityInterpreter
|
|
18
25
|
include Interpreting
|
|
19
26
|
# THE SAME PAYLOAD GATE aggregate commands and port operations already
|
|
@@ -61,9 +68,14 @@ module Hecks
|
|
|
61
68
|
# targets, so every step written before this ADR (enforce_givens,
|
|
62
69
|
# apply_mutations, advance_lifecycle, element_identity, ...) reads
|
|
63
70
|
# exactly as it always has. Only `locate_element` walks the chain.
|
|
71
|
+
# `:chain` shadows Enumerable#chain on purpose — every read is
|
|
72
|
+
# `ctx.chain` fetching the field (an Array of entities), never
|
|
73
|
+
# `ctx.chain(other)` combining enumerables. Verified before disabling
|
|
74
|
+
# this cop for it.
|
|
75
|
+
# rubocop:disable-next Lint/StructNewOverride
|
|
64
76
|
Context = Struct.new(:domain, :aggregate, :entity, :entity_name, :command, :command_name,
|
|
65
77
|
:args, :repository, :instance, :chain, :element, :view, :transition,
|
|
66
|
-
:old_element, :result, :route, :plan, :persistence_outcome, :dry_run)
|
|
78
|
+
:old_element, :result, :route, :plan, :persistence_outcome, :dry_run, :outbox_rows)
|
|
67
79
|
|
|
68
80
|
def initialize(registry, rules:)
|
|
69
81
|
@registry = registry
|
|
@@ -111,7 +123,7 @@ module Hecks
|
|
|
111
123
|
ctx.repository = @registry.repository(domain, aggregate)
|
|
112
124
|
lock_id = Identity.best_effort(aggregate, args, route)
|
|
113
125
|
run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
|
|
114
|
-
[ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
|
|
126
|
+
[ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome, ctx.outbox_rows]
|
|
115
127
|
rescue StaleWrite
|
|
116
128
|
attempt += 1
|
|
117
129
|
retry if attempt < MAX_STALE_WRITE_RETRIES
|
|
@@ -149,10 +161,10 @@ module Hecks
|
|
|
149
161
|
# `ArgumentGate#refuse_unknown_arguments`'s own header gives for `:id`/
|
|
150
162
|
# the root's `identity_heads`.
|
|
151
163
|
def step_refuse_unknown_arguments(ctx)
|
|
152
|
-
step(:refuse_unknown_arguments)
|
|
164
|
+
step(:refuse_unknown_arguments) do
|
|
153
165
|
refuse_unknown_arguments(ctx.domain, ctx.aggregate, ctx.command, ctx.args,
|
|
154
166
|
extra_identity_heads: ctx.chain.flat_map(&:identity_heads))
|
|
155
|
-
|
|
167
|
+
end
|
|
156
168
|
end
|
|
157
169
|
|
|
158
170
|
# No `aggregate:` exemption to pass — that kwarg exists only for a
|
|
@@ -180,15 +192,15 @@ module Hecks
|
|
|
180
192
|
def step_hydrate_parent(ctx)
|
|
181
193
|
# `ctx.repository` is resolved once, in `#call`, before the
|
|
182
194
|
# isolation decision — not here any more.
|
|
183
|
-
ctx.instance = step(:hydrate_parent)
|
|
195
|
+
ctx.instance = step(:hydrate_parent) do
|
|
184
196
|
parent(ctx.repository, ctx.aggregate, ctx.entity_name, ctx.command_name, ctx.args, ctx.route)
|
|
185
|
-
|
|
197
|
+
end
|
|
186
198
|
end
|
|
187
199
|
|
|
188
200
|
def step_locate_element(ctx)
|
|
189
|
-
ctx.element = step(:locate_element)
|
|
201
|
+
ctx.element = step(:locate_element) do
|
|
190
202
|
EntityElement.locate_chain(ctx.aggregate, ctx.chain, ctx.instance, ctx.args, ctx.command_name, ctx.route)
|
|
191
|
-
|
|
203
|
+
end
|
|
192
204
|
# `view` was hydrated ONCE, here, into its OWN state hash
|
|
193
205
|
# (Value.hydrate builds a fresh Hash — never aliased with `element`)
|
|
194
206
|
# — exactly right for enforce_givens, which must read pre-mutation.
|
|
@@ -197,9 +209,9 @@ module Hecks
|
|
|
197
209
|
end
|
|
198
210
|
|
|
199
211
|
def step_enforce_givens(ctx)
|
|
200
|
-
step(:enforce_givens)
|
|
212
|
+
step(:enforce_givens) do
|
|
201
213
|
@rules.enforce_givens(ctx.view, ctx.command, ctx.args, domain: ctx.domain, declaring: ctx.entity, parent: ctx.instance)
|
|
202
|
-
|
|
214
|
+
end
|
|
203
215
|
end
|
|
204
216
|
|
|
205
217
|
def step_admissible_transition(ctx)
|
|
@@ -208,11 +220,12 @@ module Hecks
|
|
|
208
220
|
|
|
209
221
|
def step_apply_mutations(ctx)
|
|
210
222
|
ctx.old_element = ctx.element.dup unless ctx.command.ensures.empty?
|
|
211
|
-
step(:apply_mutations)
|
|
212
|
-
ctx.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
step(:apply_mutations) do
|
|
224
|
+
pre = ctx.element.dup # C4.2 — the update set reads the element as it was
|
|
225
|
+
ctx.command.mutations.each do |mutation|
|
|
226
|
+
EntityElement.apply_to_element(@rules, ctx.aggregate, ctx.entity, ctx.element, mutation, ctx.args, pre)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
216
229
|
end
|
|
217
230
|
|
|
218
231
|
def step_advance_lifecycle(ctx)
|
data/lib/hecks/runtime/errors.rb
CHANGED
|
@@ -5,8 +5,34 @@ module Hecks
|
|
|
5
5
|
module Runtime
|
|
6
6
|
class UnknownVerb < StandardError; end
|
|
7
7
|
class EnsuresNotMet < StandardError; end
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
# `detail` — the failing comparison's own resolved operands, "left: X,
|
|
10
|
+
# right: Y" — set only when the given's TOP-LEVEL shape is a bare
|
|
11
|
+
# comparison (`Evaluator.comparison_detail`'s own comment has the full
|
|
12
|
+
# scoping); nil otherwise. Deliberately NOT folded into `#message`:
|
|
13
|
+
# that string is pinned byte-for-byte across this corpus's own specs
|
|
14
|
+
# (`raise_error(GivenNotMet, "...")`, command_rules_spec.rb and every
|
|
15
|
+
# domain that vendors this gem) as CONTRACT, so changing its shape by
|
|
16
|
+
# default would be a breaking change for every one of them. Riding on
|
|
17
|
+
# `#detailed_message` instead (Ruby 3.2+, what irb/a Rails console's
|
|
18
|
+
# own unhandled-exception banner already calls to show more than
|
|
19
|
+
# `#message`) means the detail actually reaches a human staring at a
|
|
20
|
+
# live refusal, without moving the string anything else asserts on.
|
|
21
|
+
class GivenNotMet < StandardError
|
|
22
|
+
attr_reader :detail
|
|
23
|
+
|
|
24
|
+
def initialize(message = nil, detail: nil)
|
|
25
|
+
super(message)
|
|
26
|
+
@detail = detail
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def detailed_message(highlight: false, **opts)
|
|
30
|
+
base = super
|
|
31
|
+
detail ? "#{base} (#{detail})" : base
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class NotFound < StandardError; end
|
|
10
36
|
class LifecycleRefused < StandardError; end
|
|
11
37
|
class TypeMismatch < StandardError; end
|
|
12
38
|
# An argument the command does not declare. Sibling of TypeMismatch : that one
|
|
@@ -3,6 +3,12 @@ require_relative "identity"
|
|
|
3
3
|
|
|
4
4
|
module Hecks
|
|
5
5
|
module Runtime
|
|
6
|
+
# One aggregate (or entity) record's runtime state: the declared
|
|
7
|
+
# attributes hydrated with defaults, plus the identity (`id`) and,
|
|
8
|
+
# for a CAS-capable adapter, the optimistic-concurrency `version`
|
|
9
|
+
# stamped on it. `[]`/`[]=`/method_missing give state-field access;
|
|
10
|
+
# `to_h` is the wire/storage shape with `id` merged in last so a
|
|
11
|
+
# same-named declared attribute can never clobber it.
|
|
6
12
|
class Instance
|
|
7
13
|
attr_reader :aggregate, :id
|
|
8
14
|
attr_accessor :state
|
|
@@ -46,12 +52,12 @@ module Hecks
|
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
def self.defaults(aggregate)
|
|
49
|
-
state = aggregate.attributes.
|
|
55
|
+
state = aggregate.attributes.to_h do |attr|
|
|
50
56
|
# FROZEN, like a list that has had something appended to it.
|
|
51
57
|
# An untouched list is the easiest one to miss and the easiest
|
|
52
58
|
# to mutate: nothing has replaced it yet, so a caller pushing
|
|
53
59
|
# into it writes straight into the aggregate's own state.
|
|
54
|
-
|
|
60
|
+
[attr.name, attr.list? ? Freezer.deep([]) : default_for(aggregate, attr)]
|
|
55
61
|
end
|
|
56
62
|
state[aggregate.lifecycle.field.to_sym] = aggregate.lifecycle.default if aggregate.lifecycle
|
|
57
63
|
state
|
|
@@ -64,7 +70,7 @@ module Hecks
|
|
|
64
70
|
return nil unless aggregate.respond_to?(:value_object)
|
|
65
71
|
|
|
66
72
|
value_object = aggregate.value_object(attribute.type)
|
|
67
|
-
return nil unless value_object
|
|
73
|
+
return nil unless value_object&.attributes&.all? { |field| !field.default.nil? }
|
|
68
74
|
|
|
69
75
|
Value.build(value_object, {}, aggregate)
|
|
70
76
|
end
|
|
@@ -40,8 +40,35 @@ module Hecks
|
|
|
40
40
|
# has to guard ITSELF at the top of its own handler and skip tracing
|
|
41
41
|
# when it does not fire, rather than the caller branching around it —
|
|
42
42
|
# see CommandInterpreter#step_assign_creation_attributes.
|
|
43
|
+
# THE COMMIT BOUNDARY. Every step up to `save` runs as before;
|
|
44
|
+
# `save`, `emit`, and the outbox enqueue that follows them run
|
|
45
|
+
# inside ONE `repository.transaction` — so the aggregate row, its
|
|
46
|
+
# journal entry, the recorded event, and the outbox rows naming
|
|
47
|
+
# who is owed a reaction commit together or not at all. No new
|
|
48
|
+
# step is added to the vocabulary's dispatch order (the step list
|
|
49
|
+
# is a pinned contract, `spec/vocabulary_conformance_spec.rb`);
|
|
50
|
+
# the enqueue is a consequence of `emit`, not a step of its own.
|
|
51
|
+
# A context without a repository (port operations — nothing to
|
|
52
|
+
# save) or with no `:save` in its order runs plainly.
|
|
43
53
|
def run_dispatch_order(order, ctx)
|
|
44
|
-
order.
|
|
54
|
+
split = order.index(:save)
|
|
55
|
+
return order.each { |name| send(:"step_#{name}", ctx) } unless split && ctx.respond_to?(:repository) && ctx.repository
|
|
56
|
+
|
|
57
|
+
order[0...split].each { |name| send(:"step_#{name}", ctx) }
|
|
58
|
+
ctx.repository.transaction do
|
|
59
|
+
order[split..].each { |name| send(:"step_#{name}", ctx) }
|
|
60
|
+
enqueue_outbox(ctx)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rows for the events this dispatch just emitted, written while
|
|
65
|
+
# the save's transaction is still open. `nil` (no outbox on this
|
|
66
|
+
# repository) tells the dispatcher to react directly, the
|
|
67
|
+
# pre-outbox path; a dry run emits nothing and enqueues nothing.
|
|
68
|
+
def enqueue_outbox(ctx)
|
|
69
|
+
return if ctx.dry_run || !ctx.respond_to?(:outbox_rows=)
|
|
70
|
+
|
|
71
|
+
ctx.outbox_rows = @registry.outbox.enqueue(ctx.repository, Array(ctx.result), ctx.domain)
|
|
45
72
|
end
|
|
46
73
|
|
|
47
74
|
# THE CONCURRENCY-CONTROL SPLIT — see docs/decisions/ (concurrency
|
|
@@ -50,15 +77,24 @@ module Hecks
|
|
|
50
77
|
# lost-update gap itself, via `step_save`'s CAS + `#call`'s own
|
|
51
78
|
# `StaleWrite` retry loop — an extra in-process lock here would be
|
|
52
79
|
# pointless overhead, not incorrect, so it's skipped for clarity.
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# `
|
|
80
|
+
# A repository that declares `:cross_process_lock` (PostgresEra —
|
|
81
|
+
# ADR 0036) holds a REAL Postgres advisory lock for the whole
|
|
82
|
+
# dispatch order instead: unlike Heki/Memory (confirmed
|
|
83
|
+
# process-local, never a second process writing the same store),
|
|
84
|
+
# PostgresEra's own tables can be dispatched against concurrently
|
|
85
|
+
# by `rust/host` from a separate OS process, and an in-process
|
|
86
|
+
# `Mutex` is invisible to that. Every OTHER repository gets the
|
|
87
|
+
# striped `Mutex` below, held for the WHOLE dispatch-order run, so
|
|
88
|
+
# a second thread's own hydrate can't start until the first
|
|
89
|
+
# thread's save has landed. `lock_key_id` is best-effort
|
|
90
|
+
# (`Identity.best_effort`) — `nil` still locks correctly, just
|
|
91
|
+
# coarser (by aggregate type).
|
|
59
92
|
def run_dispatch_order_with_isolation(order, ctx, lock_key_id:)
|
|
60
|
-
|
|
93
|
+
capabilities = ctx.repository.capabilities
|
|
94
|
+
if capabilities.include?(:optimistic_concurrency)
|
|
61
95
|
run_dispatch_order(order, ctx)
|
|
96
|
+
elsif capabilities.include?(:cross_process_lock)
|
|
97
|
+
ctx.repository.with_write_lock { run_dispatch_order(order, ctx) }
|
|
62
98
|
else
|
|
63
99
|
AggregateLock.for(ctx.domain, ctx.aggregate, lock_key_id).synchronize { run_dispatch_order(order, ctx) }
|
|
64
100
|
end
|
|
@@ -72,7 +108,7 @@ module Hecks
|
|
|
72
108
|
next unless normalized.key?(attribute.name)
|
|
73
109
|
|
|
74
110
|
Value.refuse_object_reference(command, attribute, normalized[attribute.name])
|
|
75
|
-
normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name])
|
|
111
|
+
normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name], argument: true)
|
|
76
112
|
end
|
|
77
113
|
end
|
|
78
114
|
|