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.
Files changed (247) 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 +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -44,35 +44,48 @@ module Hecks
44
44
  end
45
45
 
46
46
  with_spec.to_h do |key, source|
47
- value = if !source.is_a?(Symbol)
48
- source
49
- elsif normalized_bindings.key?(source)
50
- normalized_bindings.fetch(source)
51
- else
52
- visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
53
- unless visible
54
- names = normalized_scopes.map(&:name).join(" then ")
55
- # WHAT IS VISIBLE, NAMED. A refusal that only says which name is
56
- # missing sent a modeler guessing field after field
57
- # (`number`, `reference`…) at a fan-out row that is addressed
58
- # by ONE key — `account`, the lowercase aggregate — which
59
- # nothing else in the domain spells out. The names each scope
60
- # actually offers are the whole diagnosis; the refusal now
61
- # lists them, scope by scope.
62
- offered = normalized_scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
63
- raise UnknownArgument,
64
- "#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
65
- end
66
- visible.facts.fetch(source)
67
- end
68
-
47
+ value = resolved_mapping_value(source, normalized_bindings, normalized_scopes, label)
69
48
  [key.to_sym, Value.materialize(value)]
70
49
  end
71
50
  end
72
51
 
52
+ # ONE `with:` SOURCE, RESOLVED — pulled out of resolve_mapping
53
+ # because it is a pure function of its own arguments (a literal, a
54
+ # binding, or a name visible in some scope), with no dependency on
55
+ # anything else resolve_mapping's own to_h block is doing.
56
+ def resolved_mapping_value(source, bindings, scopes, label)
57
+ return source unless source.is_a?(Symbol)
58
+ return bindings.fetch(source) if bindings.key?(source)
59
+
60
+ visible = scopes.find { |scope| scope.facts.key?(source) }
61
+ unless visible
62
+ names = scopes.map(&:name).join(" then ")
63
+ # WHAT IS VISIBLE, NAMED. A refusal that only says which name is
64
+ # missing sent a modeler guessing field after field
65
+ # (`number`, `reference`…) at a fan-out row that is addressed
66
+ # by ONE key — `account`, the lowercase aggregate — which
67
+ # nothing else in the domain spells out. The names each scope
68
+ # actually offers are the whole diagnosis; the refusal now
69
+ # lists them, scope by scope.
70
+ offered = scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
71
+ raise UnknownArgument,
72
+ "#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
73
+ end
74
+ visible.facts.fetch(source)
75
+ end
76
+ private_class_method :resolved_mapping_value
77
+
73
78
  # A reaction without an explicit projection retains wholesale legacy
74
79
  # forwarding. Choosing `with:` opts into the strict envelope: only target
75
80
  # command attributes enter `with:`, while identities become `to:`.
81
+ # `consumed` accumulates across the aggregate-identity and entity-
82
+ # identity steps below, and refuse_unconsumed! at the end reads the
83
+ # FINAL list — an ordering dependency threaded through one shared
84
+ # local. Already leans on private helpers (identity_for,
85
+ # source_receiver_for, command_facts, refuse_unconsumed!) for every
86
+ # piece that IS self-contained; what remains is the sequencing
87
+ # itself, which further splitting would only relocate, not remove.
88
+ # rubocop:disable-next Metrics/MethodLength
76
89
  def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
77
90
  args = projected.transform_keys(&:to_sym)
78
91
  unless explicit
@@ -8,6 +8,13 @@ require_relative "../query_specification/field_path"
8
8
 
9
9
  module Hecks
10
10
  module Runtime
11
+ # Interprets one declared `read_model`: resolves each of its
12
+ # `include`d heads (root first, then the rest in dependency order),
13
+ # matches non-root heads to already-projected rows by reference
14
+ # field, and applies group_by/count/median reduction where declared.
15
+ # Prefers a native SQLite escape hatch (#query_read_model) when the
16
+ # model is simple enough for one; otherwise runs the whole join
17
+ # in-process against loaded records.
11
18
  class ReadModelInterpreter
12
19
  def initialize(registry) = @registry = registry
13
20
 
@@ -17,6 +24,19 @@ module Hecks
17
24
 
18
25
  private
19
26
 
27
+ # ROOT-FIRST, THEN THE SQLITE ESCAPE HATCH, THEN THE JOIN LOOP —
28
+ # each step's own comment names a real, previously-shipped bug the
29
+ # current ORDER fixes (the reference/TenantScope refusal ordering
30
+ # above, the root-first head processing below). Splitting this
31
+ # into smaller methods would scatter that ordering across method
32
+ # boundaries where a future editor could silently break it, and
33
+ # would force threading `rootless`/`reference_id`/`eligible`/
34
+ # `projected`/`rows_by_as` between the pieces as parameters and
35
+ # return values.
36
+ # rubocop:disable-next Metrics/AbcSize
37
+ # rubocop:disable-next Metrics/CyclomaticComplexity
38
+ # rubocop:disable-next Metrics/MethodLength
39
+ # rubocop:disable-next Metrics/PerceivedComplexity
20
40
  def project(domain, model, args)
21
41
  bluebook = @registry.bluebook(domain)
22
42
  rootless = model.reference_target.nil?
@@ -30,10 +50,11 @@ module Hecks
30
50
  refuse_object_reference(model, args) unless rootless
31
51
  reference_id = reference(args.fetch(model.reference_name)) unless rootless
32
52
  # Computed off the ORIGINAL model, before TenantScope wraps it — the
33
- # "which head do options apply to" question is about what the
53
+ # "which head(s) do options apply to" question is about what the
34
54
  # bluebook author declared, not about the synthetic tenant clause
35
- # the wrapper adds underneath.
36
- eligible = model.filtered_head_name
55
+ # the wrapper adds underneath. Plural (ADR 0055) — `on:` lets more
56
+ # than one many-side head be eligible at once.
57
+ eligible = model.filtered_head_names
37
58
  model = TenantScope.apply(model, args)
38
59
  # A ROOTLESS, `group_by`-declared, or `count`/`median`-declared
39
60
  # model skips the SQLite native escape hatch entirely (there is
@@ -46,7 +67,10 @@ module Hecks
46
67
  # than narrowed.
47
68
  unless rootless || model.group_by.any? || model.count? || model.median_field
48
69
  repository = @registry.read_repository(domain, bluebook.aggregate(model.reference_target))
49
- return repository.query_read_model(domain, model, args, bluebook) if repository.respond_to?(:query_read_model) && repository.adapter.respond_to?(:query_read_model)
70
+ if repository.respond_to?(:query_read_model) && repository.adapter.respond_to?(:query_read_model)
71
+ return repository.query_read_model(domain, model, args,
72
+ bluebook)
73
+ end
50
74
  end
51
75
 
52
76
  # ROOT FIRST, ALWAYS — regardless of `include` order in the
@@ -96,14 +120,14 @@ module Hecks
96
120
  end
97
121
  end
98
122
  end
99
- rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
123
+ rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
100
124
  projected << { aggregate: head[:aggregate], rows: rows }
101
125
  rows_by_as[head[:as]] = head[:many] ? rows : rows.first
102
126
  end
103
127
  # Declared order preserved in the OUTPUT — only the
104
128
  # computation above needed reordering, not what a caller sees
105
129
  # back.
106
- heads = model.aggregate_heads.each_with_object({}) { |head, report| report[head[:as]] = rows_by_as[head[:as]] }
130
+ heads = model.aggregate_heads.to_h { |head| [head[:as], rows_by_as[head[:as]]] }
107
131
  grouped_head = group_by_target(model, bluebook)
108
132
  reduced_head = aggregation_target(model, bluebook)
109
133
  [heads.each_with_object({}) do |(as, value), out|
@@ -343,7 +367,7 @@ module Hecks
343
367
  .map(&:name)
344
368
  end
345
369
 
346
- def matching(records) = records.select { |record| yield(record) }.sort_by(&:id)
370
+ def matching(records, &) = records.select(&).sort_by(&:id)
347
371
  def row(record) = record.to_h
348
372
  end
349
373
  end
@@ -92,7 +92,7 @@ module Hecks
92
92
  @wheres = wheres
93
93
  end
94
94
 
95
- def wheres = @wheres
95
+ attr_reader :wheres
96
96
  end
97
97
  end
98
98
  end
@@ -13,99 +13,105 @@ module Hecks
13
13
  # every dispatch, the same reason Expression::Evaluator::
14
14
  # SIGN_TEST_OPERATORS is hand-typed beside Vocabulary::SignTest.
15
15
  module RefusalWording
16
+ # `Layout/HashAlignment`'s repo-wide `table` style (.rubocop.yml)
17
+ # would force every template string below onto the SAME column —
18
+ # matching the widest key — leaving the longer wordings almost no
19
+ # room to wrap under Layout/LineLength's own 130-column limit.
20
+ # Disabled for exactly this one hash literal (a single statement, so
21
+ # disable-next rather than a disable/enable pair).
22
+ # rubocop:disable-next Layout/HashAlignment
16
23
  TEMPLATES = {
17
24
  %w[NotFound creating_no_identity] =>
18
- "{command} creates a {aggregate} — pass {identity}:",
25
+ "{command} creates a {aggregate} — pass {identity}:",
19
26
  %w[AlreadyExists creating_duplicate] =>
20
- "{command} creates a {aggregate} that already exists — {identity} {offered}",
27
+ "{command} creates a {aggregate} that already exists — {identity} {offered}",
21
28
  %w[AlreadyExists entity_duplicate] =>
22
- "a {entity} already exists on {aggregate} — {identity} {offered}",
29
+ "a {entity} already exists on {aggregate} — {identity} {offered}",
23
30
  %w[NotFound acting_no_identity] =>
24
- "{command} acts on an existing {aggregate} — pass {identity}:",
31
+ "{command} acts on an existing {aggregate} — pass {identity}:",
25
32
  %w[NotFound record_missing] =>
26
- "no {aggregate} with {identity} {offered}",
33
+ "no {aggregate} with {identity} {offered}",
27
34
  %w[NotFound entity_parent_no_identity] =>
28
- "{command} acts on a {aggregate}'s {entity} — pass {identity}:",
35
+ "{command} acts on a {aggregate}'s {entity} — pass {identity}:",
29
36
  %w[UnknownVerb entity_unknown] =>
30
- "{aggregate} has no entity {entity}",
37
+ "{aggregate} has no entity {entity}",
31
38
  %w[NotFound entity_element_no_identity] =>
32
- "{command} acts on one {entity} — pass {identity}:",
39
+ "{command} acts on one {entity} — pass {identity}:",
33
40
  %w[NotFound entity_element_missing] =>
34
- "no {entity} with {identity} {wants} on {aggregate} {parent_id}",
41
+ "no {entity} with {identity} {wants} on {aggregate} {parent_id}",
35
42
  %w[NotFound reference_target_missing] =>
36
- "no {target} with {heads} {key}",
43
+ "no {target} with {heads} {key}",
37
44
  %w[NotFound read_model_reference_missing] =>
38
- "no {aggregate} with reference {offered}",
45
+ "no {aggregate} with reference {offered}",
39
46
  %w[TypeMismatch read_model_object_reference] =>
40
- "{query} refused — a reference is an id, and {field} arrived as an object",
47
+ "{query} refused — a reference is an id, and {field} arrived as an object",
41
48
  %w[UnknownVerb no_query] =>
42
- "{aggregate} has no query {query}",
49
+ "{aggregate} has no query {query}",
43
50
  %w[UnknownVerb entity_query_missing] =>
44
- "{entity} has no query {query}",
51
+ "{entity} has no query {query}",
45
52
  %w[UnknownVerb entity_holds_no_list] =>
46
- "{aggregate} holds no list of {entity}",
53
+ "{aggregate} holds no list of {entity}",
47
54
  %w[UnknownVerb entity_no_command] =>
48
- "{entity} has no command {command}",
55
+ "{entity} has no command {command}",
49
56
  %w[UnknownVerb aggregate_no_command] =>
50
- "{aggregate} has no command {command}",
57
+ "{aggregate} has no command {command}",
51
58
  %w[UnknownVerb port_no_operation] =>
52
- "{port} has no operation {operation}",
59
+ "{port} has no operation {operation}",
53
60
  %w[UnknownVerb no_domain] =>
54
- "no domain {domain} loaded (verb {verb})",
61
+ "no domain {domain} loaded (verb {verb})",
55
62
  %w[UnknownVerb no_read_model] =>
56
- "{domain} has no read model {query}",
63
+ "{domain} has no read model {query}",
57
64
  %w[UnknownVerb not_fully_qualified] =>
58
- "{verb} is not a fully-qualified verb (Domain::Aggregate.Command)",
65
+ "{verb} is not a fully-qualified verb (Domain::Aggregate.Command)",
59
66
  %w[UnknownVerb no_aggregate] =>
60
- "{domain} has no aggregate {aggregate}",
67
+ "{domain} has no aggregate {aggregate}",
61
68
  %w[LifecycleRefused transition_blocked] =>
62
- "{command} refused — {field} is {current}, and {command} moves it only from {allowed}",
69
+ "{command} refused — {field} is {current}, and {command} moves it only from {allowed}",
63
70
  %w[TypeMismatch value_object_shape] =>
64
- "{name} is a {type} — pass its fields as an object, not {offered}",
71
+ "{name} is a {type} — pass its fields as an object, not {offered}",
65
72
  %w[TypeMismatch reference_as_object] =>
66
- "{command} refused — a reference is an id, and {attribute} arrived as an object{known_by}",
73
+ "{command} refused — a reference is an id, and {attribute} arrived as an object{known_by}",
67
74
  %w[TypeMismatch multi_field_scalar] =>
68
- "{type} has multiple fields and cannot stand in for a scalar",
75
+ "{type} has multiple fields and cannot stand in for a scalar",
69
76
  %w[TypeMismatch composite_identity] =>
70
- "{type} is a composite identity — an identity must have exactly one field",
77
+ "{type} is a composite identity — an identity must have exactly one field",
71
78
  %w[TypeMismatch numeric_field] =>
72
- "{type}.{field} expects {expected}, got {offered}",
79
+ "{type}.{field} expects {expected}, got {offered}",
73
80
  %w[TypeMismatch non_finite_field] =>
74
- "{type}.{field} must be a finite number, got {offered}",
81
+ "{type}.{field} must be a finite number, got {offered}",
82
+ %w[TypeMismatch integer_range] =>
83
+ "{type}.{field} must fit in a 64-bit integer, got {offered}",
75
84
  %w[TypeMismatch pattern_mismatch] =>
76
- "{type}.{field} must match {pattern}, got {offered}",
85
+ "{type}.{field} must match {pattern}, got {offered}",
77
86
  %w[TypeMismatch arithmetic_amount] =>
78
- "{op} of {target} needs an Integer, got {offered}",
87
+ "{op} of {target} needs an Integer, got {offered}",
79
88
  %w[TypeMismatch arithmetic_current] =>
80
- "{op} of {target} needs an Integer {target}, got {offered}",
89
+ "{op} of {target} needs an Integer {target}, got {offered}",
81
90
  %w[TypeMismatch arithmetic_shared_field] =>
82
- "{op} of {target} needs a value object with one shared Integer field",
91
+ "{op} of {target} needs a value object with one shared Integer field",
83
92
  %w[UnknownArgument unknown_args] =>
84
- "{command} does not declare {unknown} — it takes {declared}",
93
+ "{command} does not declare {unknown} — it takes {declared}",
85
94
  %w[AbsentArgument absent_args] =>
86
- "{command} was not given {absent} — it takes {declared}",
95
+ "{command} was not given {absent} — it takes {declared}",
87
96
  %w[InvariantViolation closed_set_member] =>
88
- "{type} admits {admitted} — got {offered}",
97
+ "{type} admits {admitted} — got {offered}",
89
98
  %w[InvariantViolation value_object_invariant] =>
90
- "{name} invariant violated — {description} (given {offered})",
99
+ "{name} invariant violated — {description} (given {offered})",
91
100
  %w[InvariantViolation admits_declared_set] =>
92
- "{name} admits {admits} — {admitted} — got {offered}",
101
+ "{name} admits {admits} — {admitted} — got {offered}",
93
102
  %w[InvariantViolation undeclared_set] =>
94
- "{name} admits {admits}, which this chapter does not declare — a closed set is named " \
95
- "Aggregate::SetName, and it must be one the bluebook actually holds",
103
+ "{name} admits {admits}, which this chapter does not declare — a closed set is named Aggregate::SetName, and it must " \
104
+ "be one the bluebook actually holds",
96
105
  %w[Unauthorized tenant_required] =>
97
- "{query} declares authorize with tenant: {field} — pass {field}: to name which " \
98
- "{field} this ask is scoped to",
106
+ "{query} declares authorize with tenant: {field} — pass {field}: to name which {field} this ask is scoped to",
99
107
  %w[Unauthorized role_mismatch] =>
100
- "{command} refused — role: {role}, and the caller stated {caller_role}",
108
+ "{command} refused — role: {role}, and the caller stated {caller_role}",
101
109
  %w[AttributeAbsent absent_read] =>
102
- "{aggregate} {field} is absent on this record — declared, not optional, and " \
103
- "added since it was written. Backfill it in a translation (backfill :{field}, " \
104
- "default: ...), or declare it optional: true",
110
+ "{aggregate} {field} is absent on this record — declared, not optional, and added since it was written. Backfill it " \
111
+ "in a translation (backfill :{field}, default: ...), or declare it optional: true",
105
112
  %w[ProjectionAbsent absent_read] =>
106
- "{aggregate} {field} is not yet projected on this record — declared via " \
107
- "projects :{field}, but no rebuild sweep has populated it. Run the sweep, " \
108
- "or read {reference}.{remote_field} directly if this rule cannot wait"
113
+ "{aggregate} {field} is not yet projected on this record — declared via projects :{field}, but no rebuild sweep has " \
114
+ "populated it. Run the sweep, or read {reference}.{remote_field} directly if this rule cannot wait"
109
115
  }.freeze
110
116
 
111
117
  module_function
@@ -42,7 +42,9 @@ module Hecks
42
42
  # why reusing that one would deadlock.
43
43
  def saga_persistence(domain)
44
44
  key = domain.to_s
45
- @saga_persistence[key] || @saga_persistence_mutex.synchronize { @saga_persistence[key] ||= resolve_saga_persistence(key) }
45
+ @saga_persistence[key] || @saga_persistence_mutex.synchronize do
46
+ @saga_persistence[key] ||= resolve_saga_persistence(key)
47
+ end
46
48
  end
47
49
 
48
50
  # WALKS EVERY LOADED DOMAIN, repopulating `saga_instances` from
@@ -45,6 +45,7 @@ module Hecks
45
45
  end
46
46
 
47
47
  warn_undurable_sagas!(hexagon)
48
+ warn_undurable_outbox!(hexagon)
48
49
  end
49
50
  self
50
51
  end
@@ -61,10 +62,10 @@ module Hecks
61
62
  )
62
63
  adapter_class(name)
63
64
  self
64
- rescue WiringError => error
65
+ rescue WiringError => e
65
66
  raise WiringError,
66
67
  "the default persistence adapter (#{name}) is not usable, so an " \
67
- "aggregate with no bind could not be given one: #{error.message}"
68
+ "aggregate with no bind could not be given one: #{e.message}"
68
69
  end
69
70
 
70
71
  def check_verb(bind)
@@ -227,6 +228,42 @@ module Hecks
227
228
  # refusing the boot outright would break a choice an author made
228
229
  # deliberately. What a deploy needs is for the gap to be loud and
229
230
  # undeniable, not for local dev/test to become impossible.
231
+ # THE OUTBOX'S TWIN OF `warn_undurable_sagas!` — a domain that
232
+ # declares anything a commit could owe a reaction to (a policy
233
+ # listening to one of its events, or a process manager) but is
234
+ # bound to an adapter with no outbox (`AppendOnly#outbox?`) gets
235
+ # reactions the pre-outbox way: run inline, lost on a crash
236
+ # between commit and reaction. A warning, not a refusal, for the
237
+ # reason `warn_undurable_sagas!` gives — and Memory HAS an outbox
238
+ # (in-process, like everything else it holds), so a dev/test
239
+ # boot stays quiet; this speaks up for the file/remote adapters
240
+ # that persist state durably but hand reactions to nothing.
241
+ def warn_undurable_outbox!(hexagon)
242
+ bluebook_ir = bluebook(hexagon.domain)
243
+ return unless bluebook_ir
244
+ return if bluebook_ir.process_managers.empty? && !any_policy_listens_to?(bluebook_ir)
245
+
246
+ anchor = bluebook_ir.aggregates.first or return
247
+ bind = Ports::Persistence::BindingPolicy.resolve(self, hexagon.domain, anchor)
248
+ return if adapter_class(bind.adapter) <= Ports::Persistence::RemoteRuntime
249
+ return if repository(hexagon.domain, anchor).outbox?
250
+
251
+ warn "[hecks] #{hexagon.domain} declares policies/process_managers but its persistence adapter " \
252
+ "(#{bind.adapter}) has no outbox — reactions run inline and a crash between a command's commit " \
253
+ "and its reactions loses them silently. Bind SqlitePersistence or Postgres for a durable outbox " \
254
+ "(see Runtime::Outbox), or accept in-process-only reactions on purpose."
255
+ rescue WiringError
256
+ nil
257
+ end
258
+
259
+ def any_policy_listens_to?(bluebook_ir)
260
+ emitted = bluebook_ir.aggregates.flat_map do |aggregate|
261
+ aggregate.commands.flat_map(&:emits) +
262
+ aggregate.ports.flat_map { |port| port.operations.flat_map { |op| [*op.emits, op.answers, op.refuses] } }
263
+ end.compact.map(&:to_s)
264
+ @bluebooks.each_value.any? { |candidate| candidate.policies.any? { |policy| emitted.include?(policy.event_name.to_s) } }
265
+ end
266
+
230
267
  def warn_undurable_sagas!(hexagon)
231
268
  bluebook_ir = bluebook(hexagon.domain)
232
269
  return unless bluebook_ir
@@ -1,5 +1,6 @@
1
1
  require_relative "registry/verification"
2
2
  require_relative "registry/saga_persistence"
3
+ require_relative "outbox"
3
4
 
4
5
  module Hecks
5
6
  module Runtime
@@ -87,8 +88,16 @@ module Hecks
87
88
  # reason).
88
89
  @saga_persistence = {}
89
90
  @saga_persistence_mutex = Mutex.new
91
+ @outbox = Outbox::Relay.new(self)
90
92
  end
91
93
 
94
+ # THE OUTBOX RELAY — one per registry, for its whole life (built
95
+ # here, never swapped, so no thread ever sees a different one).
96
+ # It can enqueue from the moment the registry exists; a Dispatcher
97
+ # attaches the interpreters that let it deliver. See
98
+ # `Runtime::Outbox`.
99
+ attr_reader :outbox
100
+
92
101
  # THE BUILDER STAYS OPEN FOR THE LIFE OF THIS REGISTRY, keyed by chapter
93
102
  # name — see the comment on `BluebookBuilder.build`. A chapter split across
94
103
  # several files (`language/bluebook/*.bluebook`, all `Hecks.bluebook "Bluebook"`)
@@ -201,6 +210,7 @@ module Hecks
201
210
  @saga_instances.clear
202
211
  @repositories = {}
203
212
  @projection_repositories = {}
213
+ @outbox.log.clear
204
214
  rehydrate_sagas!
205
215
  self
206
216
  end
@@ -249,13 +259,13 @@ module Hecks
249
259
  # authoritative bind" check is what actually catches a genuine
250
260
  # double-bind; this merge only concatenates, it does not itself
251
261
  # decide which of two binds for the same aggregate wins.
252
- def merge_hecksagons(a, b)
262
+ def merge_hecksagons(base, overlay)
253
263
  Bluebook::Hecksagon.new(
254
- domain: a.domain,
255
- binds: a.binds + b.binds,
256
- subscriptions: a.subscriptions + b.subscriptions,
257
- framework_members: a.framework_members + b.framework_members,
258
- vendored_bluebooks: a.vendored_bluebooks + b.vendored_bluebooks
264
+ domain: base.domain,
265
+ binds: base.binds + overlay.binds,
266
+ subscriptions: base.subscriptions + overlay.subscriptions,
267
+ framework_members: base.framework_members + overlay.framework_members,
268
+ vendored_bluebooks: base.vendored_bluebooks + overlay.vendored_bluebooks
259
269
  )
260
270
  end
261
271
 
@@ -266,12 +276,12 @@ module Hecks
266
276
  # also declares REPLACES that key's whole resolved hash (the same
267
277
  # all-or-nothing shape `WorldBuilder#method_missing` already builds
268
278
  # each entry as), it does not deep-merge field by field within it.
269
- def merge_worlds(a, b)
279
+ def merge_worlds(base, overlay)
270
280
  Bluebook::World.new(
271
- domain: a.domain,
272
- realm: b.realm || a.realm,
273
- latest: b.latest || a.latest,
274
- settings: a.settings.merge(b.settings)
281
+ domain: base.domain,
282
+ realm: overlay.realm || base.realm,
283
+ latest: overlay.latest || base.latest,
284
+ settings: base.settings.merge(overlay.settings)
275
285
  )
276
286
  end
277
287
  end
@@ -81,7 +81,9 @@ module Hecks
81
81
  # Lambda that has no way to represent its lineage history at
82
82
  # all.
83
83
  adapter_name = Ports::Persistence::BindingPolicy.resolve(@registry, domain, aggregate).adapter
84
- return @local.dispatch(verb, saga_correlation: saga_correlation, **args) unless @registry.adapter_class(adapter_name) <= Ports::Persistence::RemoteRuntime
84
+ unless @registry.adapter_class(adapter_name) <= Ports::Persistence::RemoteRuntime
85
+ return @local.dispatch(verb, saga_correlation: saga_correlation, **args)
86
+ end
85
87
 
86
88
  response = @client.dispatch(verb, args)
87
89
 
@@ -19,74 +19,87 @@ module Hecks
19
19
  def envelope(to, entity_depth: 0)
20
20
  return nil if to.nil?
21
21
 
22
- aggregate, entities = if to.is_a?(Hash)
23
- hash = to.transform_keys(&:to_sym)
24
- unknown = hash.keys - %i[aggregate entity entities]
25
- unless unknown.empty?
26
- raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}"
27
- end
28
-
29
- [hash[:aggregate], entity_identities(hash)]
30
- else
31
- [to, []]
32
- end
33
-
34
- if aggregate.nil? || aggregate.to_s.empty?
35
- raise TypeMismatch, "to: must name the receiving aggregate identity"
36
- end
22
+ aggregate, entities = to.is_a?(Hash) ? parse_envelope_hash(to) : [to, []]
23
+
24
+ raise TypeMismatch, "to: must name the receiving aggregate identity" if aggregate.nil? || aggregate.to_s.empty?
37
25
  if entities.size != entity_depth
38
26
  raise TypeMismatch,
39
27
  "to: for an entity command needs #{entity_depth} entity " \
40
28
  "#{entity_depth == 1 ? 'identity' : 'identities'} after the aggregate — got #{entities.size}"
41
29
  end
42
- if entities.any? { |identity| identity.nil? || identity.to_s.empty? }
43
- raise TypeMismatch, "to: contains a blank entity identity"
30
+ raise TypeMismatch, "to: contains a blank entity identity" if entities.any? do |identity|
31
+ identity.nil? || identity.to_s.empty?
44
32
  end
45
33
 
46
34
  Envelope.new(aggregate: aggregate, entities: entities)
47
35
  end
48
36
 
37
+ # The Hash-shaped half of `to:` — pulled out of `envelope` because
38
+ # it is a self-contained parse (raises on an unrecognized key, then
39
+ # returns the pair) with no dependency on anything `envelope` does
40
+ # afterward; the validations that follow apply the same way
41
+ # whichever branch produced `aggregate`/`entities`.
42
+ def parse_envelope_hash(to)
43
+ hash = to.transform_keys(&:to_sym)
44
+ unknown = hash.keys - %i[aggregate entity entities]
45
+ raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}" unless unknown.empty?
46
+
47
+ [hash[:aggregate], entity_identities(hash)]
48
+ end
49
+ private_class_method :parse_envelope_hash
50
+
49
51
  # `with:` is deliberately strict. Compatibility-only calls still pass
50
52
  # loose keyword arguments through the old addressing gate, but a caller
51
53
  # choosing the explicit envelope cannot smuggle receiver identity back
52
54
  # into the payload.
53
55
  def payload(command, with:, legacy:)
54
56
  if with && !legacy.empty?
55
- raise TypeMismatch, "dispatch takes command facts in with:, not both with: and loose keyword arguments"
57
+ raise TypeMismatch,
58
+ "dispatch takes command facts in with:, not both with: and loose keyword arguments"
56
59
  end
57
60
 
58
61
  return legacy unless with
59
- unless with.is_a?(Hash)
60
- raise TypeMismatch, "with: must be a hash of command facts"
61
- end
62
+ raise TypeMismatch, "with: must be a hash of command facts" unless with.is_a?(Hash)
62
63
 
63
64
  offered = with.transform_keys(&:to_sym)
64
65
  declared = command.attributes.map { |attribute| attribute.name.to_sym }
66
+ refuse_unknown_facts!(command, offered, declared)
67
+ refuse_absent_facts!(command, offered, declared)
68
+ offered
69
+ end
70
+
71
+ # The two `with:` shape checks `payload` runs in sequence — pulled
72
+ # out because each is a self-contained "compute a difference, raise
73
+ # if non-empty" rule with no dependency on the other. Order stays
74
+ # unknown-before-absent, exactly as inline: `payload` calls them in
75
+ # that order, so a fact that is both unknown AND leaves something
76
+ # else absent still raises UnknownArgument first, same as before.
77
+ def refuse_unknown_facts!(command, offered, declared)
65
78
  unknown = (offered.keys - declared).sort
66
- unless unknown.empty?
67
- reading = declared.empty? ? "none" : declared.join(", ")
68
- raise UnknownArgument,
69
- RefusalWording.render("UnknownArgument", "unknown_args",
70
- command: command.hecks_name, unknown: unknown.join(", "),
71
- declared: reading)
72
- end
79
+ return if unknown.empty?
80
+
81
+ reading = declared.empty? ? "none" : declared.join(", ")
82
+ raise UnknownArgument,
83
+ RefusalWording.render("UnknownArgument", "unknown_args",
84
+ command: command.hecks_name, unknown: unknown.join(", "),
85
+ declared: reading)
86
+ end
87
+ private_class_method :refuse_unknown_facts!
73
88
 
89
+ def refuse_absent_facts!(command, offered, declared)
74
90
  absent = command.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } - offered.keys
75
- unless absent.empty?
76
- reading = declared.empty? ? "none" : declared.join(", ")
77
- raise AbsentArgument,
78
- RefusalWording.render("AbsentArgument", "absent_args",
79
- command: command.hecks_name, absent: absent.sort.join(", "),
80
- declared: reading)
81
- end
91
+ return if absent.empty?
82
92
 
83
- offered
93
+ reading = declared.empty? ? "none" : declared.join(", ")
94
+ raise AbsentArgument,
95
+ RefusalWording.render("AbsentArgument", "absent_args",
96
+ command: command.hecks_name, absent: absent.sort.join(", "),
97
+ declared: reading)
84
98
  end
99
+ private_class_method :refuse_absent_facts!
85
100
 
86
101
  def entity_identities(hash)
87
- if hash.key?(:entities) && hash.key?(:entity)
88
- raise TypeMismatch, "to: takes entity: or entities:, not both"
89
- end
102
+ raise TypeMismatch, "to: takes entity: or entities:, not both" if hash.key?(:entities) && hash.key?(:entity)
90
103
 
91
104
  hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
92
105
  end