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
@@ -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?
@@ -46,7 +66,10 @@ module Hecks
46
66
  # than narrowed.
47
67
  unless rootless || model.group_by.any? || model.count? || model.median_field
48
68
  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)
69
+ if repository.respond_to?(:query_read_model) && repository.adapter.respond_to?(:query_read_model)
70
+ return repository.query_read_model(domain, model, args,
71
+ bluebook)
72
+ end
50
73
  end
51
74
 
52
75
  # ROOT FIRST, ALWAYS — regardless of `include` order in the
@@ -103,7 +126,7 @@ module Hecks
103
126
  # Declared order preserved in the OUTPUT — only the
104
127
  # computation above needed reordering, not what a caller sees
105
128
  # back.
106
- heads = model.aggregate_heads.each_with_object({}) { |head, report| report[head[:as]] = rows_by_as[head[:as]] }
129
+ heads = model.aggregate_heads.to_h { |head| [head[:as], rows_by_as[head[:as]]] }
107
130
  grouped_head = group_by_target(model, bluebook)
108
131
  reduced_head = aggregation_target(model, bluebook)
109
132
  [heads.each_with_object({}) do |(as, value), out|
@@ -343,7 +366,7 @@ module Hecks
343
366
  .map(&:name)
344
367
  end
345
368
 
346
- def matching(records) = records.select { |record| yield(record) }.sort_by(&:id)
369
+ def matching(records, &) = records.select(&).sort_by(&:id)
347
370
  def row(record) = record.to_h
348
371
  end
349
372
  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
@@ -14,8 +14,8 @@ module Hecks
14
14
  # correlation key even IS is representation-dependent (the object
15
15
  # itself? its serialised text?). `:"end_to_end.value"` reads the one
16
16
  # field with a single unambiguous rendering.
17
- def saga_correlation(pm, event)
18
- path = pm.correlates_by.to_s.split(".")
17
+ def saga_correlation(process_manager, event)
18
+ path = process_manager.correlates_by.to_s.split(".")
19
19
  # A LATER EVENT MAY ALREADY HOLD THE SCALAR. `reference.value` digs a
20
20
  # value object's field out of a FRESH declaration (TransferRequested's
21
21
  # `reference` IS a TransferReference) — but a downstream event this
@@ -45,7 +45,7 @@ module Hecks
45
45
  # Keyed by `correlation_head`
46
46
  # rather than a bare scalar so an event stamped by one saga cannot be
47
47
  # misread by an unrelated one correlating on a different field.
48
- stamped = event.correlation && event.correlation[pm.correlation_head.to_s]
48
+ stamped = event.correlation && event.correlation[process_manager.correlation_head.to_s]
49
49
  return stamped unless stamped.nil? || stamped.to_s.empty?
50
50
 
51
51
  # A SELF-REFERENCING LEG carries the correlation forward under its
@@ -69,7 +69,7 @@ module Hecks
69
69
  # not by which dispatch convention the caller happened to use.
70
70
  #
71
71
  # GATED, still — a manually-dispatched command on a wholly
72
- # UNRELATED aggregate can share an event NAME this pm happens to
72
+ # UNRELATED aggregate can share an event NAME this process_manager happens to
73
73
  # handle (`Drawer.Take` also emits "Taken", the same name a
74
74
  # SAGA-DISPATCHED leg uses) with nothing this saga should read as
75
75
  # its own conversation. What makes a leg genuinely
@@ -79,17 +79,17 @@ module Hecks
79
79
  # name: `OnboardingCase.identity_heads` really does include
80
80
  # `:reference`, `correlates_by :"reference.value"`'s own head ;
81
81
  # `Drawer.identity_heads` is `[:number]`, nowhere close.
82
- self_identified?(pm, event) ? event.id : nil
82
+ self_identified?(process_manager, event) ? event.id : nil
83
83
  end
84
84
 
85
- def self_identified?(pm, event)
85
+ def self_identified?(process_manager, event)
86
86
  domain, bare_name = event.aggregate.to_s.split("::", 2)
87
87
  return false unless bare_name
88
88
 
89
89
  construct = @registry.bluebook(domain)&.aggregate(bare_name)
90
90
  return false unless construct
91
91
 
92
- construct.identity_heads.map(&:to_s).include?(pm.correlation_head.to_s)
92
+ construct.identity_heads.map(&:to_s).include?(process_manager.correlation_head.to_s)
93
93
  end
94
94
  end
95
95
  end