hecks 3.14.0 → 5.0.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 (427) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/adapters/fake_processor.rb +34 -0
  3. data/examples/pizzas/adapters/payment_signature.rb +66 -0
  4. data/examples/pizzas/adapters/payment_webhook.rb +87 -0
  5. data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
  6. data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
  7. data/examples/pizzas/config.ru +22 -0
  8. data/exe/hecks +1 -1
  9. data/lib/hecks/adapters/driven/prism.rb +0 -2
  10. data/lib/hecks/adapters/driven.rb +0 -2
  11. data/lib/hecks/adapters/driving/json.rb +9 -16
  12. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
  13. data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
  14. data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
  15. data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
  16. data/lib/hecks/bluebook/hecksagon.rb +1 -1
  17. data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
  18. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
  19. data/lib/hecks/bluebook/meta_validator.rb +0 -11
  20. data/lib/hecks/bluebook/translation.rb +18 -10
  21. data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
  22. data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
  23. data/lib/hecks/bluebook/translation_expression.rb +39 -0
  24. data/lib/hecks/bluebook/translation_plan.rb +70 -0
  25. data/lib/hecks/bluebook.rb +2 -0
  26. data/lib/hecks/chapters.rb +3 -3
  27. data/lib/hecks/cli/project_cli/launcher_source.rb +1 -1
  28. data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
  29. data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
  30. data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
  31. data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
  32. data/lib/hecks/composer/adapters/jev.adapter +3 -0
  33. data/lib/hecks/composer/adapters/jev.rb +66 -0
  34. data/lib/hecks/composer/agent.rb +113 -0
  35. data/lib/hecks/composer/assistant.rb +110 -0
  36. data/lib/hecks/composer/behavior_run.rb +53 -0
  37. data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
  38. data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
  39. data/lib/hecks/composer/board.rb +65 -0
  40. data/lib/hecks/composer/board_edit/command_text.rb +39 -0
  41. data/lib/hecks/composer/board_edit/source.rb +84 -0
  42. data/lib/hecks/composer/board_edit.rb +144 -0
  43. data/lib/hecks/composer/check.rb +89 -0
  44. data/lib/hecks/composer/domains.rb +81 -0
  45. data/lib/hecks/composer/jobs.rb +134 -0
  46. data/lib/hecks/composer/layout.rb +37 -0
  47. data/lib/hecks/composer/live.rb +150 -0
  48. data/lib/hecks/composer/outline.rb +48 -0
  49. data/lib/hecks/composer/page.html +989 -0
  50. data/lib/hecks/composer/panels.rb +142 -0
  51. data/lib/hecks/composer/parse.rb +83 -0
  52. data/lib/hecks/composer/plain.rb +28 -0
  53. data/lib/hecks/composer/ports/composer_agent.port +7 -0
  54. data/lib/hecks/composer/session.rb +141 -0
  55. data/lib/hecks/composer/toolbox.rb +166 -0
  56. data/lib/hecks/composer/web.rb +156 -0
  57. data/lib/hecks/composer/workbench.rb +51 -0
  58. data/lib/hecks/composer/workspace.rb +81 -0
  59. data/lib/hecks/composer.rb +17 -0
  60. data/lib/hecks/corpus/adapters.rb +22 -0
  61. data/lib/hecks/corpus/rust.rb +1 -1
  62. data/lib/hecks/corpus/worlds.rb +19 -0
  63. data/lib/hecks/corpus.rb +5 -0
  64. data/lib/hecks/forms/app/commands.rb +2 -3
  65. data/lib/hecks/forms/app/queries.rb +2 -3
  66. data/lib/hecks/forms/app/records.rb +17 -5
  67. data/lib/hecks/forms/app/responses.rb +3 -1
  68. data/lib/hecks/forms/command_form_renderer.rb +42 -16
  69. data/lib/hecks/forms/field_controls.rb +2 -2
  70. data/lib/hecks/forms/field_renderer.rb +1 -1
  71. data/lib/hecks/forms/field_shape.rb +36 -17
  72. data/lib/hecks/forms/index_renderer.rb +2 -1
  73. data/lib/hecks/forms/page.rb +1 -1
  74. data/lib/hecks/forms/params.rb +0 -13
  75. data/lib/hecks/forms/query_form_renderer.rb +7 -6
  76. data/lib/hecks/forms/record_renderer.rb +11 -7
  77. data/lib/hecks/forms/rule_words.rb +100 -0
  78. data/lib/hecks/forms/value_object_shape.rb +2 -1
  79. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
  80. data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
  81. data/lib/hecks/fuzzing/replay/filters.rb +2 -6
  82. data/lib/hecks/gate/stages.yml +25 -0
  83. data/lib/hecks/grammar/evolve/seeds.rb +0 -6
  84. data/lib/hecks/grammar/evolve.rb +0 -3
  85. data/lib/hecks/grammar/translation.bluebook +1 -1
  86. data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
  87. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  88. data/lib/hecks/hecks/adapters/first_admin.rb +3 -3
  89. data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
  90. data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
  91. data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
  92. data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
  93. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
  94. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
  95. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
  96. data/lib/hecks/hecks/codebase.behaviors +6 -0
  97. data/lib/hecks/hecks/codebase.bluebook +21 -0
  98. data/lib/hecks/hecks/composer.behaviors +68 -0
  99. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  100. data/lib/hecks/hecks/custodian.behaviors +0 -6
  101. data/lib/hecks/hecks/custodian.bluebook +3 -80
  102. data/lib/hecks/hecks/hecks.hecksagon +2 -2
  103. data/lib/hecks/hecks/hecks.world +1 -1
  104. data/lib/hecks/hecks/rehearsal.bluebook +7 -13
  105. data/lib/hecks/kernel_capabilities/templates.rb +4 -4
  106. data/lib/hecks/kernel_capabilities.rb +1 -1
  107. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
  108. data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
  109. data/lib/hecks/ports/access_control.rb +2 -2
  110. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
  111. data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
  112. data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
  113. data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
  114. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
  115. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
  116. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
  117. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
  118. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
  119. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
  120. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
  121. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
  122. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
  123. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
  124. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
  125. data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
  126. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
  127. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
  128. data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
  129. data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
  130. data/lib/hecks/ports.rb +0 -1
  131. data/lib/hecks/projection_files.rb +3 -1
  132. data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
  133. data/lib/hecks/projections/glossary/entries.rb +10 -4
  134. data/lib/hecks/projections/glossary/groups.rb +42 -0
  135. data/lib/hecks/projections/glossary/html/page.rb +7 -2
  136. data/lib/hecks/projections/glossary/html/parser.rb +1 -1
  137. data/lib/hecks/projections/glossary/html/sections.rb +31 -11
  138. data/lib/hecks/projections/glossary/html.rb +1 -2
  139. data/lib/hecks/projections/glossary/markdown.rb +31 -28
  140. data/lib/hecks/projections/glossary/mermaid.rb +3 -0
  141. data/lib/hecks/projections/glossary/page.css +143 -153
  142. data/lib/hecks/projections/glossary/page.js +63 -3
  143. data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
  144. data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
  145. data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
  146. data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
  147. data/lib/hecks/projections/glossary/sentences.rb +13 -3
  148. data/lib/hecks/projections/glossary/slugs.rb +4 -1
  149. data/lib/hecks/projections/glossary.rb +2 -1
  150. data/lib/hecks/projections/plain_english.rb +97 -0
  151. data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
  152. data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
  153. data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
  154. data/lib/hecks/projections/rust_vocabulary.rb +14 -6
  155. data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
  156. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +7 -2
  157. data/lib/hecks/projections/statements/actions.rb +99 -0
  158. data/lib/hecks/projections/statements/relationships.rb +86 -0
  159. data/lib/hecks/projections/statements.rb +15 -24
  160. data/lib/hecks/projector/exporter/adapters.rb +112 -0
  161. data/lib/hecks/projector/exporter/translations.rb +18 -35
  162. data/lib/hecks/projector/exporter/worlds.rb +103 -0
  163. data/lib/hecks/projector/exporter.rb +5 -1
  164. data/lib/hecks/quality_control/adapters/agent.rb +6 -7
  165. data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
  166. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
  167. data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
  168. data/lib/hecks/runtime/outbox.rb +2 -9
  169. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
  170. data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
  171. data/lib/hecks/runtime/value/unwrapping.rb +60 -0
  172. data/lib/hecks/runtime/value.rb +10 -21
  173. data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
  174. data/lib/hecks/rust_build/wasm.rb +0 -12
  175. data/lib/hecks/rust_build/write_if_changed.rb +12 -12
  176. data/lib/hecks/rust_format.rb +77 -0
  177. data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
  178. data/lib/hecks/tools/fuzz_sweep.rb +1 -3
  179. data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
  180. data/lib/hecks/tools/translation_audit.rb +8 -10
  181. data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
  182. data/lib/hecks/version.rb +1 -1
  183. data/lib/hecks/vocabulary.rb +2 -1
  184. data/lib/hecks.rb +3 -0
  185. data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal} +0 -0
  186. data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal} +0 -0
  187. data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json} +1 -1
  188. data/rust/Cargo.toml +40 -0
  189. data/rust/build/Cargo.toml +36 -0
  190. data/rust/build/clippy.toml +4 -0
  191. data/rust/build/src/adapters_pass.rs +196 -0
  192. data/rust/build/src/binding_pass.rs +54 -13
  193. data/rust/build/src/build_artifact.rs +23 -5
  194. data/rust/build/src/cargo_sync.rs +69 -26
  195. data/rust/build/src/lineage_pass.rs +101 -87
  196. data/rust/build/src/main.rs +11 -2
  197. data/rust/build/src/pipeline.rs +80 -19
  198. data/rust/build/src/reserved_names.rs +6 -50
  199. data/rust/build/src/resolve.rs +82 -35
  200. data/rust/build/src/rustfmt.rs +100 -0
  201. data/rust/build/src/seams_pass.rs +85 -16
  202. data/rust/build/src/subprocess.rs +30 -6
  203. data/rust/build/src/tmp.rs +5 -2
  204. data/rust/build/src/translation_pass.rs +72 -22
  205. data/rust/build/src/world_pass.rs +109 -0
  206. data/rust/clippy.toml +4 -0
  207. data/rust/codegen/Cargo.toml +36 -0
  208. data/rust/codegen/clippy.toml +4 -0
  209. data/rust/codegen/src/asks.rs +40 -14
  210. data/rust/codegen/src/attr.rs +2 -2
  211. data/rust/codegen/src/bridging.rs +676 -117
  212. data/rust/codegen/src/commands.rs +979 -373
  213. data/rust/codegen/src/constraints.rs +88 -25
  214. data/rust/codegen/src/dependency_planning.rs +128 -34
  215. data/rust/codegen/src/domain_generator.rs +306 -170
  216. data/rust/codegen/src/exemplar.rs +87 -32
  217. data/rust/codegen/src/expr_emitter.rs +54 -6
  218. data/rust/codegen/src/fielded.rs +193 -41
  219. data/rust/codegen/src/hecks_naming.rs +124 -16
  220. data/rust/codegen/src/hecks_naming_tests.rs +81 -0
  221. data/rust/codegen/src/json.rs +80 -44
  222. data/rust/codegen/src/json_codec.rs +585 -149
  223. data/rust/codegen/src/literal.rs +104 -138
  224. data/rust/codegen/src/main.rs +151 -52
  225. data/rust/codegen/src/manifest.rs +76 -14
  226. data/rust/codegen/src/mutations.rs +1212 -239
  227. data/rust/codegen/src/naming.rs +111 -60
  228. data/rust/codegen/src/optional_pass.rs +90 -28
  229. data/rust/codegen/src/ports.rs +5 -4
  230. data/rust/codegen/src/prelude.rs +139 -49
  231. data/rust/codegen/src/presenters.rs +335 -0
  232. data/rust/codegen/src/queries.rs +601 -226
  233. data/rust/codegen/src/reactions.rs +268 -74
  234. data/rust/codegen/src/read_models.rs +574 -229
  235. data/rust/codegen/src/reference_specs.rs +16 -3
  236. data/rust/codegen/src/registry.rs +128 -79
  237. data/rust/codegen/src/reserved_names.rs +6 -50
  238. data/rust/codegen/src/shared.rs +71 -22
  239. data/rust/codegen/src/sidecars.rs +15 -5
  240. data/rust/codegen/src/skip_reason.rs +8 -2
  241. data/rust/codegen/src/types.rs +306 -108
  242. data/rust/host/Cargo.lock +1 -0
  243. data/rust/host/Cargo.toml +40 -4
  244. data/rust/host/HECKS_RELEASE +1 -1
  245. data/rust/host/clippy.toml +4 -0
  246. data/rust/host/src/api.rs +698 -218
  247. data/rust/host/src/auth.rs +491 -214
  248. data/rust/host/src/b64.rs +91 -0
  249. data/rust/host/src/bin/expr_harness.rs +6 -2
  250. data/rust/host/src/bin/lineage_harness.rs +47 -11
  251. data/rust/host/src/bin/mint_harness.rs +70 -37
  252. data/rust/host/src/bin/validate_harness.rs +16 -8
  253. data/rust/host/src/boot.rs +144 -68
  254. data/rust/host/src/boundary_fuzz/api.rs +130 -32
  255. data/rust/host/src/boundary_fuzz/auth.rs +108 -23
  256. data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
  257. data/rust/host/src/boundary_fuzz/ir.rs +164 -12
  258. data/rust/host/src/boundary_fuzz/server.rs +283 -36
  259. data/rust/host/src/boundary_fuzz/web.rs +56 -8
  260. data/rust/host/src/checkout.rs +78 -32
  261. data/rust/host/src/clock.rs +8 -0
  262. data/rust/host/src/commerce_ir.rs +333 -95
  263. data/rust/host/src/crypto.rs +33 -0
  264. data/rust/host/src/dispatch.rs +1037 -331
  265. data/rust/host/src/expr_json.rs +1021 -273
  266. data/rust/host/src/extension.rs +24 -4
  267. data/rust/host/src/field_hints.rs +11 -7
  268. data/rust/host/src/fuzz_support.rs +85 -13
  269. data/rust/host/src/host.rs +100 -0
  270. data/rust/host/src/http.rs +22 -0
  271. data/rust/host/src/ir.rs +326 -16
  272. data/rust/host/src/journal.rs +277 -176
  273. data/rust/host/src/lambda_client.rs +219 -53
  274. data/rust/host/src/lib.rs +16 -4
  275. data/rust/host/src/log.rs +62 -11
  276. data/rust/host/src/mint.rs +1153 -326
  277. data/rust/host/src/needs.rs +185 -44
  278. data/rust/host/src/payments/keystore.rs +197 -45
  279. data/rust/host/src/payments/roles.rs +39 -7
  280. data/rust/host/src/payments/tests.rs +1427 -282
  281. data/rust/host/src/payments.rs +391 -112
  282. data/rust/host/src/platform_signin/attack_tests.rs +5 -5
  283. data/rust/host/src/platform_signin/config.rs +11 -11
  284. data/rust/host/src/platform_signin/cookie.rs +2 -2
  285. data/rust/host/src/platform_signin/extension.rs +2 -1
  286. data/rust/host/src/platform_signin/handler.rs +9 -8
  287. data/rust/host/src/platform_signin/jti.rs +4 -2
  288. data/rust/host/src/platform_signin/jwks.rs +2 -1
  289. data/rust/host/src/platform_signin/jws.rs +2 -37
  290. data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
  291. data/rust/host/src/platform_signin/mode_tests.rs +5 -3
  292. data/rust/host/src/platform_signin/pages.rs +20 -20
  293. data/rust/host/src/platform_signin/session.rs +8 -1
  294. data/rust/host/src/platform_signin/settings.rs +6 -6
  295. data/rust/host/src/platform_signin/strict_json.rs +12 -9
  296. data/rust/host/src/platform_signin/verify.rs +41 -41
  297. data/rust/host/src/platform_signin/wiring_support.rs +13 -18
  298. data/rust/host/src/presentation.rs +207 -63
  299. data/rust/host/src/presentation_write.rs +502 -187
  300. data/rust/host/src/query_step.rs +69 -18
  301. data/rust/host/src/rate_limit.rs +541 -142
  302. data/rust/host/src/reference_transform.rs +77 -25
  303. data/rust/host/src/reference_validate.rs +443 -104
  304. data/rust/host/src/resend.rs +190 -55
  305. data/rust/host/src/secrets.rs +116 -21
  306. data/rust/host/src/server.rs +555 -104
  307. data/rust/host/src/storage_shape.rs +88 -21
  308. data/rust/host/src/test_pg.rs +17 -7
  309. data/rust/host/src/ui_schema.rs +495 -207
  310. data/rust/host/src/wasm_runner.rs +21 -6
  311. data/rust/host/src/web/commerce.rs +101 -23
  312. data/rust/host/src/web/newsletter/cooldown.rs +10 -3
  313. data/rust/host/src/web/newsletter/template.rs +26 -7
  314. data/rust/host/src/web/newsletter/tests.rs +112 -23
  315. data/rust/host/src/web/newsletter.rs +209 -59
  316. data/rust/host/src/web/newsletter_send.rs +251 -79
  317. data/rust/host/src/web/registration_receipt/tests.rs +16 -4
  318. data/rust/host/src/web/registration_receipt.rs +100 -24
  319. data/rust/host/src/web/registrations.rs +1384 -332
  320. data/rust/host/src/web/resend_webhook.rs +138 -80
  321. data/rust/host/src/web.rs +2416 -508
  322. data/rust/lsp/Cargo.toml +36 -0
  323. data/rust/lsp/clippy.toml +4 -0
  324. data/rust/lsp/src/json.rs +29 -22
  325. data/rust/lsp/src/main.rs +81 -31
  326. data/rust/lsp/src/outline.rs +23 -7
  327. data/rust/lsp/src/rpc.rs +17 -4
  328. data/rust/parser/Cargo.toml +36 -0
  329. data/rust/parser/clippy.toml +4 -0
  330. data/rust/parser/src/build/closed_sets.rs +6 -1
  331. data/rust/parser/src/build/identity.rs +4 -3
  332. data/rust/parser/src/build/mod.rs +1 -1
  333. data/rust/parser/src/build/named_pattern.rs +4 -1
  334. data/rust/parser/src/build/naming.rs +2 -80
  335. data/rust/parser/src/build/pattern_subset.rs +6 -8
  336. data/rust/parser/src/build/query_derive.rs +3 -1
  337. data/rust/parser/src/build/query_inference.rs +14 -5
  338. data/rust/parser/src/build/read_model.rs +4 -1
  339. data/rust/parser/src/build/references.rs +4 -3
  340. data/rust/parser/src/canonical.rs +34 -15
  341. data/rust/parser/src/emit.rs +187 -105
  342. data/rust/parser/src/expr/ast_json.rs +134 -42
  343. data/rust/parser/src/expr/evaluator.rs +27 -9
  344. data/rust/parser/src/expr/mod.rs +54 -13
  345. data/rust/parser/src/expr/resolver.rs +299 -92
  346. data/rust/parser/src/ir.rs +35 -3
  347. data/rust/parser/src/keywords.rs +7168 -516
  348. data/rust/parser/src/lex.rs +11 -25
  349. data/rust/parser/src/lib.rs +1 -0
  350. data/rust/parser/src/main.rs +65 -6
  351. data/rust/parser/src/parse/adapter.rs +157 -0
  352. data/rust/parser/src/parse/aggregate.rs +19 -10
  353. data/rust/parser/src/parse/chapter.rs +16 -17
  354. data/rust/parser/src/parse/command.rs +35 -16
  355. data/rust/parser/src/parse/domain_port.rs +4 -7
  356. data/rust/parser/src/parse/entity.rs +21 -11
  357. data/rust/parser/src/parse/hecksagon.rs +10 -4
  358. data/rust/parser/src/parse/lifecycle.rs +9 -5
  359. data/rust/parser/src/parse/mod.rs +22 -91
  360. data/rust/parser/src/parse/needs.rs +21 -12
  361. data/rust/parser/src/parse/policy.rs +2 -2
  362. data/rust/parser/src/parse/process_manager.rs +9 -11
  363. data/rust/parser/src/parse/query.rs +8 -4
  364. data/rust/parser/src/parse/read_model.rs +57 -16
  365. data/rust/parser/src/parse/value_object.rs +7 -2
  366. data/rust/parser/src/parse/world.rs +322 -0
  367. data/rust/parser/src/ruby_value.rs +7 -6
  368. data/rust/src/exemplar/json.rs +4 -4
  369. data/rust/src/exemplar/mutations.rs +18 -0
  370. data/rust/src/exemplar/read_models.rs +1 -10
  371. data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
  372. data/rust/src/kernel/attribute_shapes/list.rs +2 -2
  373. data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
  374. data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
  375. data/rust/src/kernel/cli.rs +244 -114
  376. data/rust/src/kernel/dispatch.rs +323 -115
  377. data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
  378. data/rust/src/kernel/expr.rs +203 -86
  379. data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
  380. data/rust/src/kernel/expression_operators/comparison.rs +87 -2
  381. data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
  382. data/rust/src/kernel/expression_operators/logical.rs +9 -3
  383. data/rust/src/kernel/expression_operators/membership.rs +3 -1
  384. data/rust/src/kernel/expression_operators/mod.rs +34 -21
  385. data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
  386. data/rust/src/kernel/expression_operators/positional.rs +84 -30
  387. data/rust/src/kernel/expression_operators/presence.rs +52 -13
  388. data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
  389. data/rust/src/kernel/expression_operators/sized.rs +24 -8
  390. data/rust/src/kernel/expression_operators/text.rs +120 -27
  391. data/rust/src/kernel/expression_operators/to_string.rs +10 -4
  392. data/rust/src/kernel/json.rs +244 -148
  393. data/rust/src/kernel/mod.rs +47 -33
  394. data/rust/src/kernel/named_query.rs +63 -15
  395. data/rust/src/kernel/needs.rs +112 -25
  396. data/rust/src/kernel/orchestrate.rs +980 -581
  397. data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
  398. data/rust/src/kernel/pattern.rs +107 -34
  399. data/rust/src/kernel/present.rs +261 -0
  400. data/rust/src/kernel/query_comparators.rs +118 -41
  401. data/rust/src/kernel/query_ordering.rs +13 -5
  402. data/rust/src/kernel/read_model.rs +479 -181
  403. data/rust/src/kernel/reference_lookup.rs +122 -26
  404. data/rust/src/kernel/repository.rs +293 -66
  405. data/rust/src/kernel/routing.rs +58 -42
  406. data/rust/src/kernel/ruby_float.rs +139 -0
  407. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
  408. data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
  409. data/rust/src/kernel/vocab/field_hint.rs +19 -25
  410. data/rust/src/kernel/vocab/mod.rs +6 -6
  411. data/rust/src/kernel/vocab/query_comparator.rs +23 -24
  412. data/rust/src/kernel/vocab/refusal_template.rs +501 -297
  413. data/rust/src/lib.rs +50 -0
  414. data/rust/src/main.rs +4 -1
  415. data/rust/web/Cargo.toml +36 -0
  416. data/rust/web/clippy.toml +4 -0
  417. metadata +85 -15
  418. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
  419. data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
  420. data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
  421. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
  422. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
  423. data/lib/hecks/ports/authentication.port +0 -6
  424. data/lib/hecks/ports/authentication.rb +0 -67
  425. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
  426. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
  427. data/rust/host/src/approval.rs +0 -652
@@ -1,8 +1,14 @@
1
1
  //! Interprets a compiled `report` block (`ReadModelDef`) against a domain's generated
2
2
  //! `READ_MODELS` table — the hand-written counterpart to the retired Ruby generator's `read_models.rb`.
3
3
 
4
- use super::refusal_wording::{InvariantViolationGroupByCollisionArgs, NotFoundReadModelReferenceMissingArgs, UnauthorizedTenantRequiredArgs};
5
- use super::{named_query, query_comparators, query_ordering, repository, AggregateScan, Json, QueryCondition, QueryConditionValue, Refusal};
4
+ use super::refusal_wording::{
5
+ InvariantViolationGroupByCollisionArgs, NotFoundReadModelReferenceMissingArgs,
6
+ UnauthorizedTenantRequiredArgs,
7
+ };
8
+ use super::{
9
+ named_query, query_comparators, query_ordering, repository, AggregateScan, Json,
10
+ QueryCondition, QueryConditionValue, Refusal,
11
+ };
6
12
 
7
13
  /// A `where` clause that crosses one or more references on the eligible head
8
14
  /// (`account/status`, or a longer `member/sponsor/standing` chain via `through`).
@@ -56,6 +62,9 @@ pub type ReadModelLimit = query_ordering::Limit;
56
62
  /// A read model's declared `offset N`, applying to `ReadModelDef::filtered_head` alone.
57
63
  pub type ReadModelOffset = query_ordering::Offset;
58
64
 
65
+ /// A per-read-model generated function that groups the rows of the one `many`-side head.
66
+ pub type GroupByFn = fn(Vec<(String, Json)>) -> Result<Json, Refusal>;
67
+
59
68
  /// One many-side head's own where/order_by/offset/limit. ADR 0055's `on:` lets a read model
60
69
  /// with several many-side heads filter/order/page each independently; a read model with exactly
61
70
  /// one many-side head and untargeted options still compiles to a single entry here, unchanged
@@ -99,28 +108,48 @@ pub struct ReadModelDef {
99
108
  /// A per-read-model generated function rather than data here, because unwrapping a
100
109
  /// single-attribute value object needs codegen-time type knowledge `run`'s generic body,
101
110
  /// holding already-serialized `Json`, doesn't have.
102
- pub group_by: Option<fn(Vec<(String, Json)>) -> Result<Json, Refusal>>,
103
- /// `count` on the one `many`-side head — a bare marker; row-count needs no per-read-model
104
- /// generated function, unlike `group_by`.
105
- pub count: bool,
106
- /// `median :field` — the declared numeric field's median across the one `many`-side head.
107
- pub median_field: Option<&'static str>,
108
- /// `sum :field` — the declared Integer field's total (ADR 0078).
109
- pub sum_field: Option<&'static str>,
110
- /// `avg :field` — the declared Integer field's mean (ADR 0078).
111
- pub avg_field: Option<&'static str>,
112
- /// `min :field` — the declared numeric field's smallest value (ADR 0078).
113
- pub min_field: Option<&'static str>,
114
- /// `max :field` — the declared numeric field's largest value (ADR 0078).
115
- pub max_field: Option<&'static str>,
116
- /// `percentile :field, at:` — the declared numeric field's value at one interpolated
117
- /// rank; `median` is the fixed `at: 0.5` case (ADR 0078).
118
- pub percentile_field: Option<&'static str>,
119
- pub percentile_at: Option<f64>,
120
- /// `any :field` — whether any row's declared boolean field is `true` (ADR 0078).
121
- pub any_field: Option<&'static str>,
122
- /// `all :field` — whether every row's declared boolean field is `true` (ADR 0078).
123
- pub all_field: Option<&'static str>,
111
+ pub group_by: Option<GroupByFn>,
112
+ /// What the one `many`-side head is reduced to, in place of its rows (ADR 0078).
113
+ pub reduction: Option<Reduction>,
114
+ }
115
+
116
+ /// One reduction a read model declares over its `many`-side head's rows.
117
+ #[derive(Debug, Clone, Copy)]
118
+ pub enum Reduction {
119
+ /// `count` — a bare marker; row-count needs no per-read-model generated function.
120
+ Count,
121
+ /// `median :field` — the declared numeric field's median.
122
+ Median(&'static str),
123
+ /// `sum :field` — the declared Integer field's total.
124
+ Sum(&'static str),
125
+ /// `avg :field` — the declared Integer field's mean.
126
+ Avg(&'static str),
127
+ /// `min :field` — the declared numeric field's smallest value.
128
+ Min(&'static str),
129
+ /// `max :field` — the declared numeric field's largest value.
130
+ Max(&'static str),
131
+ /// `percentile :field, at:` — the declared numeric field's value at one interpolated rank;
132
+ /// `median` is the fixed `at: 0.5` case.
133
+ Percentile { field: &'static str, at: f64 },
134
+ /// `any :field` — whether any row's declared boolean field is `true`.
135
+ Any(&'static str),
136
+ /// `all :field` — whether every row's declared boolean field is `true`.
137
+ All(&'static str),
138
+ }
139
+
140
+ /// The head's rows reduced as declared.
141
+ fn reduce(rows: &[(String, Json)], reduction: Reduction) -> Json {
142
+ match reduction {
143
+ Reduction::Count => Json::Num(rows.len() as f64, None),
144
+ Reduction::Median(field) => median(rows, field),
145
+ Reduction::Sum(field) => sum(rows, field),
146
+ Reduction::Avg(field) => avg(rows, field),
147
+ Reduction::Min(field) => extreme(rows, field, false),
148
+ Reduction::Max(field) => extreme(rows, field, true),
149
+ Reduction::Percentile { field, at } => percentile(rows, field, at),
150
+ Reduction::Any(field) => boolean_reduce(rows, field, false),
151
+ Reduction::All(field) => boolean_reduce(rows, field, true),
152
+ }
124
153
  }
125
154
 
126
155
  /// Whether a `group_by` leaf is checked for a second row — decided once, by the generator, from
@@ -145,14 +174,29 @@ pub fn nest(rows: Vec<Json>, fields: &[&str], check: LeafCheck) -> Result<Json,
145
174
  /// One level of `nest`. `all_fields` is the whole declared `group_by`
146
175
  /// (the refusal names it); `reached` is the key path walked so far, one
147
176
  /// `(field, key)` per level above this one.
148
- fn nest_level(rows: Vec<Json>, all_fields: &[&str], fields: &[&str], check: LeafCheck, reached: &[(&str, Json)]) -> Result<Json, Refusal> {
149
- let (field, rest) = fields.split_first().expect("nest called with empty fields — read_model_skip_reason should refuse a group_by with none");
177
+ fn nest_level(
178
+ rows: Vec<Json>,
179
+ all_fields: &[&str],
180
+ fields: &[&str],
181
+ check: LeafCheck,
182
+ reached: &[(&str, Json)],
183
+ ) -> Result<Json, Refusal> {
184
+ let Some((field, rest)) = fields.split_first() else {
185
+ return Err(Refusal::Fault(
186
+ "nest called with empty fields — read_model_skip_reason should refuse a group_by with none"
187
+ .to_string(),
188
+ ));
189
+ };
150
190
  let mut groups: Vec<(Json, Vec<Json>)> = Vec::new();
151
191
  for row in rows {
152
192
  let (key, stripped) = match row {
153
193
  Json::Object(pairs) => {
154
- let key = pairs.iter().find(|(k, _)| k == field).map(|(_, v)| v.clone()).unwrap_or(Json::Null);
155
- let stripped = Json::Object(pairs.into_iter().filter(|(k, _)| k != field).collect());
194
+ let key = pairs
195
+ .iter()
196
+ .find(|(k, _)| k == field)
197
+ .map_or(Json::Null, |(_, v)| v.clone());
198
+ let stripped =
199
+ Json::Object(pairs.into_iter().filter(|(k, _)| k != field).collect());
156
200
  (key, stripped)
157
201
  }
158
202
  other => (Json::Null, other),
@@ -166,7 +210,11 @@ fn nest_level(rows: Vec<Json>, all_fields: &[&str], fields: &[&str], check: Leaf
166
210
  for (key, group) in groups {
167
211
  let mut path = reached.to_vec();
168
212
  path.push((field, key.clone()));
169
- let value = if rest.is_empty() { leaf(group, all_fields, check, &path)? } else { nest_level(group, all_fields, rest, check, &path)? };
213
+ let value = if rest.is_empty() {
214
+ leaf(group, all_fields, check, &path)?
215
+ } else {
216
+ nest_level(group, all_fields, rest, check, &path)?
217
+ };
170
218
  // A non-String key must stringify the same way Ruby's `Hash#[]=` does when used as a
171
219
  // JSON object key.
172
220
  out.push((super::query_comparators::to_s(&key), value));
@@ -176,16 +224,38 @@ fn nest_level(rows: Vec<Json>, all_fields: &[&str], fields: &[&str], check: Leaf
176
224
 
177
225
  /// The one row a `group_by` key path reached, or the `group_by_collision` refusal when a
178
226
  /// checked path was reached by more than one.
179
- fn leaf(group: Vec<Json>, all_fields: &[&str], check: LeafCheck, path: &[(&str, Json)]) -> Result<Json, Refusal> {
227
+ fn leaf(
228
+ group: Vec<Json>,
229
+ all_fields: &[&str],
230
+ check: LeafCheck,
231
+ path: &[(&str, Json)],
232
+ ) -> Result<Json, Refusal> {
180
233
  let read_model = match check {
181
234
  LeafCheck::RefuseCollision(read_model) if group.len() > 1 => read_model,
182
235
  _ => return Ok(group.into_iter().next().unwrap_or(Json::Null)),
183
236
  };
184
- let ids: Vec<String> = group.iter().map(|row| row.get("id").map(super::query_comparators::to_s).unwrap_or_default()).collect();
237
+ let ids: Vec<String> = group
238
+ .iter()
239
+ .map(|row| {
240
+ row.get("id")
241
+ .map(super::query_comparators::to_s)
242
+ .unwrap_or_default()
243
+ })
244
+ .collect();
185
245
  let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
186
- let key = path.iter().map(|(field, value)| format!("{field} = {}", super::query_comparators::to_s(value))).collect::<Vec<_>>().join(", ");
246
+ let key = path
247
+ .iter()
248
+ .map(|(field, value)| format!("{field} = {}", super::query_comparators::to_s(value)))
249
+ .collect::<Vec<_>>()
250
+ .join(", ");
187
251
  Err(Refusal::InvariantViolation(
188
- InvariantViolationGroupByCollisionArgs { read_model, fields: all_fields, ids: &id_refs, key: &key }.render_args(),
252
+ InvariantViolationGroupByCollisionArgs {
253
+ read_model,
254
+ fields: all_fields,
255
+ ids: &id_refs,
256
+ key: &key,
257
+ }
258
+ .render_args(),
189
259
  ))
190
260
  }
191
261
 
@@ -205,7 +275,11 @@ fn percentile(rows: &[(String, Json)], field: &'static str, at: f64) -> Json {
205
275
  if values.is_empty() {
206
276
  return Json::Null;
207
277
  }
208
- values.sort_by(|a, b| query_comparators::as_f64(a).partial_cmp(&query_comparators::as_f64(b)).unwrap_or(std::cmp::Ordering::Equal));
278
+ values.sort_by(|a, b| {
279
+ query_comparators::as_f64(a)
280
+ .partial_cmp(&query_comparators::as_f64(b))
281
+ .unwrap_or(std::cmp::Ordering::Equal)
282
+ });
209
283
 
210
284
  let position = at * (values.len() - 1) as f64;
211
285
  let lower = position.floor() as usize;
@@ -266,11 +340,18 @@ fn extreme(rows: &[(String, Json)], field: &'static str, want_max: bool) -> Json
266
340
  .map(query_comparators::comparable)
267
341
  .filter(|value| !matches!(value, Json::Null))
268
342
  .collect();
269
- if values.is_empty() {
270
- return Json::Null;
271
- }
272
- values.sort_by(|a, b| query_comparators::as_f64(a).partial_cmp(&query_comparators::as_f64(b)).unwrap_or(std::cmp::Ordering::Equal));
273
- if want_max { values.pop().expect("checked non-empty above") } else { values.remove(0) }
343
+ values.sort_by(|a, b| {
344
+ query_comparators::as_f64(a)
345
+ .partial_cmp(&query_comparators::as_f64(b))
346
+ .unwrap_or(std::cmp::Ordering::Equal)
347
+ });
348
+ let mut sorted = values.into_iter();
349
+ let picked = if want_max {
350
+ sorted.next_back()
351
+ } else {
352
+ sorted.next()
353
+ };
354
+ picked.unwrap_or(Json::Null)
274
355
  }
275
356
 
276
357
  /// Whether any (`want_all: false`) or every (`true`) row's declared boolean field is `true`
@@ -280,10 +361,14 @@ fn boolean_reduce(rows: &[(String, Json)], field: &'static str, want_all: bool)
280
361
  let values: Vec<bool> = rows
281
362
  .iter()
282
363
  .filter_map(|(_, row)| row.get(field))
283
- .map(|value| query_comparators::comparable(value))
364
+ .map(query_comparators::comparable)
284
365
  .filter_map(|value| value.as_bool())
285
366
  .collect();
286
- Json::Bool(if want_all { values.iter().all(|v| *v) } else { values.iter().any(|v| *v) })
367
+ Json::Bool(if want_all {
368
+ values.iter().all(|v| *v)
369
+ } else {
370
+ values.iter().any(|v| *v)
371
+ })
287
372
  }
288
373
 
289
374
  /// The lookup a read-model ask (a bare "Domain.Name" string, no "::") dispatches through — a
@@ -291,21 +376,29 @@ fn boolean_reduce(rows: &[(String, Json)], field: &'static str, want_all: bool)
291
376
  /// spelling or its snake_case alias (`matches_snake_alias`, below), since a real corpus caller
292
377
  /// uses the snake_case form for this read model's own query step.
293
378
  pub fn find<'a>(table: &'a [ReadModelDef], verb: &str) -> Option<&'a ReadModelDef> {
294
- table.iter().find(|def| def.verb == verb || matches_snake_alias(def.verb, verb))
379
+ table
380
+ .iter()
381
+ .find(|def| def.verb == verb || matches_snake_alias(def.verb, verb))
295
382
  }
296
383
 
297
384
  /// True when `declared_verb` and `asked` name the same domain and `asked`'s name half equals
298
385
  /// `declared_verb`'s name half run through `to_snake`.
299
386
  fn matches_snake_alias(declared_verb: &str, asked: &str) -> bool {
300
- let Some((domain, name)) = declared_verb.split_once('.') else { return false };
301
- let Some((asked_domain, asked_name)) = asked.split_once('.') else { return false };
387
+ let Some((domain, name)) = declared_verb.split_once('.') else {
388
+ return false;
389
+ };
390
+ let Some((asked_domain, asked_name)) = asked.split_once('.') else {
391
+ return false;
392
+ };
302
393
 
303
394
  domain == asked_domain && to_snake(name) == asked_name
304
395
  }
305
396
 
306
397
  /// A port of `Hecks::Naming.snake` (lib/hecks/naming.rb). No regex engine in this
307
398
  /// dependency-free kernel, so the two `gsub` passes collapse into one left-to-right scan of
308
- /// adjacent-character pairs; every declared read-model/query name is plain ASCII.
399
+ /// adjacent-character pairs. The kernel stays self-contained on purpose: it is compiled into every
400
+ /// domain's wasm module from a workspace that holds only this crate, so it cannot include a file
401
+ /// from `rust/codegen` the way the host, parser and build crates share `hecks_naming.rs`.
309
402
  fn to_snake(name: &str) -> String {
310
403
  let chars: Vec<char> = name.chars().collect();
311
404
  let mut out = String::with_capacity(name.len() + 4);
@@ -314,9 +407,12 @@ fn to_snake(name: &str) -> String {
314
407
  if i > 0 {
315
408
  let prev = chars[i - 1];
316
409
  // "HTTPServer" splits before the "S" of "Server", not before the "H" of "HTTP".
317
- let rule1 = prev.is_ascii_uppercase() && c.is_ascii_uppercase() && chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
410
+ let rule1 = prev.is_ascii_uppercase()
411
+ && c.is_ascii_uppercase()
412
+ && chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
318
413
  // "customerPortfolio" splits before "P".
319
- let rule2 = (prev.is_ascii_lowercase() || prev.is_ascii_digit()) && c.is_ascii_uppercase();
414
+ let rule2 =
415
+ (prev.is_ascii_lowercase() || prev.is_ascii_digit()) && c.is_ascii_uppercase();
320
416
  if rule1 || rule2 {
321
417
  out.push('_');
322
418
  }
@@ -324,8 +420,9 @@ fn to_snake(name: &str) -> String {
324
420
  out.push(c);
325
421
  }
326
422
 
327
- out.make_ascii_lowercase();
328
- out
423
+ // Ruby's `downcase` is Unicode, one character at a time (a word-final capital sigma stays a
424
+ // plain sigma); only the word boundaries above are ASCII-only, as in Ruby's patterns.
425
+ out.chars().flat_map(char::to_lowercase).collect()
329
426
  }
330
427
 
331
428
  #[cfg(test)]
@@ -341,6 +438,9 @@ mod snake_alias_tests {
341
438
  assert_eq!(to_snake("ByFee"), "by_fee");
342
439
  assert_eq!(to_snake("HTTPServer"), "http_server");
343
440
  assert_eq!(to_snake("Type2Foo"), "type2_foo");
441
+ // Ruby lowercases every Unicode capital, though only ASCII ones start a word boundary.
442
+ assert_eq!(to_snake("ÉcoleDeParis"), "école_de_paris");
443
+ assert_eq!(to_snake("ΟΔΥΣΣΕΑΣ"), "οδυσσεασ");
344
444
  }
345
445
 
346
446
  #[test]
@@ -352,16 +452,7 @@ mod snake_alias_tests {
352
452
  filtered_heads: &[],
353
453
  authorization: None,
354
454
  group_by: None,
355
- count: false,
356
- median_field: None,
357
- sum_field: None,
358
- avg_field: None,
359
- min_field: None,
360
- max_field: None,
361
- percentile_field: None,
362
- percentile_at: None,
363
- any_field: None,
364
- all_field: None,
455
+ reduction: None,
365
456
  }];
366
457
 
367
458
  assert!(find(&table, "Banking.CustomerPortfolio").is_some());
@@ -384,7 +475,8 @@ mod nest_tests {
384
475
  fn refuses_two_rows_on_one_checked_key_path() {
385
476
  let rows = vec![part("p2", "b1"), part("p3", "b2"), part("p1", "b1")];
386
477
 
387
- let err = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin")).expect_err("p1 and p2 share bin b1");
478
+ let err = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin"))
479
+ .expect_err("p1 and p2 share bin b1");
388
480
 
389
481
  assert_eq!(
390
482
  err,
@@ -399,16 +491,34 @@ mod nest_tests {
399
491
  fn answers_when_every_key_path_holds_one_row() {
400
492
  let rows = vec![part("p1", "b1"), part("p3", "b2")];
401
493
 
402
- let nested = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin")).expect("no two parts share a bin");
494
+ let nested = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin"))
495
+ .expect("no two parts share a bin");
403
496
 
404
- assert_eq!(nested, Json::obj(vec![("b1", Json::obj(vec![("id", Json::str("p1"))])), ("b2", Json::obj(vec![("id", Json::str("p3"))]))]));
497
+ assert_eq!(
498
+ nested,
499
+ Json::obj(vec![
500
+ ("b1", Json::obj(vec![("id", Json::str("p1"))])),
501
+ ("b2", Json::obj(vec![("id", Json::str("p3"))]))
502
+ ])
503
+ );
405
504
  }
406
505
 
407
506
  #[test]
408
507
  fn names_every_level_of_a_multi_field_key_path() {
409
- let row = |id: &str| Json::obj(vec![("bin", Json::str("b1")), ("shelf", Json::Num(2.0, None)), ("id", Json::str(id))]);
508
+ let row = |id: &str| {
509
+ Json::obj(vec![
510
+ ("bin", Json::str("b1")),
511
+ ("shelf", Json::Num(2.0, None)),
512
+ ("id", Json::str(id)),
513
+ ])
514
+ };
410
515
 
411
- let err = nest(vec![row("p1"), row("p2")], &["bin", "shelf"], LeafCheck::RefuseCollision("PartsByShelf")).expect_err("same bin and shelf");
516
+ let err = nest(
517
+ vec![row("p1"), row("p2")],
518
+ &["bin", "shelf"],
519
+ LeafCheck::RefuseCollision("PartsByShelf"),
520
+ )
521
+ .expect_err("same bin and shelf");
412
522
 
413
523
  assert_eq!(
414
524
  err,
@@ -438,12 +548,18 @@ mod reference_hop_tests {
438
548
  impl AggregateScan for FakeStore {
439
549
  fn scan(&self, aggregate: &str) -> Option<Vec<(String, Json)>> {
440
550
  let bare = aggregate.strip_prefix(&format!("{}::", self.domain))?;
441
- self.aggregates.iter().find(|(name, _)| *name == bare).map(|(_, entries)| entries.clone())
551
+ self.aggregates
552
+ .iter()
553
+ .find(|(name, _)| *name == bare)
554
+ .map(|(_, entries)| entries.clone())
442
555
  }
443
556
  }
444
557
 
445
558
  fn account(status: &str, customer: &str) -> Json {
446
- Json::obj(vec![("status", Json::str(status)), ("customer", Json::str(customer))])
559
+ Json::obj(vec![
560
+ ("status", Json::str(status)),
561
+ ("customer", Json::str(customer)),
562
+ ])
447
563
  }
448
564
 
449
565
  fn customer(status: &str) -> Json {
@@ -457,9 +573,18 @@ mod reference_hop_tests {
457
573
  (
458
574
  "Account",
459
575
  vec![
460
- ("acc-suspended-open".to_string(), account("open", "cust-suspended")),
461
- ("acc-active-open".to_string(), account("open", "cust-active")),
462
- ("acc-suspended-closed".to_string(), account("closed", "cust-suspended")),
576
+ (
577
+ "acc-suspended-open".to_string(),
578
+ account("open", "cust-suspended"),
579
+ ),
580
+ (
581
+ "acc-active-open".to_string(),
582
+ account("open", "cust-active"),
583
+ ),
584
+ (
585
+ "acc-suspended-closed".to_string(),
586
+ account("closed", "cust-suspended"),
587
+ ),
463
588
  ],
464
589
  ),
465
590
  (
@@ -479,10 +604,20 @@ mod reference_hop_tests {
479
604
  ReadModelDef {
480
605
  verb: "Banking.OpenForSuspendedCustomers",
481
606
  reference_name: None,
482
- heads: &[ReadModelHead { aggregate: "Banking::Account", as_name: "accounts", many: true, is_root: false, reference_fields: &[] }],
607
+ heads: &[ReadModelHead {
608
+ aggregate: "Banking::Account",
609
+ as_name: "accounts",
610
+ many: true,
611
+ is_root: false,
612
+ reference_fields: &[],
613
+ }],
483
614
  filtered_heads: &[FilteredHead {
484
615
  as_name: "accounts",
485
- conditions: &[QueryCondition { field: "status", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("open") }],
616
+ conditions: &[QueryCondition {
617
+ field: "status",
618
+ comparator: query_comparators::QueryComparator::Eq,
619
+ value: QueryConditionValue::Literal("open"),
620
+ }],
486
621
  reference_hop_conditions: &[ReferenceHopCondition {
487
622
  via_field: "customer",
488
623
  target_aggregate: "Banking::Customer",
@@ -497,31 +632,37 @@ mod reference_hop_tests {
497
632
  }],
498
633
  authorization: None,
499
634
  group_by: None,
500
- count: false,
501
- median_field: None,
502
- sum_field: None,
503
- avg_field: None,
504
- min_field: None,
505
- max_field: None,
506
- percentile_field: None,
507
- percentile_at: None,
508
- any_field: None,
509
- all_field: None,
635
+ reduction: None,
510
636
  }
511
637
  }
512
638
 
513
639
  #[test]
514
640
  fn a_hop_condition_keeps_only_rows_whose_local_and_hopped_conditions_both_hold() {
515
641
  let def = open_for_suspended_customers_def();
516
- let result = run(&store(), &def, &Json::obj(vec![])).expect("a rootless read model with no authorization needs no args");
517
-
518
- let accounts = result.get("accounts").expect("the one declared head").as_array().expect("a many head answers an array");
519
- let ids: Vec<&str> = accounts.iter().map(|row| row.get("id").and_then(Json::as_str).expect("row_json always stamps id")).collect();
642
+ let result = run(&store(), &def, &Json::obj(vec![]))
643
+ .expect("a rootless read model with no authorization needs no args");
644
+
645
+ let accounts = result
646
+ .get("accounts")
647
+ .expect("the one declared head")
648
+ .as_array()
649
+ .expect("a many head answers an array");
650
+ let ids: Vec<&str> = accounts
651
+ .iter()
652
+ .map(|row| {
653
+ row.get("id")
654
+ .and_then(Json::as_str)
655
+ .expect("row_json always stamps id")
656
+ })
657
+ .collect();
520
658
 
521
659
  assert_eq!(ids, vec!["acc-suspended-open"]);
522
660
  // Named explicitly so a regression that widens either condition fails on the specific
523
661
  // wrong row it would admit, not just a count.
524
- assert!(!ids.contains(&"acc-active-open"), "open but NOT suspended — the hop condition alone should have excluded this");
662
+ assert!(
663
+ !ids.contains(&"acc-active-open"),
664
+ "open but NOT suspended — the hop condition alone should have excluded this"
665
+ );
525
666
  assert!(!ids.contains(&"acc-suspended-closed"), "suspended customer but NOT open — the ordinary local condition alone should have excluded this");
526
667
  }
527
668
 
@@ -530,7 +671,11 @@ mod reference_hop_tests {
530
671
  let mut def = open_for_suspended_customers_def();
531
672
  def.filtered_heads = &[FilteredHead {
532
673
  as_name: "accounts",
533
- conditions: &[QueryCondition { field: "status", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("open") }],
674
+ conditions: &[QueryCondition {
675
+ field: "status",
676
+ comparator: query_comparators::QueryComparator::Eq,
677
+ value: QueryConditionValue::Literal("open"),
678
+ }],
534
679
  reference_hop_conditions: &[ReferenceHopCondition {
535
680
  via_field: "customer",
536
681
  target_aggregate: "Banking::NoSuchAggregate",
@@ -555,14 +700,39 @@ mod reference_hop_tests {
555
700
  let mut def = open_for_suspended_customers_def();
556
701
  def.filtered_heads = &[];
557
702
  def.heads = &[
558
- ReadModelHead { aggregate: "Banking::Account", as_name: "accounts", many: true, is_root: false, reference_fields: &[] },
559
- ReadModelHead { aggregate: "Banking::LedgerEntry", as_name: "ledger_entries", many: true, is_root: false, reference_fields: &[] },
703
+ ReadModelHead {
704
+ aggregate: "Banking::Account",
705
+ as_name: "accounts",
706
+ many: true,
707
+ is_root: false,
708
+ reference_fields: &[],
709
+ },
710
+ ReadModelHead {
711
+ aggregate: "Banking::LedgerEntry",
712
+ as_name: "ledger_entries",
713
+ many: true,
714
+ is_root: false,
715
+ reference_fields: &[],
716
+ },
560
717
  ];
561
718
 
562
- let result = run(&store(), &def, &Json::obj(vec![])).expect("an entity head must not refuse the read model");
719
+ let result = run(&store(), &def, &Json::obj(vec![]))
720
+ .expect("an entity head must not refuse the read model");
563
721
 
564
- assert_eq!(result.get("accounts").and_then(|v| v.as_array()).map(|rows| rows.len()), Some(3));
565
- assert_eq!(result.get("ledger_entries").and_then(|v| v.as_array()).map(|rows| rows.len()), Some(0));
722
+ assert_eq!(
723
+ result
724
+ .get("accounts")
725
+ .and_then(|v| v.as_array())
726
+ .map(<[Json]>::len),
727
+ Some(3)
728
+ );
729
+ assert_eq!(
730
+ result
731
+ .get("ledger_entries")
732
+ .and_then(|v| v.as_array())
733
+ .map(<[Json]>::len),
734
+ Some(0)
735
+ );
566
736
  }
567
737
 
568
738
  // A declared query folds its hop the same way a read model head does.
@@ -571,7 +741,11 @@ mod reference_hop_tests {
571
741
  let def = crate::kernel::named_query::QueryDef {
572
742
  verb: "Banking::Account.OpenForSuspendedCustomers",
573
743
  aggregate: "Banking::Account",
574
- conditions: &[QueryCondition { field: "status", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("open") }],
744
+ conditions: &[QueryCondition {
745
+ field: "status",
746
+ comparator: query_comparators::QueryComparator::Eq,
747
+ value: QueryConditionValue::Literal("open"),
748
+ }],
575
749
  reference_hop_conditions: &[ReferenceHopCondition {
576
750
  via_field: "customer",
577
751
  target_aggregate: "Banking::Customer",
@@ -586,7 +760,8 @@ mod reference_hop_tests {
586
760
  authorization: None,
587
761
  };
588
762
 
589
- let rows = crate::kernel::named_query::run(&store(), &def, &Json::obj(vec![]), None).expect("no authorization, no args needed");
763
+ let rows = crate::kernel::named_query::run(&store(), &def, &Json::obj(vec![]), None)
764
+ .expect("no authorization, no args needed");
590
765
  let ids: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
591
766
 
592
767
  assert_eq!(ids, vec!["acc-suspended-open"]);
@@ -596,15 +771,36 @@ mod reference_hop_tests {
596
771
  #[test]
597
772
  fn an_entity_query_flattens_every_owners_list_ordered_by_parent_then_identity() {
598
773
  fn withdrawal(sequence: f64, state: &str) -> Json {
599
- Json::obj(vec![("sequence", Json::obj(vec![("value", Json::Num(sequence, None))])), ("state", Json::str(state))])
774
+ Json::obj(vec![
775
+ (
776
+ "sequence",
777
+ Json::obj(vec![("value", Json::Num(sequence, None))]),
778
+ ),
779
+ ("state", Json::str(state)),
780
+ ])
600
781
  }
601
782
  let cards = FakeStore {
602
783
  domain: "Banking",
603
784
  aggregates: vec![(
604
785
  "ATMCard",
605
786
  vec![
606
- ("card-2".to_string(), Json::obj(vec![("withdrawals", Json::Array(vec![withdrawal(2.0, "taken"), withdrawal(1.0, "taken")]))])),
607
- ("card-1".to_string(), Json::obj(vec![("withdrawals", Json::Array(vec![withdrawal(1.0, "disputed"), withdrawal(3.0, "taken")]))])),
787
+ (
788
+ "card-2".to_string(),
789
+ Json::obj(vec![(
790
+ "withdrawals",
791
+ Json::Array(vec![withdrawal(2.0, "taken"), withdrawal(1.0, "taken")]),
792
+ )]),
793
+ ),
794
+ (
795
+ "card-1".to_string(),
796
+ Json::obj(vec![(
797
+ "withdrawals",
798
+ Json::Array(vec![
799
+ withdrawal(1.0, "disputed"),
800
+ withdrawal(3.0, "taken"),
801
+ ]),
802
+ )]),
803
+ ),
608
804
  ],
609
805
  )],
610
806
  };
@@ -614,26 +810,42 @@ mod reference_hop_tests {
614
810
  list_field: "withdrawals",
615
811
  parent_key: "atm_card",
616
812
  identity_keys: &["sequence"],
617
- conditions: &[QueryCondition { field: "state", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("taken") }],
813
+ conditions: &[QueryCondition {
814
+ field: "state",
815
+ comparator: query_comparators::QueryComparator::Eq,
816
+ value: QueryConditionValue::Literal("taken"),
817
+ }],
618
818
  order_by: None,
619
819
  offset: None,
620
820
  limit: Some(query_ordering::Limit::Literal(2)),
621
821
  };
622
822
 
623
- let rows = crate::kernel::named_query::run_entity(&cards, &def, &Json::obj(vec![])).expect("no args needed");
823
+ let rows = crate::kernel::named_query::run_entity(&cards, &def, &Json::obj(vec![]))
824
+ .expect("no args needed");
624
825
  let keyed: Vec<(String, String)> = rows
625
826
  .iter()
626
827
  .map(|row| {
627
- let parent = row.get("atm_card").and_then(Json::as_str).expect("parent key leads every row").to_string();
828
+ let parent = row
829
+ .get("atm_card")
830
+ .and_then(Json::as_str)
831
+ .expect("parent key leads every row")
832
+ .to_string();
628
833
  let sequence = format!("{:?}", row.dig("sequence.value"));
629
834
  (parent, sequence)
630
835
  })
631
836
  .collect();
632
837
 
633
- assert_eq!(keyed.len(), 2, "limit 2 over three taken withdrawals: {keyed:?}");
838
+ assert_eq!(
839
+ keyed.len(),
840
+ 2,
841
+ "limit 2 over three taken withdrawals: {keyed:?}"
842
+ );
634
843
  assert_eq!(keyed[0].0, "card-1");
635
844
  assert_eq!(keyed[1].0, "card-2");
636
- assert!(keyed[1].1.contains('1'), "card-2's lowest sequence comes first: {keyed:?}");
845
+ assert!(
846
+ keyed[1].1.contains('1'),
847
+ "card-2's lowest sequence comes first: {keyed:?}"
848
+ );
637
849
  }
638
850
 
639
851
  // A chain: the inner clause picks sponsors, the middle step keeps members sponsored by one,
@@ -646,22 +858,40 @@ mod reference_hop_tests {
646
858
  (
647
859
  "Sponsor",
648
860
  vec![
649
- ("s-good".to_string(), Json::obj(vec![("standing", Json::str("good"))])),
650
- ("s-poor".to_string(), Json::obj(vec![("standing", Json::str("poor"))])),
861
+ (
862
+ "s-good".to_string(),
863
+ Json::obj(vec![("standing", Json::str("good"))]),
864
+ ),
865
+ (
866
+ "s-poor".to_string(),
867
+ Json::obj(vec![("standing", Json::str("poor"))]),
868
+ ),
651
869
  ],
652
870
  ),
653
871
  (
654
872
  "Member",
655
873
  vec![
656
- ("m-good".to_string(), Json::obj(vec![("sponsor", Json::str("s-good"))])),
657
- ("m-poor".to_string(), Json::obj(vec![("sponsor", Json::str("s-poor"))])),
874
+ (
875
+ "m-good".to_string(),
876
+ Json::obj(vec![("sponsor", Json::str("s-good"))]),
877
+ ),
878
+ (
879
+ "m-poor".to_string(),
880
+ Json::obj(vec![("sponsor", Json::str("s-poor"))]),
881
+ ),
658
882
  ],
659
883
  ),
660
884
  (
661
885
  "Referral",
662
886
  vec![
663
- ("r-good".to_string(), Json::obj(vec![("member", Json::str("m-good"))])),
664
- ("r-poor".to_string(), Json::obj(vec![("member", Json::str("m-poor"))])),
887
+ (
888
+ "r-good".to_string(),
889
+ Json::obj(vec![("member", Json::str("m-good"))]),
890
+ ),
891
+ (
892
+ "r-poor".to_string(),
893
+ Json::obj(vec![("member", Json::str("m-poor"))]),
894
+ ),
665
895
  ],
666
896
  ),
667
897
  ],
@@ -673,7 +903,10 @@ mod reference_hop_tests {
673
903
  reference_hop_conditions: &[ReferenceHopCondition {
674
904
  via_field: "member",
675
905
  target_aggregate: "ReferralChain::Member",
676
- through: &[HopStep { via_field: "sponsor", target_aggregate: "ReferralChain::Sponsor" }],
906
+ through: &[HopStep {
907
+ via_field: "sponsor",
908
+ target_aggregate: "ReferralChain::Sponsor",
909
+ }],
677
910
  inner_field: "standing",
678
911
  inner_comparator: query_comparators::QueryComparator::Eq,
679
912
  inner_value: QueryConditionValue::Literal("good"),
@@ -684,13 +917,17 @@ mod reference_hop_tests {
684
917
  authorization: None,
685
918
  };
686
919
 
687
- let rows = crate::kernel::named_query::run(&chain_store, &def, &Json::obj(vec![]), None).expect("no authorization, no args needed");
920
+ let rows = crate::kernel::named_query::run(&chain_store, &def, &Json::obj(vec![]), None)
921
+ .expect("no authorization, no args needed");
688
922
  let ids: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
689
923
 
690
924
  assert_eq!(ids, vec!["r-good"]);
691
925
  }
692
926
  }
693
927
 
928
+ /// One head's records by id, with whether it is a `many` head.
929
+ type HeadRows = (bool, Vec<(String, Json)>);
930
+
694
931
  /// The generic read-model interpreter — the single projected row, one entry per declared
695
932
  /// head's `as_name`, for exactly the subset this module admits.
696
933
  ///
@@ -702,7 +939,12 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
702
939
  Some(name) => Some(
703
940
  args.get(name)
704
941
  .and_then(Json::as_str)
705
- .ok_or_else(|| Refusal::TypeMismatch(format!("{}: missing reference argument {name:?}", def.verb)))?
942
+ .ok_or_else(|| {
943
+ Refusal::TypeMismatch(format!(
944
+ "{}: missing reference argument {name:?}",
945
+ def.verb
946
+ ))
947
+ })?
706
948
  .to_string(),
707
949
  ),
708
950
  // Rootless: no caller-supplied id to require or resolve at all.
@@ -713,33 +955,42 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
713
955
  if let Some(auth) = &def.authorization {
714
956
  if args.get(auth.tenant_field).is_none() {
715
957
  return Err(Refusal::Unauthorized(
716
- UnauthorizedTenantRequiredArgs { query: auth.query_name, field: auth.tenant_field }.render_args(),
958
+ UnauthorizedTenantRequiredArgs {
959
+ query: auth.query_name,
960
+ field: auth.tenant_field,
961
+ }
962
+ .render_args(),
717
963
  ));
718
964
  }
719
965
  }
720
966
 
721
- let (root_heads, other_heads): (Vec<&ReadModelHead>, Vec<&ReadModelHead>) = def.heads.iter().partition(|head| head.is_root);
967
+ let (root_heads, other_heads): (Vec<&ReadModelHead>, Vec<&ReadModelHead>) =
968
+ def.heads.iter().partition(|head| head.is_root);
722
969
 
723
970
  // One entry per head already computed, in computation order (root-first): every candidate
724
971
  // record is checked against every entry seen so far, not just the immediately-preceding one.
725
972
  let mut projected: Vec<(&'static str, Vec<(String, Json)>)> = Vec::new();
726
- let mut rows_by_as: std::collections::HashMap<&'static str, (bool, Vec<(String, Json)>)> = std::collections::HashMap::new();
727
- let mut grouped_heads: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
973
+ let mut rows_by_as: std::collections::HashMap<&'static str, HeadRows> =
974
+ std::collections::HashMap::new();
975
+ let mut grouped_heads: std::collections::HashSet<&'static str> =
976
+ std::collections::HashSet::new();
728
977
 
729
978
  for head in root_heads.into_iter().chain(other_heads) {
730
- let mut rows = if reference_id.is_none() {
979
+ let mut rows = match reference_id.as_deref() {
731
980
  // Rootless: each head reads its own aggregate's whole table independently, with no
732
981
  // cross-referencing against `projected`. A head with no table reads as empty.
733
- store.scan(head.aggregate).unwrap_or_default()
734
- } else if head.is_root {
735
- vec![fetch_root(store, head, reference_id.as_deref().unwrap())?]
736
- } else {
737
- scan_matching(store, head, &projected)?
982
+ None => store.scan(head.aggregate).unwrap_or_default(),
983
+ Some(id) if head.is_root => vec![fetch_root(store, head, id)?],
984
+ Some(_) => scan_matching(store, head, &projected),
738
985
  };
739
986
 
740
987
  // Applied before this head's rows go into `projected`, so any later head's own
741
988
  // reference-matching sees the filtered rows, not the pre-filter scan.
742
- if let Some(filtered) = def.filtered_heads.iter().find(|fh| fh.as_name == head.as_name) {
989
+ if let Some(filtered) = def
990
+ .filtered_heads
991
+ .iter()
992
+ .find(|fh| fh.as_name == head.as_name)
993
+ {
743
994
  rows = apply_filtered_head_options(rows, filtered, args, store)?;
744
995
  }
745
996
 
@@ -759,39 +1010,33 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
759
1010
  // `id` the way a live Ruby record's `to_h` does.
760
1011
  let mut out = Vec::with_capacity(def.heads.len());
761
1012
  for head in def.heads {
762
- let (many, rows) = rows_by_as
763
- .remove(head.as_name)
764
- .expect("every head in def.heads was computed in the loop above — as_name is unique per read model (ReadModelBuilder#add_aggregate_head)");
1013
+ let (many, rows) = rows_by_as.remove(head.as_name).ok_or_else(|| {
1014
+ Refusal::Fault(
1015
+ "every head in def.heads was computed in the loop above — as_name is unique per read model (ReadModelBuilder#add_aggregate_head)"
1016
+ .to_string(),
1017
+ )
1018
+ })?;
765
1019
  let value = if grouped_heads.contains(head.as_name) {
766
1020
  // Already fully formed by the generated transform — must not be wrapped again the
767
1021
  // way an ordinary head's rows are.
768
- (def.group_by.expect("grouped_heads is only ever populated when def.group_by is Some"))(rows)?
769
- } else if many && def.count {
770
- Json::Num(rows.len() as f64, None)
771
- } else if many && def.median_field.is_some() {
772
- median(&rows, def.median_field.expect("checked by the branch guard"))
773
- } else if many && def.sum_field.is_some() {
774
- sum(&rows, def.sum_field.expect("checked by the branch guard"))
775
- } else if many && def.avg_field.is_some() {
776
- avg(&rows, def.avg_field.expect("checked by the branch guard"))
777
- } else if many && def.min_field.is_some() {
778
- extreme(&rows, def.min_field.expect("checked by the branch guard"), false)
779
- } else if many && def.max_field.is_some() {
780
- extreme(&rows, def.max_field.expect("checked by the branch guard"), true)
781
- } else if many && def.percentile_field.is_some() {
782
- percentile(
783
- &rows,
784
- def.percentile_field.expect("checked by the branch guard"),
785
- def.percentile_at.expect("percentile_field implies percentile_at"),
786
- )
787
- } else if many && def.any_field.is_some() {
788
- boolean_reduce(&rows, def.any_field.expect("checked by the branch guard"), false)
789
- } else if many && def.all_field.is_some() {
790
- boolean_reduce(&rows, def.all_field.expect("checked by the branch guard"), true)
1022
+ let group_by = def.group_by.ok_or_else(|| {
1023
+ Refusal::Fault(
1024
+ "grouped_heads is only ever populated when def.group_by is Some".to_string(),
1025
+ )
1026
+ })?;
1027
+ group_by(rows)?
1028
+ } else if let Some(reduction) = def.reduction.filter(|_| many) {
1029
+ reduce(&rows, reduction)
791
1030
  } else if many {
792
- Json::Array(rows.into_iter().map(|(id, record)| repository::row_json(id, record)).collect())
1031
+ Json::Array(
1032
+ rows.into_iter()
1033
+ .map(|(id, record)| repository::row_json(id, record))
1034
+ .collect(),
1035
+ )
793
1036
  } else {
794
- rows.into_iter().next().map(|(id, record)| repository::row_json(id, record)).unwrap_or(Json::Null)
1037
+ rows.into_iter()
1038
+ .next()
1039
+ .map_or(Json::Null, |(id, record)| repository::row_json(id, record))
795
1040
  };
796
1041
  out.push((head.as_name.to_string(), value));
797
1042
  }
@@ -801,22 +1046,36 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
801
1046
 
802
1047
  /// The root head's own single row: find the one instance by id, `NotFound` if it isn't there.
803
1048
  /// Unlike every other head, the root is looked up directly rather than scanned-and-matched.
804
- fn fetch_root(store: &impl AggregateScan, head: &ReadModelHead, reference_id: &str) -> Result<(String, Json), Refusal> {
805
- let entries = store
806
- .scan(head.aggregate)
807
- .ok_or_else(|| Refusal::TypeMismatch(format!("unknown aggregate {:?}", head.aggregate)))?;
1049
+ fn fetch_root(
1050
+ store: &impl AggregateScan,
1051
+ head: &ReadModelHead,
1052
+ reference_id: &str,
1053
+ ) -> Result<(String, Json), Refusal> {
1054
+ // Borrowed rows: only the one that matches is cloned.
1055
+ let mut found = None;
1056
+ let known = store.scan_each(head.aggregate, &mut |id, record| {
1057
+ if found.is_none() && id == reference_id {
1058
+ found = Some((id.to_string(), record.clone()));
1059
+ }
1060
+ });
1061
+ if !known {
1062
+ return Err(Refusal::TypeMismatch(format!(
1063
+ "unknown aggregate {:?}",
1064
+ head.aggregate
1065
+ )));
1066
+ }
808
1067
 
809
- entries
810
- .into_iter()
811
- .find(|(id, _)| id == reference_id)
812
- .ok_or_else(|| {
813
- // Named by its bare bluebook name; `head.aggregate` is the domain-qualified one.
814
- let bare = head.aggregate.rsplit("::").next().unwrap_or(head.aggregate);
815
- Refusal::NotFound(
816
- NotFoundReadModelReferenceMissingArgs { aggregate: bare, offered: &format!("{reference_id:?}") }
817
- .render_args(),
818
- )
819
- })
1068
+ found.ok_or_else(|| {
1069
+ // Named by its bare bluebook name; `head.aggregate` is the domain-qualified one.
1070
+ let bare = head.aggregate.rsplit("::").next().unwrap_or(head.aggregate);
1071
+ Refusal::NotFound(
1072
+ NotFoundReadModelReferenceMissingArgs {
1073
+ aggregate: bare,
1074
+ offered: &format!("{reference_id:?}"),
1075
+ }
1076
+ .render_args(),
1077
+ )
1078
+ })
820
1079
  }
821
1080
 
822
1081
  /// A non-root head's own rows: every instance of this head's aggregate whose reference field(s)
@@ -825,26 +1084,36 @@ fn scan_matching(
825
1084
  store: &impl AggregateScan,
826
1085
  head: &ReadModelHead,
827
1086
  projected: &[(&'static str, Vec<(String, Json)>)],
828
- ) -> Result<Vec<(String, Json)>, Refusal> {
1087
+ ) -> Vec<(String, Json)> {
829
1088
  // The generator emits a head with no table only when it names a nested entity; that reads
830
1089
  // as empty here, while the root still refuses in `fetch_root`.
831
1090
  let Some(entries) = store.scan(head.aggregate) else {
832
- return Ok(Vec::new());
1091
+ return Vec::new();
833
1092
  };
834
1093
 
835
- let mut matched: Vec<(String, Json)> = entries.into_iter().filter(|(_, record)| record_matches(record, head, projected)).collect();
1094
+ let mut matched: Vec<(String, Json)> = entries
1095
+ .into_iter()
1096
+ .filter(|(_, record)| record_matches(record, head, projected))
1097
+ .collect();
836
1098
  matched.sort_by(|a, b| a.0.cmp(&b.0));
837
- Ok(matched)
1099
+ matched
838
1100
  }
839
1101
 
840
1102
  /// Whether `record` holds one of its precomputed `reference_fields` pointing at a row already
841
1103
  /// in `projected`.
842
- fn record_matches(record: &Json, head: &ReadModelHead, projected: &[(&'static str, Vec<(String, Json)>)]) -> bool {
1104
+ fn record_matches(
1105
+ record: &Json,
1106
+ head: &ReadModelHead,
1107
+ projected: &[(&'static str, Vec<(String, Json)>)],
1108
+ ) -> bool {
843
1109
  head.reference_fields.iter().any(|reference_field| {
844
- let Some(held_id) = record.get(reference_field.field).and_then(Json::as_str) else { return false };
845
- projected
846
- .iter()
847
- .any(|(aggregate, rows)| *aggregate == reference_field.target_aggregate && rows.iter().any(|(id, _)| id == held_id))
1110
+ let Some(held_id) = record.get(reference_field.field).and_then(Json::as_str) else {
1111
+ return false;
1112
+ };
1113
+ projected.iter().any(|(aggregate, rows)| {
1114
+ *aggregate == reference_field.target_aggregate
1115
+ && rows.iter().any(|(id, _)| id == held_id)
1116
+ })
848
1117
  })
849
1118
  }
850
1119
 
@@ -869,7 +1138,13 @@ fn apply_filtered_head_options(
869
1138
 
870
1139
  rows = apply_reference_hops(rows, filtered.reference_hop_conditions, args, store)?;
871
1140
 
872
- Ok(query_ordering::apply(rows, filtered.order_by.as_ref(), filtered.offset.as_ref(), filtered.limit.as_ref(), args))
1141
+ Ok(query_ordering::apply(
1142
+ rows,
1143
+ filtered.order_by.as_ref(),
1144
+ filtered.offset.as_ref(),
1145
+ filtered.limit.as_ref(),
1146
+ args,
1147
+ ))
873
1148
  }
874
1149
 
875
1150
  /// Folds a `ReferenceHopCondition` chain: one query against the chain's final target picks
@@ -888,27 +1163,50 @@ pub(super) fn apply_reference_hops(
888
1163
  QueryConditionValue::Arg(name) => args.get(name).cloned().unwrap_or(Json::Null),
889
1164
  };
890
1165
  let scan = |aggregate: &str| {
891
- store.scan(aggregate).ok_or_else(|| Refusal::TypeMismatch(format!("unknown aggregate {aggregate:?}")))
1166
+ store
1167
+ .scan(aggregate)
1168
+ .ok_or_else(|| Refusal::TypeMismatch(format!("unknown aggregate {aggregate:?}")))
1169
+ };
1170
+ let ids_of = |entries: Vec<(String, Json)>| -> Vec<Json> {
1171
+ entries.into_iter().map(|(id, _)| Json::Str(id)).collect()
892
1172
  };
893
- let ids_of = |entries: Vec<(String, Json)>| -> Vec<Json> { entries.into_iter().map(|(id, _)| Json::Str(id)).collect() };
894
1173
 
895
1174
  // The chain, first hop included: `(via_field, target_aggregate)` pairs, each via_field a
896
1175
  // reference on the previous target.
897
1176
  let chain: Vec<(&str, &str)> = std::iter::once((hop.via_field, hop.target_aggregate))
898
- .chain(hop.through.iter().map(|step| (step.via_field, step.target_aggregate)))
1177
+ .chain(
1178
+ hop.through
1179
+ .iter()
1180
+ .map(|step| (step.via_field, step.target_aggregate)),
1181
+ )
899
1182
  .collect();
900
1183
 
901
1184
  // Inside out: the inner clause picks ids on the last target, and each earlier step keeps
902
1185
  // the rows of its target whose next via_field points at one of them.
903
1186
  let (_, last_target) = chain[chain.len() - 1];
904
- let mut ids = ids_of(repository::filter_entries(scan(last_target)?, hop.inner_field, hop.inner_comparator, &inner_want));
1187
+ let mut ids = ids_of(repository::filter_entries(
1188
+ scan(last_target)?,
1189
+ hop.inner_field,
1190
+ hop.inner_comparator,
1191
+ &inner_want,
1192
+ ));
905
1193
  for step in (1..chain.len()).rev() {
906
1194
  let (via_field, _) = chain[step];
907
1195
  let (_, source) = chain[step - 1];
908
- ids = ids_of(repository::filter_entries(scan(source)?, via_field, query_comparators::QueryComparator::In, &Json::Array(ids)));
1196
+ ids = ids_of(repository::filter_entries(
1197
+ scan(source)?,
1198
+ via_field,
1199
+ query_comparators::QueryComparator::In,
1200
+ &Json::Array(ids),
1201
+ ));
909
1202
  }
910
1203
 
911
- rows = repository::filter_entries(rows, hop.via_field, query_comparators::QueryComparator::In, &Json::Array(ids));
1204
+ rows = repository::filter_entries(
1205
+ rows,
1206
+ hop.via_field,
1207
+ query_comparators::QueryComparator::In,
1208
+ &Json::Array(ids),
1209
+ );
912
1210
  }
913
1211
  Ok(rows)
914
1212
  }