hecks 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -2
@@ -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 { |field| @root_owner_fields << field.name }
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 => error
227
- unresolved << "expression #{rule.canonical.inspect} could not be analyzed: #{error.message}"
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
- if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
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
- old_field = nested.to_s.split(".", 2).first
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.each { |event| @policies.react(event, domain) }
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.each { |event| @policies.react(event, domain) }
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
- def apply_to_element(rules, aggregate, entity, element, mutation, args)
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, element, mutation, args)
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, element, mutation, args)
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 = element[mutation.target]
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 = element[mutation.target]
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(element[mutation.target], mutation.source, mutation.target)
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.command.mutations.each { |mutation|
213
- EntityElement.apply_to_element(@rules, ctx.aggregate, ctx.entity, ctx.element, mutation, ctx.args)
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)
@@ -5,8 +5,34 @@ module Hecks
5
5
  module Runtime
6
6
  class UnknownVerb < StandardError; end
7
7
  class EnsuresNotMet < StandardError; end
8
- class GivenNotMet < StandardError; end
9
- class NotFound < StandardError; end
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.each_with_object({}) do |attr, acc|
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
- acc[attr.name] = attr.list? ? Freezer.deep([]) : default_for(aggregate, attr)
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 && value_object.attributes.all? { |field| !field.default.nil? }
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.each { |name| send(:"step_#{name}", ctx) }
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
@@ -72,7 +99,7 @@ module Hecks
72
99
  next unless normalized.key?(attribute.name)
73
100
 
74
101
  Value.refuse_object_reference(command, attribute, normalized[attribute.name])
75
- normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name])
102
+ normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name], argument: true)
76
103
  end
77
104
  end
78
105
 
@@ -8,6 +8,12 @@ require_relative "registry"
8
8
 
9
9
  module Hecks
10
10
  module Runtime
11
+ # The boot entry point: loads a bluebook directory (or an explicit
12
+ # file list) into a fresh Registry, runs every registered boot gate
13
+ # (era checks, saga rehydration, …), and hands back the Dispatcher
14
+ # (or RemoteDispatcher, for a domain declaring `dispatched_by
15
+ # ("Lambda")`) bound to it — installing the `Widget::Item.Add(...)`
16
+ # facade sugar unless the caller opts out.
11
17
  class Loader
12
18
  # `install_facade:` defaults on — every ordinary caller wants
13
19
  # `Widget::Item.Add(...)` sugar. A caller that only ever dispatches
@@ -45,9 +51,24 @@ module Hecks
45
51
 
46
52
  run_boot_gates!(registry, directory)
47
53
  dispatcher = dispatcher_for(registry)
54
+ redrive_outbox!(dispatcher)
48
55
  install_facade ? bind_runtime(dispatcher) : dispatcher
49
56
  end
50
57
 
58
+ # THE OUTBOX'S BOOT-TIME RECONCILIATION — after the dispatcher
59
+ # exists (a row's consumer runs through its interpreters, so this
60
+ # cannot be a plain registry gate the way saga rehydration is) and
61
+ # after saga rehydration (a redriven row may advance a saga, which
62
+ # must already be in memory). `pending` rows are redriven —
63
+ # provably never started; `claimed` rows are surfaced, never
64
+ # auto-redriven — see `Runtime::Outbox`. A remote dispatcher has
65
+ # no local stores to scan.
66
+ def self.redrive_outbox!(dispatcher)
67
+ return unless dispatcher.respond_to?(:outbox)
68
+
69
+ dispatcher.outbox.redrive!
70
+ end
71
+
51
72
  # THE EXPLICIT-FILE FORM — `paths` names the exact bluebook/hecksagon/
52
73
  # world files to boot, in place, wherever they actually live. `boot`
53
74
  # above only ever takes a directory and globs it; that is the right