hecks 0.2.0 → 0.3.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 (408) hide show
  1. checksums.yaml +5 -5
  2. data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +127 -0
  4. data/lib/hecks/adapters/driven/d1.adapter +12 -0
  5. data/lib/hecks/adapters/driven/d1.rb +403 -0
  6. data/lib/hecks/adapters/driven/folder.adapter +3 -0
  7. data/lib/hecks/adapters/driven/folder.rb +199 -0
  8. data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
  9. data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
  10. data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
  11. data/lib/hecks/adapters/driven/governance_authorization.rb +62 -0
  12. data/lib/hecks/adapters/driven/heki/journal.rb +51 -0
  13. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  14. data/lib/hecks/adapters/driven/heki/snapshot.rb +38 -0
  15. data/lib/hecks/adapters/driven/heki.adapter +4 -0
  16. data/lib/hecks/adapters/driven/heki.rb +148 -0
  17. data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
  18. data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
  19. data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
  20. data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
  21. data/lib/hecks/adapters/driven/lambda.adapter +4 -0
  22. data/lib/hecks/adapters/driven/lambda.rb +131 -0
  23. data/lib/hecks/adapters/driven/memory.adapter +3 -0
  24. data/lib/hecks/adapters/driven/memory.rb +103 -0
  25. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
  26. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
  27. data/lib/hecks/adapters/driven/postgres/codec.rb +80 -0
  28. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +168 -0
  29. data/lib/hecks/adapters/driven/postgres.adapter +5 -0
  30. data/lib/hecks/adapters/driven/postgres.rb +391 -0
  31. data/lib/hecks/adapters/driven/postgres_era/lineage/era_store.rb +171 -0
  32. data/lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb +190 -0
  33. data/lib/hecks/adapters/driven/postgres_era/lineage/head_compiler.rb +429 -0
  34. data/lib/hecks/adapters/driven/postgres_era/lineage/mint_transaction.rb +166 -0
  35. data/lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb +286 -0
  36. data/lib/hecks/adapters/driven/postgres_era/lineage/resumable_backfill.rb +168 -0
  37. data/lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb +163 -0
  38. data/lib/hecks/adapters/driven/postgres_era/lineage/transform_installer.rb +114 -0
  39. data/lib/hecks/adapters/driven/postgres_era/lineage.rb +137 -0
  40. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/coverage_check.rb +89 -0
  41. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb +82 -0
  42. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
  43. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/minter.rb +132 -0
  44. data/lib/hecks/adapters/driven/postgres_era/lineage_manager.rb +74 -0
  45. data/lib/hecks/adapters/driven/postgres_era.adapter +6 -0
  46. data/lib/hecks/adapters/driven/postgres_era.rb +671 -0
  47. data/lib/hecks/adapters/driven/prism.adapter +3 -0
  48. data/lib/hecks/adapters/driven/prism.rb +80 -0
  49. data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
  50. data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
  51. data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
  52. data/lib/hecks/adapters/driven/sqlite/codec.rb +63 -0
  53. data/lib/hecks/adapters/driven/sqlite/projection.rb +105 -0
  54. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +180 -0
  55. data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
  56. data/lib/hecks/adapters/driven/sqlite.rb +285 -0
  57. data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
  58. data/lib/hecks/adapters/driven/system_clock.rb +14 -0
  59. data/lib/hecks/adapters/driven.rb +36 -0
  60. data/lib/hecks/adapters.rb +6 -0
  61. data/lib/hecks/behaviors/dsl.rb +96 -0
  62. data/lib/hecks/behaviors/expectations.rb +280 -0
  63. data/lib/hecks/behaviors/ir.rb +31 -0
  64. data/lib/hecks/behaviors/rspec.rb +42 -0
  65. data/lib/hecks/behaviors/runner.rb +96 -0
  66. data/lib/hecks/behaviors.rb +25 -0
  67. data/lib/hecks/bluebook/aggregate.rb +108 -0
  68. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
  69. data/lib/hecks/bluebook/assembly/build.rb +48 -0
  70. data/lib/hecks/bluebook/assembly/contract.rb +112 -0
  71. data/lib/hecks/bluebook/assembly/contracts.rb +409 -0
  72. data/lib/hecks/bluebook/assembly/marks.rb +227 -0
  73. data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
  74. data/lib/hecks/bluebook/assembly.rb +78 -0
  75. data/lib/hecks/bluebook/attribute.rb +96 -0
  76. data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
  77. data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
  78. data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
  79. data/lib/hecks/bluebook/behaviour/command.rb +116 -0
  80. data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
  81. data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
  82. data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
  83. data/lib/hecks/bluebook/behaviour/lifecycle.rb +51 -0
  84. data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
  85. data/lib/hecks/bluebook/behaviour/process_manager.rb +38 -0
  86. data/lib/hecks/bluebook/behaviour/query.rb +10 -0
  87. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
  88. data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
  89. data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
  90. data/lib/hecks/bluebook/chapter.rb +68 -0
  91. data/lib/hecks/bluebook/command.rb +124 -0
  92. data/lib/hecks/bluebook/domain_port.rb +102 -0
  93. data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
  94. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +906 -0
  95. data/lib/hecks/bluebook/dsl/attribute_collector.rb +327 -0
  96. data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
  97. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1018 -0
  98. data/lib/hecks/bluebook/dsl/command_builder.rb +626 -0
  99. data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
  100. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
  101. data/lib/hecks/bluebook/dsl/entity_builder.rb +305 -0
  102. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
  103. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
  104. data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
  105. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
  106. data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
  107. data/lib/hecks/bluebook/dsl/policy_builder.rb +120 -0
  108. data/lib/hecks/bluebook/dsl/port_builder.rb +30 -0
  109. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
  110. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +207 -0
  111. data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
  112. data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
  113. data/lib/hecks/bluebook/dsl/rule_reference.rb +172 -0
  114. data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
  115. data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
  116. data/lib/hecks/bluebook/dsl/word_gate.rb +221 -0
  117. data/lib/hecks/bluebook/dsl/world_builder.rb +77 -0
  118. data/lib/hecks/bluebook/dsl.rb +45 -0
  119. data/lib/hecks/bluebook/entity.rb +103 -0
  120. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -0
  121. data/lib/hecks/bluebook/expression/evaluator.rb +261 -0
  122. data/lib/hecks/bluebook/expression/projection.json +170 -0
  123. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
  124. data/lib/hecks/bluebook/expression/resolver.rb +630 -0
  125. data/lib/hecks/bluebook/expression.rb +13 -0
  126. data/lib/hecks/bluebook/hexagon.rb +60 -0
  127. data/lib/hecks/bluebook/lifecycle.rb +42 -0
  128. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
  129. data/lib/hecks/bluebook/meta_validator/judge.rb +559 -0
  130. data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
  131. data/lib/hecks/bluebook/meta_validator/port_judge.rb +47 -0
  132. data/lib/hecks/bluebook/meta_validator/readings.rb +350 -0
  133. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +312 -0
  134. data/lib/hecks/bluebook/meta_validator/shapes.rb +246 -0
  135. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
  136. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
  137. data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
  138. data/lib/hecks/bluebook/meta_validator.rb +452 -0
  139. data/lib/hecks/bluebook/model_check.rb +318 -0
  140. data/lib/hecks/bluebook/pattern_subset.rb +120 -0
  141. data/lib/hecks/bluebook/policy.rb +41 -0
  142. data/lib/hecks/bluebook/process_manager.rb +86 -0
  143. data/lib/hecks/bluebook/project_discovery.rb +30 -0
  144. data/lib/hecks/bluebook/project_loader.rb +40 -0
  145. data/lib/hecks/bluebook/project_register.rb +82 -0
  146. data/lib/hecks/bluebook/query.rb +61 -0
  147. data/lib/hecks/bluebook/read_model.rb +109 -0
  148. data/lib/hecks/bluebook/reference.rb +74 -0
  149. data/lib/hecks/bluebook/smoke_test.rb +166 -0
  150. data/lib/hecks/bluebook/synthesizer.rb +95 -0
  151. data/lib/hecks/bluebook/translation.rb +92 -0
  152. data/lib/hecks/bluebook/value_object.rb +58 -0
  153. data/lib/hecks/bluebook.rb +74 -0
  154. data/lib/hecks/codemod.rb +342 -0
  155. data/lib/hecks/construct.rb +71 -0
  156. data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
  157. data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
  158. data/lib/hecks/deploy/oidc.json +18 -0
  159. data/lib/hecks/doc/reference.rb +389 -0
  160. data/lib/hecks/embryonaut_bluebook.rb +75 -0
  161. data/lib/hecks/facade/cli_door.rb +116 -0
  162. data/lib/hecks/facade/cli_runner.rb +190 -0
  163. data/lib/hecks/facade/command_request.rb +105 -0
  164. data/lib/hecks/facade/handle.rb +173 -0
  165. data/lib/hecks/facade/json_door.rb +154 -0
  166. data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
  167. data/lib/hecks/facade/surface/chapter.rb +107 -0
  168. data/lib/hecks/facade/surface.rb +48 -0
  169. data/lib/hecks/facade.rb +44 -0
  170. data/lib/hecks/forms/app.rb +300 -0
  171. data/lib/hecks/forms/command_form_renderer.rb +113 -0
  172. data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
  173. data/lib/hecks/forms/field_renderer.rb +170 -0
  174. data/lib/hecks/forms/field_shape.rb +232 -0
  175. data/lib/hecks/forms/html.rb +53 -0
  176. data/lib/hecks/forms/index_renderer.rb +35 -0
  177. data/lib/hecks/forms/page.rb +157 -0
  178. data/lib/hecks/forms/params.rb +132 -0
  179. data/lib/hecks/forms/query_form_renderer.rb +114 -0
  180. data/lib/hecks/forms/record_renderer.rb +115 -0
  181. data/lib/hecks/forms/record_table.rb +63 -0
  182. data/lib/hecks/forms/reference_options.rb +30 -0
  183. data/lib/hecks/forms/value_object_shape.rb +46 -0
  184. data/lib/hecks/forms.rb +54 -0
  185. data/lib/hecks/fqn.rb +94 -0
  186. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
  187. data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
  188. data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
  189. data/lib/hecks/framework/bluebook/governance.bluebook +130 -0
  190. data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
  191. data/lib/hecks/framework/oidc.json +39 -0
  192. data/lib/hecks/framework.rb +90 -0
  193. data/lib/hecks/freezer.rb +67 -0
  194. data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
  195. data/lib/hecks/fuzzing/isolated_boot.rb +92 -0
  196. data/lib/hecks/fuzzing/properties.rb +1146 -0
  197. data/lib/hecks/fuzzing/replay.rb +635 -0
  198. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
  199. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +54 -0
  200. data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
  201. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +185 -0
  202. data/lib/hecks/fuzzing/sequence_generator.rb +130 -0
  203. data/lib/hecks/fuzzing/value_generator.rb +177 -0
  204. data/lib/hecks/fuzzing.rb +12 -0
  205. data/lib/hecks/grammar/evolve.rb +291 -0
  206. data/lib/hecks/grammar/expression.bluebook +417 -0
  207. data/lib/hecks/grammar/expression_operators.json +1152 -0
  208. data/lib/hecks/grammar/grammar.hecksagon +20 -0
  209. data/lib/hecks/grammar/oidc.json +69 -0
  210. data/lib/hecks/grammar/translation.bluebook +203 -0
  211. data/lib/hecks/grammar.rb +178 -0
  212. data/lib/hecks/ir.rb +126 -0
  213. data/lib/hecks/language/adapter.bluebook +116 -0
  214. data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
  215. data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
  216. data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
  217. data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
  218. data/lib/hecks/language/bluebook/command.bluebook +437 -0
  219. data/lib/hecks/language/bluebook/entity.bluebook +316 -0
  220. data/lib/hecks/language/bluebook/policy.bluebook +172 -0
  221. data/lib/hecks/language/bluebook/process_manager.bluebook +264 -0
  222. data/lib/hecks/language/bluebook/projection.bluebook +267 -0
  223. data/lib/hecks/language/bluebook/query.bluebook +245 -0
  224. data/lib/hecks/language/bluebook/shape.bluebook +292 -0
  225. data/lib/hecks/language/bluebook/syntax.bluebook +447 -0
  226. data/lib/hecks/language/bluebook/vocabulary.bluebook +364 -0
  227. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
  228. data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
  229. data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
  230. data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
  231. data/lib/hecks/language/oidc.json +313 -0
  232. data/lib/hecks/language/port.bluebook +92 -0
  233. data/lib/hecks/language/translation/translation.bluebook +110 -0
  234. data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
  235. data/lib/hecks/language/world/wiring.bluebook +62 -0
  236. data/lib/hecks/language/world/world.bluebook +84 -0
  237. data/lib/hecks/literal.rb +125 -0
  238. data/lib/hecks/naming.rb +121 -0
  239. data/lib/hecks/ports/access_control.port +4 -0
  240. data/lib/hecks/ports/access_control.rb +62 -0
  241. data/lib/hecks/ports/agent/answers.rb +104 -0
  242. data/lib/hecks/ports/agent.port +4 -0
  243. data/lib/hecks/ports/agent.rb +167 -0
  244. data/lib/hecks/ports/authentication.port +4 -0
  245. data/lib/hecks/ports/authentication.rb +50 -0
  246. data/lib/hecks/ports/authorization.port +4 -0
  247. data/lib/hecks/ports/authorization.rb +59 -0
  248. data/lib/hecks/ports/clock.port +4 -0
  249. data/lib/hecks/ports/clock.rb +62 -0
  250. data/lib/hecks/ports/extraction.port +4 -0
  251. data/lib/hecks/ports/extraction.rb +37 -0
  252. data/lib/hecks/ports/identity_assignment.port +4 -0
  253. data/lib/hecks/ports/identity_assignment.rb +45 -0
  254. data/lib/hecks/ports/identity_generation.port +4 -0
  255. data/lib/hecks/ports/identity_generation.rb +49 -0
  256. data/lib/hecks/ports/identity_resolution.port +4 -0
  257. data/lib/hecks/ports/identity_resolution.rb +40 -0
  258. data/lib/hecks/ports/loading.port +4 -0
  259. data/lib/hecks/ports/loading.rb +13 -0
  260. data/lib/hecks/ports/persistence/append_only.rb +102 -0
  261. data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
  262. data/lib/hecks/ports/persistence/execution.rb +18 -0
  263. data/lib/hecks/ports/persistence/lineage.rb +277 -0
  264. data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
  265. data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
  266. data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
  267. data/lib/hecks/ports/persistence.port +4 -0
  268. data/lib/hecks/ports/persistence.rb +39 -0
  269. data/lib/hecks/ports/projection.port +4 -0
  270. data/lib/hecks/ports/projection.rb +76 -0
  271. data/lib/hecks/ports/query/in_memory.rb +60 -0
  272. data/lib/hecks/ports/query/ordering.rb +41 -0
  273. data/lib/hecks/ports/query.rb +36 -0
  274. data/lib/hecks/ports.rb +27 -0
  275. data/lib/hecks/projections/diagrams.rb +413 -0
  276. data/lib/hecks/projections/ir.rb +18 -0
  277. data/lib/hecks/projections/model/deviations.rb +98 -0
  278. data/lib/hecks/projections/model.rb +145 -0
  279. data/lib/hecks/projections/oidc.rb +110 -0
  280. data/lib/hecks/projections/parser_table.rb +159 -0
  281. data/lib/hecks/projections/reference.rb +38 -0
  282. data/lib/hecks/projections/shape.rb +31 -0
  283. data/lib/hecks/projections/statements.rb +110 -0
  284. data/lib/hecks/projections/vocabulary.rb +100 -0
  285. data/lib/hecks/projections.rb +32 -0
  286. data/lib/hecks/projector/cli_projector.rb +437 -0
  287. data/lib/hecks/projector/docs_projector.rb +321 -0
  288. data/lib/hecks/projector/exporter.rb +141 -0
  289. data/lib/hecks/projector/ir_projector.rb +18 -0
  290. data/lib/hecks/projector/narrate_projector.rb +243 -0
  291. data/lib/hecks/projector/target.rb +97 -0
  292. data/lib/hecks/projector.rb +186 -0
  293. data/lib/hecks/query_ir.rb +392 -0
  294. data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
  295. data/lib/hecks/query_specification/common/comparators.rb +25 -0
  296. data/lib/hecks/query_specification/common/comparison.rb +174 -0
  297. data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
  298. data/lib/hecks/query_specification/common/dsl.rb +58 -0
  299. data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
  300. data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
  301. data/lib/hecks/query_specification/common/null_policy.rb +80 -0
  302. data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
  303. data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
  304. data/lib/hecks/query_specification/common/options.rb +32 -0
  305. data/lib/hecks/query_specification/common/order_by.rb +9 -0
  306. data/lib/hecks/query_specification/common/specification.rb +10 -0
  307. data/lib/hecks/query_specification/common/where_clause.rb +9 -0
  308. data/lib/hecks/query_specification/field_path.rb +89 -0
  309. data/lib/hecks/query_specification/hop_path.rb +130 -0
  310. data/lib/hecks/query_specification/read_model/specification.rb +18 -0
  311. data/lib/hecks/query_specification.rb +14 -0
  312. data/lib/hecks/rendering.rb +48 -0
  313. data/lib/hecks/router/namespace_installer.rb +157 -0
  314. data/lib/hecks/router.rb +70 -0
  315. data/lib/hecks/runtime/caller.rb +50 -0
  316. data/lib/hecks/runtime/capability_graph.rb +44 -0
  317. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +124 -0
  318. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +248 -0
  319. data/lib/hecks/runtime/command_interpreter.rb +413 -0
  320. data/lib/hecks/runtime/command_rules/admissibility.rb +307 -0
  321. data/lib/hecks/runtime/command_rules/arithmetic.rb +254 -0
  322. data/lib/hecks/runtime/command_rules/authorization.rb +64 -0
  323. data/lib/hecks/runtime/command_rules/emission.rb +34 -0
  324. data/lib/hecks/runtime/command_rules/references.rb +181 -0
  325. data/lib/hecks/runtime/command_rules.rb +28 -0
  326. data/lib/hecks/runtime/dependency_planning.rb +231 -0
  327. data/lib/hecks/runtime/dispatcher.rb +286 -0
  328. data/lib/hecks/runtime/entity_element.rb +253 -0
  329. data/lib/hecks/runtime/entity_interpreter.rb +228 -0
  330. data/lib/hecks/runtime/era_check.rb +136 -0
  331. data/lib/hecks/runtime/era_guard/shape_diff.rb +128 -0
  332. data/lib/hecks/runtime/era_guard.rb +193 -0
  333. data/lib/hecks/runtime/era_tamper.rb +61 -0
  334. data/lib/hecks/runtime/errors.rb +100 -0
  335. data/lib/hecks/runtime/event.rb +51 -0
  336. data/lib/hecks/runtime/identity.rb +128 -0
  337. data/lib/hecks/runtime/instance.rb +120 -0
  338. data/lib/hecks/runtime/interpreting.rb +69 -0
  339. data/lib/hecks/runtime/loader.rb +143 -0
  340. data/lib/hecks/runtime/policy_interpreter.rb +366 -0
  341. data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
  342. data/lib/hecks/runtime/query_interpreter.rb +254 -0
  343. data/lib/hecks/runtime/reaction_invocation.rb +245 -0
  344. data/lib/hecks/runtime/read_model_interpreter.rb +266 -0
  345. data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
  346. data/lib/hecks/runtime/reference_hop.rb +99 -0
  347. data/lib/hecks/runtime/refusal_wording.rb +124 -0
  348. data/lib/hecks/runtime/registry/saga_persistence.rb +70 -0
  349. data/lib/hecks/runtime/registry/verification.rb +158 -0
  350. data/lib/hecks/runtime/registry.rb +218 -0
  351. data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
  352. data/lib/hecks/runtime/routing.rb +96 -0
  353. data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
  354. data/lib/hecks/runtime/saga_interpreter.rb +301 -0
  355. data/lib/hecks/runtime/storage_shape.rb +120 -0
  356. data/lib/hecks/runtime/tenant_check.rb +84 -0
  357. data/lib/hecks/runtime/tenant_scope.rb +55 -0
  358. data/lib/hecks/runtime/value/admission.rb +112 -0
  359. data/lib/hecks/runtime/value/coercion.rb +500 -0
  360. data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
  361. data/lib/hecks/runtime/value.rb +125 -0
  362. data/lib/hecks/runtime.rb +95 -0
  363. data/lib/hecks/translation/audit/approval_digest.rb +31 -0
  364. data/lib/hecks/translation/audit/layer_one.rb +37 -0
  365. data/lib/hecks/translation/audit/layer_two.rb +77 -0
  366. data/lib/hecks/translation/audit/unfed_report.rb +42 -0
  367. data/lib/hecks/translation/audit.rb +70 -0
  368. data/lib/hecks/translation/reattest.rb +72 -0
  369. data/lib/hecks/translation/rule_compiler.rb +120 -0
  370. data/lib/hecks/translation/scaffold/differ.rb +183 -0
  371. data/lib/hecks/translation/scaffold/renderer.rb +41 -0
  372. data/lib/hecks/translation/scaffold/writer.rb +28 -0
  373. data/lib/hecks/translation/scaffold.rb +30 -0
  374. data/lib/hecks/translation.rb +11 -0
  375. data/lib/hecks/version.rb +16 -0
  376. data/lib/hecks/vocabulary.rb +213 -0
  377. data/lib/hecks.rb +118 -6
  378. metadata +401 -222
  379. data/bin/hecks +0 -7
  380. data/bin/hecks-package +0 -65
  381. data/bin/hecks_console +0 -12
  382. data/bin/hecks_serverless +0 -6
  383. data/lib/cli/build.rb +0 -14
  384. data/lib/cli/command_runner.rb +0 -28
  385. data/lib/cli/console.rb +0 -10
  386. data/lib/cli/generate.rb +0 -37
  387. data/lib/cli/hecks-cli.rb +0 -27
  388. data/lib/cli/test.rb +0 -57
  389. data/lib/console/commands.rb +0 -8
  390. data/lib/console/hecks-console.rb +0 -1
  391. data/lib/packager/README.md +0 -0
  392. data/lib/packager/app_runner.rb +0 -21
  393. data/lib/packager/args.rb +0 -26
  394. data/lib/packager/compatibility/fixnum.rb +0 -6
  395. data/lib/packager/hecks.rb +0 -39
  396. data/lib/packager/query_runner.rb +0 -21
  397. data/lib/packager/resources/Dockerfile +0 -11
  398. data/lib/packager/resources/app_binary +0 -7
  399. data/lib/packager/resources/bundle_config +0 -3
  400. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
  401. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
  402. data/lib/serverless/Domain +0 -32
  403. data/lib/serverless/cli.rb +0 -75
  404. data/lib/serverless/resources/command_name.js +0 -5
  405. data/lib/serverless/resources/environment.js +0 -7
  406. data/lib/serverless/resources/handler.js.tt +0 -28
  407. data/lib/serverless/resources/run_binary.js +0 -22
  408. data/lib/serverless/resources/serverless.yml +0 -20
@@ -0,0 +1,245 @@
1
+ require_relative "../naming"
2
+ require_relative "errors"
3
+ require_relative "identity"
4
+ require_relative "value"
5
+
6
+ module Hecks
7
+ module Runtime
8
+ # Turns facts selected by a policy or process manager into the same
9
+ # receiver/payload envelope an outside caller uses. Reaction declarations
10
+ # historically selected both through one `with:` map, so this is the one
11
+ # compatibility seam that separates receiver identities from facts after
12
+ # resolving the declaration and before re-entering the dispatcher.
13
+ module ReactionInvocation
14
+ Target = Struct.new(:aggregate, :entities, :command, keyword_init: true)
15
+ # :facts, not :values — Struct.new already defines #values (every
16
+ # member's own value, in order); naming this field :values silently
17
+ # shadowed that with the single Hash it actually holds.
18
+ Scope = Struct.new(:name, :facts, keyword_init: true)
19
+
20
+ module_function
21
+
22
+ # The holding IR historically represented both an omitted projection and
23
+ # an explicitly empty `with: {}` as the same empty array. Builders now
24
+ # preserve declaration presence off-wire; reconstructed/legacy IR falls
25
+ # back to the old non-empty reading.
26
+ def projection_declared?(declaration)
27
+ if declaration.instance_variable_defined?(:@projection_declared)
28
+ declaration.instance_variable_get(:@projection_declared)
29
+ else
30
+ !declaration.with_spec.to_a.empty?
31
+ end
32
+ end
33
+
34
+ # A reaction's source names resolve lexically, not globally. Policies
35
+ # supply one event/row scope. Process managers supply current event then
36
+ # opening-event memory, while correlation is an explicit binding ahead
37
+ # of both. Missing names are refused here rather than materialized as nil
38
+ # and accidentally presented as target command facts.
39
+ def resolve_mapping(with_spec:, scopes:, bindings: {}, label: "reaction")
40
+ normalized_bindings = bindings.transform_keys(&:to_sym)
41
+ normalized_scopes = scopes.map do |scope|
42
+ scope = Scope.new(name: scope.first, facts: scope.last) unless scope.is_a?(Scope)
43
+ Scope.new(name: scope.name.to_s, facts: scope.facts.transform_keys(&:to_sym))
44
+ end
45
+
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
+ raise UnknownArgument,
56
+ "#{label}'s with: reads :#{source}, which is not visible in #{names}"
57
+ end
58
+ visible.facts.fetch(source)
59
+ end
60
+
61
+ [key.to_sym, Value.materialize(value)]
62
+ end
63
+ end
64
+
65
+ # A reaction without an explicit projection retains wholesale legacy
66
+ # forwarding. Choosing `with:` opts into the strict envelope: only target
67
+ # command attributes enter `with:`, while identities become `to:`.
68
+ def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
69
+ args = projected.transform_keys(&:to_sym)
70
+ unless explicit
71
+ return args unless source_receiver
72
+
73
+ # Compatibility calls still belong to Dispatcher when the target is
74
+ # absent: it owns the canonical UnknownVerb wording. Resolution here
75
+ # is only an optional opportunity to lift same-aggregate Event.id.
76
+ begin
77
+ target = resolve_target(registry, verb)
78
+ rescue UnknownVerb
79
+ return args
80
+ end
81
+ inherited_receiver = source_receiver_for(target, source_receiver)
82
+ return inherited_receiver ? args.merge(to: inherited_receiver) : args
83
+ end
84
+
85
+ target = resolve_target(registry, verb)
86
+ inherited_receiver = source_receiver_for(target, source_receiver)
87
+ facts = command_facts(target.command, args)
88
+ consumed = facts.keys
89
+
90
+ if target.entities.empty? && target.command.creates?
91
+ refuse_unconsumed!(target.command, args, consumed, passthrough)
92
+ return { with: facts }
93
+ end
94
+
95
+ aggregate_identity, aggregate_keys = identity_for(
96
+ target.aggregate,
97
+ args,
98
+ aliases: aggregate_aliases(target),
99
+ value_owner: target.aggregate
100
+ )
101
+ aggregate_identity ||= inherited_receiver
102
+ require_identity!(verb, target.aggregate, aggregate_identity)
103
+ consumed.concat(aggregate_keys)
104
+
105
+ entity_identities = target.entities.map do |entity|
106
+ identity, keys = identity_for(
107
+ entity,
108
+ args,
109
+ aliases: [Naming.reference_key(entity.hecks_name)],
110
+ value_owner: target.aggregate
111
+ )
112
+ require_identity!(verb, entity, identity)
113
+ consumed.concat(keys)
114
+ identity
115
+ end
116
+
117
+ refuse_unconsumed!(target.command, args, consumed, passthrough)
118
+
119
+ route = if entity_identities.empty?
120
+ aggregate_identity
121
+ else
122
+ { aggregate: aggregate_identity, entities: entity_identities }
123
+ end
124
+ { to: route, with: facts }
125
+ end
126
+
127
+ def resolve_target(registry, verb)
128
+ domain, aggregate_name, command_path = Naming.split_verb(verb)
129
+ raise UnknownVerb, "reaction target #{verb.inspect} is not a qualified command" unless command_path
130
+
131
+ bluebook = registry.bluebook(domain)
132
+ aggregate = bluebook&.aggregate(aggregate_name)
133
+ raise UnknownVerb, "reaction target #{verb.inspect} does not resolve to an aggregate" unless aggregate
134
+
135
+ *entity_names, command_name = command_path.split(".")
136
+ owner = aggregate
137
+ entities = entity_names.map do |entity_name|
138
+ entity = owner.entities.find { |candidate| candidate.hecks_name == entity_name }
139
+ raise UnknownVerb, "reaction target #{verb.inspect} does not resolve entity #{entity_name.inspect}" unless entity
140
+
141
+ owner = entity
142
+ entity
143
+ end
144
+ command = owner.command(command_name)
145
+ raise UnknownVerb, "reaction target #{verb.inspect} does not resolve to a command" unless command
146
+
147
+ Target.new(aggregate: aggregate, entities: entities, command: command)
148
+ end
149
+ private_class_method :resolve_target
150
+
151
+ def command_facts(command, args)
152
+ declared = command.attributes.map { |attribute| attribute.name.to_sym }
153
+ args.slice(*declared)
154
+ end
155
+ private_class_method :command_facts
156
+
157
+ def aggregate_aliases(target)
158
+ aliases = [:aggregate, Naming.reference_key(target.aggregate.name)]
159
+ aliases.unshift(target.command.addressing_key_for(target.aggregate.name)) if target.entities.empty?
160
+ aliases.compact.uniq
161
+ end
162
+ private_class_method :aggregate_aliases
163
+
164
+ # Event.id names the aggregate that emitted the event. It can therefore
165
+ # supply only the receiver of a non-creating command on that SAME root;
166
+ # it cannot address another aggregate, invent an entity identity, or turn
167
+ # a creation into a mutation. An explicit projected receiver is resolved
168
+ # first and remains authoritative.
169
+ def source_receiver_for(target, source_receiver)
170
+ return nil unless source_receiver
171
+ return nil unless target.entities.empty?
172
+ return nil if target.command.creates?
173
+
174
+ source = source_receiver.transform_keys(&:to_sym)
175
+ source_aggregate = source[:aggregate].to_s
176
+ same_aggregate = source_aggregate == if source_aggregate.include?("::")
177
+ target.aggregate.hecks_fqn
178
+ else
179
+ target.aggregate.hecks_name
180
+ end
181
+ return nil unless same_aggregate
182
+
183
+ identity = source[:identity]
184
+ identity.to_s unless identity.nil? || identity.to_s.empty?
185
+ end
186
+ private_class_method :source_receiver_for
187
+
188
+ def identity_for(construct, args, aliases:, value_owner:)
189
+ identity = Identity.of(construct, args, value_owner: value_owner)
190
+ return [identity, construct.identity_heads] if identity
191
+
192
+ aliases.each do |key|
193
+ key = key.to_sym
194
+ next unless args.key?(key)
195
+
196
+ identity = identity_from_alias(construct, args[key], value_owner)
197
+ return [identity, [key]] if identity
198
+ end
199
+
200
+ return [args[:id].to_s, [:id]] if args.key?(:id) && !args[:id].to_s.empty?
201
+
202
+ [nil, []]
203
+ end
204
+ private_class_method :identity_for
205
+
206
+ def identity_from_alias(construct, held, value_owner)
207
+ materialized = Value.materialize(held)
208
+ if materialized.is_a?(Hash)
209
+ keyed = materialized.transform_keys(&:to_sym)
210
+ nested = Identity.of(construct, keyed, value_owner: value_owner)
211
+ return nested if nested
212
+
213
+ if construct.identity_paths.one?
214
+ scalar = Identity.scalar(construct.identity_paths.first, keyed)
215
+ return scalar.to_s unless scalar.nil? || scalar.to_s.empty?
216
+ end
217
+
218
+ return nil
219
+ end
220
+
221
+ materialized.to_s unless materialized.nil? || materialized.to_s.empty?
222
+ end
223
+ private_class_method :identity_from_alias
224
+
225
+ def require_identity!(verb, construct, identity)
226
+ return if identity
227
+
228
+ raise TypeMismatch,
229
+ "reaction target #{verb} needs #{construct.hecks_name}'s receiver identity outside command facts"
230
+ end
231
+ private_class_method :require_identity!
232
+
233
+ def refuse_unconsumed!(command, args, consumed, passthrough)
234
+ allowed = consumed.map(&:to_sym) + Array(passthrough).map(&:to_sym)
235
+ unknown = args.keys - allowed
236
+ return if unknown.empty?
237
+
238
+ raise UnknownArgument,
239
+ "#{command.hecks_name} reaction projection contains neither receiver identity nor declared command facts: " \
240
+ "#{unknown.sort.join(', ')}"
241
+ end
242
+ private_class_method :refuse_unconsumed!
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,266 @@
1
+ require_relative "../rendering"
2
+ require_relative "errors"
3
+ require_relative "value"
4
+ require_relative "refusal_wording"
5
+ require_relative "tenant_scope"
6
+ require_relative "../ports/query/in_memory"
7
+ require_relative "../query_specification/field_path"
8
+
9
+ module Hecks
10
+ module Runtime
11
+ class ReadModelInterpreter
12
+ def initialize(registry) = @registry = registry
13
+
14
+ def call(domain, model, args)
15
+ project(domain, model, args)
16
+ end
17
+
18
+ private
19
+
20
+ def project(domain, model, args)
21
+ bluebook = @registry.bluebook(domain)
22
+ rootless = model.reference_target.nil?
23
+ # BEFORE the adapter early-return below, so the SQLite path inherits it.
24
+ # Without this a stale caller passing a wrapped reference gets a
25
+ # path-dependent answer — an adapter could quietly open the wrapped
26
+ # reference while the in-process path reads it whole and finds nothing.
27
+ # Refused up front, identically on every path. Nothing to refuse for
28
+ # a rootless model — there's no reference argument to have offered
29
+ # wrong at all.
30
+ refuse_object_reference(model, args) unless rootless
31
+ reference_id = reference(args.fetch(model.reference_name)) unless rootless
32
+ # Computed off the ORIGINAL model, before TenantScope wraps it — the
33
+ # "which head do options apply to" question is about what the
34
+ # bluebook author declared, not about the synthetic tenant clause
35
+ # the wrapper adds underneath.
36
+ eligible = model.filtered_head_name
37
+ model = TenantScope.apply(model, args)
38
+ # A ROOTLESS, `group_by`-declared, or `count`/`median`-declared
39
+ # model skips the SQLite native escape hatch entirely (there is
40
+ # no root aggregate to look up a repository FOR when rootless,
41
+ # and `query_read_model` knows nothing about grouping or
42
+ # reducing) — always runs the in-process loop below instead.
43
+ # Correct everywhere ; not SQL-pushed-down for a SQLite-backed
44
+ # aggregate yet, a real, named limit, not a silent one — the
45
+ # same one `group_by` already accepted, extended here rather
46
+ # than narrowed.
47
+ unless rootless || model.group_by.any? || model.count? || model.median_field
48
+ 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)
50
+ end
51
+
52
+ # ROOT FIRST, ALWAYS — regardless of `include` order in the
53
+ # bluebook. `read_model_builder.rb`'s own `include` is
54
+ # documented "Order-independent" (the `:many` flag is resolved
55
+ # at build time, once `@reference_target` is known), but that
56
+ # promise was never kept HERE: this loop used to run heads in
57
+ # their literal declared order and match each "many" head
58
+ # against whatever was ALREADY in `projected` — empty, the
59
+ # very first time through, if a many-side head happened to be
60
+ # declared before the root. A real, live bug (not a guess):
61
+ # `include Promotion` before `include Item` on a read model
62
+ # whose root IS Item silently returned an empty array for
63
+ # Promotion — no error, just a wrong, too-small answer — while
64
+ # the reverse order worked purely by accident. `partition`,
65
+ # not `sort_by`: Ruby's `sort_by` is not guaranteed stable,
66
+ # and correctness here must not depend on it being so by
67
+ # chance on the current MRI build.
68
+ root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
69
+ projected = []
70
+ rows_by_as = {}
71
+ (root_heads + other_heads).each do |head|
72
+ rows = if head[:aggregate] == model.reference_target
73
+ [fetch(bluebook, domain, head[:aggregate], reference_id)]
74
+ elsif rootless
75
+ # No root to FK-match against — a rootless model reads
76
+ # each of its own heads WHOLE, independently. (Multiple
77
+ # heads on one rootless model aren't cross-joined
78
+ # against each other either — each is its own bulk
79
+ # read. A real, deliberate scope limit for now.)
80
+ records(bluebook, domain, head[:aggregate])
81
+ else
82
+ matching(records(bluebook, domain, head[:aggregate])) do |record|
83
+ projected.any? do |source|
84
+ reference_fields(bluebook.aggregate(head[:aggregate]), source[:aggregate]).any? do |field|
85
+ source[:rows].any? { |parent| reference(record[field]) == parent.id }
86
+ end
87
+ end
88
+ end
89
+ end
90
+ rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
91
+ projected << { aggregate: head[:aggregate], rows: rows }
92
+ rows_by_as[head[:as]] = head[:many] ? rows : rows.first
93
+ end
94
+ # Declared order preserved in the OUTPUT — only the
95
+ # computation above needed reordering, not what a caller sees
96
+ # back.
97
+ heads = model.aggregate_heads.each_with_object({}) { |head, report| report[head[:as]] = rows_by_as[head[:as]] }
98
+ grouped_head = group_by_target(model, bluebook)
99
+ reduced_head = aggregation_target(model, bluebook)
100
+ [heads.each_with_object({}) do |(as, value), out|
101
+ out[as] = if grouped_head && as == grouped_head[:as]
102
+ nest(value.map { |record| Value.materialize_unwrapped(row(record)) }, model.group_by_fields)
103
+ elsif reduced_head && as == reduced_head[:as] && model.count?
104
+ value.length
105
+ elsif reduced_head && as == reduced_head[:as]
106
+ median(value, model.median_field)
107
+ elsif value.is_a?(Array)
108
+ value.map { |record| Value.materialize(row(record)) }
109
+ else
110
+ Value.materialize(row(value))
111
+ end
112
+ end]
113
+ end
114
+
115
+ # `group_by`'s own declared fields, checked against the ONE
116
+ # many-side head they apply to (`seal_group_by` already refuses
117
+ # zero or several) — resolved here, once, rather than re-derived
118
+ # per row. Raises loudly on a typo'd field name rather than
119
+ # silently grouping every row into one bucket keyed `nil`.
120
+ def group_by_target(model, bluebook)
121
+ return nil unless model.group_by.any?
122
+
123
+ target = model.aggregate_heads.find { |head| head[:many] }
124
+ aggregate = bluebook.aggregate(target[:aggregate])
125
+ model.group_by_fields.each do |field|
126
+ next if aggregate.attribute(field)
127
+ # THE LIFECYCLE FIELD IS A FIELD, and refusing it here was a drift
128
+ # between two halves of the same language: `where(status: "logged")`
129
+ # has always been legal on the same aggregate, because a lifecycle
130
+ # state is stored on the record like anything else — it is simply
131
+ # declared by `lifecycle :status` rather than by `attribute`.
132
+ #
133
+ # It is also the grouping anybody actually wants. "How are we doing"
134
+ # over a bug ledger IS the count per status, and a report that could
135
+ # group by every field EXCEPT that one could not answer the question
136
+ # reports exist for.
137
+ next if aggregate.lifecycle && aggregate.lifecycle.field.to_sym == field.to_sym
138
+
139
+ raise ArgumentError,
140
+ "#{model.name}'s group_by names #{field.inspect}, but #{target[:aggregate]} " \
141
+ "declares no such attribute (it declares #{aggregate.attributes.map(&:name).join(', ')})"
142
+ end
143
+ target
144
+ end
145
+
146
+ # One level of nesting per field, in `group_by`'s own declared
147
+ # order — the leaf is the row with every grouped field removed
148
+ # (already spent, as the keys that reached it). ASSUMES the full
149
+ # `group_by` path uniquely identifies one row (true for grouping by
150
+ # an aggregate's own full identity, ConsoleSettings' own real use)
151
+ # — `leaves.first` silently keeps only the first row when several
152
+ # share the same full key path. A real, deliberate scope limit:
153
+ # true multi-row-per-leaf grouping would change the leaf shape
154
+ # from "one row" to "an array of rows", which no caller needs yet.
155
+ def nest(rows, fields)
156
+ field, *rest = fields
157
+ rows.group_by { |row| row[field] }.transform_values do |group|
158
+ # Strip ONLY the field just grouped by, not the whole remaining
159
+ # list — `rest`'s own fields have to survive into the recursive
160
+ # call below, or the NEXT level groups by a key that's already
161
+ # gone (found by trying it: a two-field group_by's own second
162
+ # level came back keyed `nil` for every group, every time).
163
+ stripped = group.map { |row| row.reject { |key, _| key == field } }
164
+ rest.empty? ? stripped.first : nest(stripped, rest)
165
+ end
166
+ end
167
+
168
+ # `count`/`median`'s own declared target — the SAME single
169
+ # many-side head `group_by_target` resolves, for the same reason
170
+ # (`seal_aggregation` already refuses zero or several many-side
171
+ # heads, and refuses count/median declared alongside group_by, so
172
+ # there is exactly one to name whenever this is asked at all).
173
+ # Raises loudly on a `median` field that doesn't exist, or exists
174
+ # but isn't numeric, rather than silently comparing garbage or
175
+ # averaging strings.
176
+ def aggregation_target(model, bluebook)
177
+ return nil unless model.count? || model.median_field
178
+
179
+ target = model.aggregate_heads.find { |head| head[:many] }
180
+ return target unless model.median_field
181
+
182
+ aggregate = bluebook.aggregate(target[:aggregate])
183
+ attribute = aggregate.attribute(model.median_field)
184
+ unless attribute
185
+ raise ArgumentError,
186
+ "#{model.name}'s median names #{model.median_field.inspect}, but #{target[:aggregate]} " \
187
+ "declares no such attribute (it declares #{aggregate.attributes.map(&:name).join(', ')})"
188
+ end
189
+ unless QuerySpecification::FieldPath.numeric?(attribute, []) { |type| aggregate.value_object(type) }
190
+ raise ArgumentError,
191
+ "#{model.name}'s median names #{model.median_field.inspect} on #{target[:aggregate]}, " \
192
+ "which is not numeric — median needs a numeric field (a bare number, or a " \
193
+ "value object carrying one)"
194
+ end
195
+ target
196
+ end
197
+
198
+ # THE STANDARD DEFINITION. An ODD count's median is its one true
199
+ # middle value, sorted ; an EVEN count's median is the AVERAGE of
200
+ # its two middle values — the common convention (as opposed to,
201
+ # say, always taking the lower of the two), and the one this
202
+ # session's own task named explicitly as the deliberate choice.
203
+ # An EMPTY collection has no median: nil, not zero, so a caller
204
+ # cannot mistake "nothing to average" for "the values averaged to
205
+ # zero". Reuses `Ports::Query::InMemory`'s own field reading
206
+ # (`FieldPath.dig` + `comparable`) — the same unwrap `where`/
207
+ # `order_by` already give a value-object-carrying field, so
208
+ # `median :daily_limit` on a `DailyLimit{cents}` field reads the
209
+ # same scalar an `order_by :daily_limit` comparison already would.
210
+ def median(rows, field)
211
+ values = rows.map { |record| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row(record), field)) }
212
+ .compact.sort
213
+ return nil if values.empty?
214
+
215
+ middle = values.length / 2
216
+ values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
217
+ end
218
+
219
+ def fetch(bluebook, domain, aggregate_name, id)
220
+ @registry.read_repository(domain, bluebook.aggregate(aggregate_name)).find(id) ||
221
+ raise(NotFound, RefusalWording.render("NotFound", "read_model_reference_missing",
222
+ aggregate: aggregate_name, offered: Rendering.describe(id)))
223
+ end
224
+
225
+ def records(bluebook, domain, aggregate_name)
226
+ aggregate = bluebook.aggregate(aggregate_name)
227
+ aggregate ? @registry.read_repository(domain, aggregate).all : []
228
+ end
229
+
230
+ # A stored reference holds the target's id inside the REFERENCE
231
+ # ATTRIBUTE's own declared shape, so reading it is reading that shape —
232
+ # a different thing from the identity unwrap that was removed. An
233
+ # IDENTITY is declared as a path and followed (Runtime::Identity) ; a
234
+ # reference has no path of its own, and `Value.scalar` refuses a
235
+ # composite rather than guessing which field was meant.
236
+ #
237
+ # Storing the scalar itself would remove this reading altogether. That
238
+ # is a change to how references are STORED, not to how identities are
239
+ # declared, so it is not made here.
240
+ # An ask names itself where a command would name itself, and says the
241
+ # same thing about the same shape. `Value.refuse_object_reference` is
242
+ # not reused because it speaks of a COMMAND and its attribute ; a read
243
+ # model has a query name and one declared reference.
244
+ def refuse_object_reference(model, args)
245
+ offered = args.fetch(model.reference_name, nil)
246
+ return unless offered.is_a?(Hash) || offered.is_a?(Value)
247
+
248
+ raise TypeMismatch,
249
+ RefusalWording.render("TypeMismatch", "read_model_object_reference",
250
+ query: model.query_name, field: model.reference_name)
251
+ end
252
+
253
+ # A reference is the id, in the argument and in the stored row alike.
254
+ def reference(value) = value.to_s
255
+
256
+ def reference_fields(aggregate, target)
257
+ aggregate.attributes
258
+ .select { |attribute| attribute.reference? && attribute.type.target_name == target.to_s }
259
+ .map(&:name)
260
+ end
261
+
262
+ def matching(records) = records.select { |record| yield(record) }.sort_by(&:id)
263
+ def row(record) = record.to_h
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,74 @@
1
+ module Hecks
2
+ module Runtime
3
+ # THE OUT-OF-BAND HALF OF `projects` (S12, ADR 0025 — "Consistency
4
+ # across aggregate boundaries"). A projected field is never written
5
+ # by the command that reads it — nothing at dispatch time takes a
6
+ # live cross-aggregate read the way `CommandRules::References
7
+ # #dereference` still does — so this is the one place a projected
8
+ # field's value actually gets copied over: walk every record of the
9
+ # OWNING aggregate, resolve each of its own `projected_fields`
10
+ # through the reference it names, and `save` the local copy.
11
+ #
12
+ # EXPLICIT AND CALLABLE, NOT AUTOMATIC — no on-boot detection of a
13
+ # freshly-declared `projects` with no held-era precedent, no
14
+ # generated `Policy#for_each` reaction keeping it live in real
15
+ # time as the target changes. Both are real extensions this same
16
+ # ADR section describes; both are deliberately deferred. This is
17
+ # the mechanism a caller reaches for by hand — `bin/rebuild
18
+ # Domain::Aggregate`, a scheduled job, whatever the deployment
19
+ # needs — proven to work end to end before either automatic
20
+ # trigger is built on top of it.
21
+ #
22
+ # NEEDS NO NEW ADAPTER CAPABILITY. `find`/`all`/`save` are the same
23
+ # three primitives every real adapter already answers identically
24
+ # (`Ports::Persistence::AppendOnly#save` — append, then project,
25
+ # the same for Memory/Postgres/SQLite/D1/Heki) — confirmed by
26
+ # direct reading before this was built, not assumed.
27
+ module RebuildSweep
28
+ module_function
29
+
30
+ # One aggregate's own projected fields, refreshed across every
31
+ # record it holds. Returns how many records actually changed —
32
+ # `save` only runs when a projected value would differ from what
33
+ # is already stored, so re-running a sweep with nothing having
34
+ # moved on the target side touches the append log not at all.
35
+ def call(registry, domain, aggregate)
36
+ return 0 if aggregate.projected_fields.empty?
37
+
38
+ repository = registry.repository(domain, aggregate)
39
+ repository.all.count { |record| refresh(registry, domain, aggregate, record, repository) }
40
+ end
41
+
42
+ def refresh(registry, domain, aggregate, record, repository)
43
+ changed = false
44
+
45
+ aggregate.projected_fields.each do |field|
46
+ value = remote_value(registry, domain, aggregate, record, field)
47
+ next if value.nil?
48
+ next if record.key?(field.name) && record[field.name] == value
49
+
50
+ record[field.name] = value
51
+ changed = true
52
+ end
53
+
54
+ repository.save(record) if changed
55
+ changed
56
+ end
57
+
58
+ # `nil` when the reference itself does not resolve in this
59
+ # chapter (a cross-domain target left "unfollowed" the same way
60
+ # References#dereference already leaves one) or when the record
61
+ # names no target at all — an optional reference nobody set.
62
+ def remote_value(registry, domain, aggregate, record, field)
63
+ target = aggregate.attribute(field.reference)&.type&.resolve
64
+ return nil unless target
65
+
66
+ id = record[field.reference]
67
+ return nil if id.nil?
68
+
69
+ remote = registry.repository(domain, target).find(id.to_s)
70
+ remote&.[](field.remote_field)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,99 @@
1
+ require "delegate"
2
+ require_relative "../query_specification/common/where_clause"
3
+ require_relative "../query_specification/common/options"
4
+ require_relative "../query_specification/hop_path"
5
+ require_relative "../ports/query"
6
+ require_relative "registry"
7
+
8
+ module Hecks
9
+ module Runtime
10
+ # `TenantScope`'s sibling — a hop folds to a synthetic `in`
11
+ # where-clause the exact same way a tenant boundary folds to a
12
+ # synthetic `eq` one, so every engine that already reads `.wheres`
13
+ # answers a hop for free, with no per-engine code and no way for
14
+ # one engine to forget it.
15
+ #
16
+ # A hop's own filtering never happens here — RESOLVING one hop
17
+ # means running one ordinary, adapter-agnostic query against the
18
+ # hop's TARGET aggregate (through the same Ports::Query boundary
19
+ # any other query goes through), and folding the ids it answers
20
+ # back in as a local membership check. A multi-hop chain resolves
21
+ # from the FAR END inward: `fold` only ever peels off the head hop
22
+ # (QuerySpecification::HopPath.next_hop, the one-step primitive),
23
+ # and hands everything still left in the tail to a recursive
24
+ # `apply` call — so hop 2, hop 3, and so on each get resolved by
25
+ # their own ordinary query against their own target, one recursion
26
+ # level at a time, never by this module trying to see the whole
27
+ # chain at once. It terminates because each recursion is over a
28
+ # strictly shorter dotted string, and
29
+ # BluebookBuilder#validate_query_hops! already refused any chain
30
+ # deep enough to matter before this ever runs.
31
+ module ReferenceHop
32
+ module_function
33
+
34
+ def apply(declared, args, registry:, domain:, aggregate:)
35
+ hopped, local = declared.wheres.partition { |clause| QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes) }
36
+ return declared if hopped.empty?
37
+
38
+ folded = hopped.map { |clause| fold(clause, args, registry: registry, domain: domain, aggregate: aggregate) }
39
+ Folded.new(declared, local + folded)
40
+ end
41
+
42
+ def fold(clause, args, registry:, domain:, aggregate:)
43
+ step = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
44
+ hop, rest = step
45
+
46
+ # A guarantee BluebookBuilder#validate_query_hops! already gave
47
+ # this exact clause before the domain ever booted. Held anyway:
48
+ # an unresolvable target folding silently to "matches
49
+ # everything" is precisely the failure shape this whole feature
50
+ # exists to close, and a hop that can no longer resolve (a
51
+ # target aggregate unloaded after boot, say) is a real fact
52
+ # worth a real error, not a query that quietly stops filtering.
53
+ unless hop&.target
54
+ raise WiringError,
55
+ "#{clause.field} hops through a reference this domain cannot resolve " \
56
+ "right now — the chapter seal already checked this once; something " \
57
+ "changed between then and this dispatch"
58
+ end
59
+
60
+ inner = QuerySpecification::Common::WhereClause.new(field: rest, op: clause.op, value: clause.value)
61
+ ids = matching_ids(domain, hop.target, [inner], args, registry: registry)
62
+
63
+ QuerySpecification::Common::WhereClause.new(field: hop.attribute.name, op: "in", value: ids)
64
+ end
65
+
66
+ # Every id the inner clause admits on the hop's TARGET — one
67
+ # whole, ordinary query against the target's own repository,
68
+ # through the very same Ports::Query boundary the outer ask
69
+ # uses, so a hop is answered by whatever engine the target
70
+ # aggregate is actually bound to (which may not be the engine
71
+ # the OUTER aggregate is bound to at all) rather than by a
72
+ # second reading of the comparators.
73
+ def matching_ids(domain, target, wheres, args, registry:)
74
+ spec = apply(QuerySpecification::Common::Options.new(wheres: wheres), args,
75
+ registry: registry, domain: domain, aggregate: target)
76
+ repository = registry.repository(domain, target)
77
+ rows = Ports::Query.execute(repository, spec, args, context: { domain: domain, aggregate: target }) ||
78
+ Ports::Query::InMemory.execute(repository.all, spec, args)
79
+
80
+ rows.map { |row| row.id.to_s }.uniq
81
+ end
82
+
83
+ # Never returned to a caller that might call an IR-level method
84
+ # (`to_h`, …) whose OWN internal `wheres` read would resolve
85
+ # against the original object, not this override — the exact
86
+ # caution TenantScope::Scoped's own comment gives, for the exact
87
+ # same reason: SimpleDelegator only intercepts calls made
88
+ # directly on the wrapper.
89
+ class Folded < SimpleDelegator
90
+ def initialize(declared, wheres)
91
+ super(declared)
92
+ @wheres = wheres
93
+ end
94
+
95
+ def wheres = @wheres
96
+ end
97
+ end
98
+ end
99
+ end