hecks 1.3.0 → 1.5.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 (373) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +72 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +83 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
  7. data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
  9. data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
  10. data/lib/hecks/adapters/driven/heki.rb +106 -9
  11. data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
  12. data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
  13. data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
  15. data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
  16. data/lib/hecks/adapters/driven/lambda.rb +82 -35
  17. data/lib/hecks/adapters/driven/local_storage.rb +83 -10
  18. data/lib/hecks/adapters/driven/memory.rb +205 -9
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  24. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  25. data/lib/hecks/adapters/driven/prism.rb +50 -11
  26. data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  31. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  32. data/lib/hecks/adapters/driven/system_clock.rb +3 -0
  33. data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
  34. data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
  35. data/lib/hecks/adapters/driven.rb +6 -4
  36. data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
  37. data/lib/hecks/behaviors/dsl.rb +60 -2
  38. data/lib/hecks/behaviors/expectations.rb +190 -29
  39. data/lib/hecks/behaviors/ir.rb +12 -1
  40. data/lib/hecks/behaviors/rspec.rb +9 -1
  41. data/lib/hecks/behaviors/runner.rb +21 -2
  42. data/lib/hecks/behaviors.rb +9 -1
  43. data/lib/hecks/bluebook/aggregate.rb +43 -13
  44. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
  45. data/lib/hecks/bluebook/assembly/build.rb +26 -5
  46. data/lib/hecks/bluebook/assembly/contract.rb +98 -23
  47. data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
  48. data/lib/hecks/bluebook/assembly/marks.rb +159 -30
  49. data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
  50. data/lib/hecks/bluebook/assembly.rb +32 -14
  51. data/lib/hecks/bluebook/attribute.rb +26 -12
  52. data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
  53. data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
  54. data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
  55. data/lib/hecks/bluebook/behaviour/command.rb +55 -25
  56. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
  57. data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
  58. data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
  59. data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
  60. data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
  61. data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
  62. data/lib/hecks/bluebook/behaviour/query.rb +6 -1
  63. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
  64. data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
  65. data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
  66. data/lib/hecks/bluebook/capabilities.rb +27 -0
  67. data/lib/hecks/bluebook/chapter.rb +51 -9
  68. data/lib/hecks/bluebook/command.rb +62 -17
  69. data/lib/hecks/bluebook/domain_port.rb +34 -9
  70. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  71. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  72. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
  73. data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
  74. data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
  75. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
  76. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
  77. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  78. data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
  79. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  80. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  81. data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
  82. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  83. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
  84. data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
  85. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  86. data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
  87. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  88. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  89. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
  90. data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
  91. data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
  92. data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
  93. data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
  94. data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
  95. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  96. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  97. data/lib/hecks/bluebook/entity.rb +40 -11
  98. data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
  99. data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
  100. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
  101. data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
  102. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
  103. data/lib/hecks/bluebook/expression/resolver.rb +369 -128
  104. data/lib/hecks/bluebook/hexagon.rb +35 -1
  105. data/lib/hecks/bluebook/lifecycle.rb +12 -1
  106. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
  107. data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
  108. data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
  109. data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
  110. data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
  111. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
  112. data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
  113. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
  114. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
  115. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
  116. data/lib/hecks/bluebook/meta_validator.rb +235 -139
  117. data/lib/hecks/bluebook/model_check.rb +434 -104
  118. data/lib/hecks/bluebook/pattern_subset.rb +32 -10
  119. data/lib/hecks/bluebook/policy.rb +15 -13
  120. data/lib/hecks/bluebook/process_manager.rb +27 -14
  121. data/lib/hecks/bluebook/project_discovery.rb +5 -0
  122. data/lib/hecks/bluebook/project_loader.rb +40 -0
  123. data/lib/hecks/bluebook/project_register.rb +50 -6
  124. data/lib/hecks/bluebook/query.rb +31 -4
  125. data/lib/hecks/bluebook/read_model.rb +35 -15
  126. data/lib/hecks/bluebook/reference.rb +26 -13
  127. data/lib/hecks/bluebook/smoke_test.rb +46 -23
  128. data/lib/hecks/bluebook/synthesizer.rb +46 -12
  129. data/lib/hecks/bluebook/translation.rb +34 -5
  130. data/lib/hecks/bluebook/value_object.rb +29 -11
  131. data/lib/hecks/bluebook.rb +5 -6
  132. data/lib/hecks/codemod.rb +138 -50
  133. data/lib/hecks/construct.rb +21 -7
  134. data/lib/hecks/corpus.rb +438 -0
  135. data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
  136. data/lib/hecks/doc/reference.rb +200 -31
  137. data/lib/hecks/embryonaut_bluebook.rb +38 -15
  138. data/lib/hecks/facade/cli_door.rb +69 -10
  139. data/lib/hecks/facade/cli_runner.rb +105 -24
  140. data/lib/hecks/facade/command_request.rb +23 -0
  141. data/lib/hecks/facade/handle.rb +155 -35
  142. data/lib/hecks/facade/json_door.rb +106 -25
  143. data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
  144. data/lib/hecks/facade/surface/chapter.rb +26 -17
  145. data/lib/hecks/facade/surface.rb +16 -3
  146. data/lib/hecks/facade.rb +15 -4
  147. data/lib/hecks/forms/app.rb +46 -30
  148. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  149. data/lib/hecks/forms/field_renderer.rb +142 -6
  150. data/lib/hecks/forms/field_shape.rb +183 -19
  151. data/lib/hecks/forms/html.rb +51 -7
  152. data/lib/hecks/forms/index_renderer.rb +14 -2
  153. data/lib/hecks/forms/page.rb +14 -0
  154. data/lib/hecks/forms/params.rb +120 -23
  155. data/lib/hecks/forms/port_argument.rb +14 -2
  156. data/lib/hecks/forms/query_form_renderer.rb +65 -2
  157. data/lib/hecks/forms/record_renderer.rb +60 -2
  158. data/lib/hecks/forms/record_table.rb +28 -1
  159. data/lib/hecks/forms/reference_options.rb +24 -0
  160. data/lib/hecks/forms/value_object_shape.rb +13 -3
  161. data/lib/hecks/forms.rb +24 -4
  162. data/lib/hecks/fqn.rb +59 -1
  163. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  164. data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
  165. data/lib/hecks/framework/oidc.json +15 -0
  166. data/lib/hecks/framework.rb +79 -25
  167. data/lib/hecks/freezer.rb +27 -11
  168. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
  169. data/lib/hecks/fuzzing/combination_miner.rb +178 -0
  170. data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
  171. data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
  172. data/lib/hecks/fuzzing/differential.rb +192 -0
  173. data/lib/hecks/fuzzing/domain_generator.rb +871 -0
  174. data/lib/hecks/fuzzing/era_boundary.rb +68 -19
  175. data/lib/hecks/fuzzing/form_census.rb +121 -20
  176. data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
  177. data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
  178. data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
  179. data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
  180. data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
  181. data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
  182. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
  183. data/lib/hecks/fuzzing/properties/guards.rb +86 -42
  184. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
  185. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
  186. data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
  187. data/lib/hecks/fuzzing/properties/querying.rb +82 -28
  188. data/lib/hecks/fuzzing/properties.rb +84 -51
  189. data/lib/hecks/fuzzing/qa_settings.rb +164 -0
  190. data/lib/hecks/fuzzing/replay.rb +241 -133
  191. data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
  192. data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
  193. data/lib/hecks/fuzzing/self_consistency.rb +315 -137
  194. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
  195. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  196. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  197. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  198. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
  199. data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
  200. data/lib/hecks/fuzzing/shrinker.rb +309 -0
  201. data/lib/hecks/fuzzing/structural_skips.rb +37 -130
  202. data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
  203. data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
  204. data/lib/hecks/fuzzing/value_generator.rb +110 -22
  205. data/lib/hecks/fuzzing.rb +5 -0
  206. data/lib/hecks/grammar/evolve.rb +188 -12
  207. data/lib/hecks/grammar.rb +53 -7
  208. data/lib/hecks/ir.rb +51 -20
  209. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  210. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  211. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  212. data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
  213. data/lib/hecks/language/oidc.json +5 -0
  214. data/lib/hecks/literal.rb +41 -9
  215. data/lib/hecks/naming.rb +112 -31
  216. data/lib/hecks/ports/access_control.rb +53 -2
  217. data/lib/hecks/ports/agent/answers.rb +83 -6
  218. data/lib/hecks/ports/agent.rb +119 -35
  219. data/lib/hecks/ports/authentication.rb +44 -4
  220. data/lib/hecks/ports/authorization.rb +50 -11
  221. data/lib/hecks/ports/clock.rb +42 -23
  222. data/lib/hecks/ports/extraction.rb +16 -0
  223. data/lib/hecks/ports/identity_assignment.rb +23 -2
  224. data/lib/hecks/ports/identity_generation.rb +17 -3
  225. data/lib/hecks/ports/identity_resolution.rb +17 -1
  226. data/lib/hecks/ports/key_vault.port +6 -0
  227. data/lib/hecks/ports/key_vault.rb +58 -0
  228. data/lib/hecks/ports/loading.rb +4 -0
  229. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  230. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  231. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  232. data/lib/hecks/ports/persistence/execution.rb +4 -0
  233. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  234. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  235. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  236. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  237. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  238. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  239. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  250. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  251. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  252. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
  253. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  254. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  255. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  256. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  257. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  258. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
  259. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  260. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  261. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  262. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
  263. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
  264. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
  265. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
  266. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  267. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  268. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  269. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  270. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  271. data/lib/hecks/ports/persistence.rb +36 -1
  272. data/lib/hecks/ports/projection.rb +61 -7
  273. data/lib/hecks/ports/query/in_memory.rb +41 -8
  274. data/lib/hecks/ports/query/ordering.rb +21 -6
  275. data/lib/hecks/ports/query.rb +35 -0
  276. data/lib/hecks/ports.rb +1 -0
  277. data/lib/hecks/projections/bootstrap_table.rb +147 -0
  278. data/lib/hecks/projections/diagrams.rb +315 -79
  279. data/lib/hecks/projections/glossary/html.rb +91 -3
  280. data/lib/hecks/projections/glossary/markdown.rb +50 -3
  281. data/lib/hecks/projections/glossary/mermaid.rb +49 -1
  282. data/lib/hecks/projections/glossary/sections.rb +1 -1
  283. data/lib/hecks/projections/glossary/sentences.rb +110 -5
  284. data/lib/hecks/projections/glossary.rb +173 -31
  285. data/lib/hecks/projections/ir.rb +1 -1
  286. data/lib/hecks/projections/model/deviations.rb +62 -17
  287. data/lib/hecks/projections/model.rb +75 -21
  288. data/lib/hecks/projections/oidc.rb +45 -8
  289. data/lib/hecks/projections/parser_table.rb +53 -12
  290. data/lib/hecks/projections/reference.rb +15 -3
  291. data/lib/hecks/projections/rust_vocabulary.rb +646 -0
  292. data/lib/hecks/projections/shape.rb +10 -2
  293. data/lib/hecks/projections/statements.rb +69 -22
  294. data/lib/hecks/projections/vocabulary.rb +26 -9
  295. data/lib/hecks/projections.rb +5 -3
  296. data/lib/hecks/projector/cli_projector.rb +245 -37
  297. data/lib/hecks/projector/docs_projector.rb +154 -28
  298. data/lib/hecks/projector/exporter.rb +104 -29
  299. data/lib/hecks/projector/ir_projector.rb +7 -1
  300. data/lib/hecks/projector/narrate_projector.rb +144 -23
  301. data/lib/hecks/projector/target.rb +42 -18
  302. data/lib/hecks/projector.rb +86 -18
  303. data/lib/hecks/query_ir.rb +94 -47
  304. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  305. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  306. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  307. data/lib/hecks/query_specification/common/null_policy.rb +61 -15
  308. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  309. data/lib/hecks/query_specification/common/options.rb +25 -0
  310. data/lib/hecks/query_specification/field_path.rb +69 -15
  311. data/lib/hecks/query_specification/hop_path.rb +57 -20
  312. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  313. data/lib/hecks/rendering.rb +9 -3
  314. data/lib/hecks/router/namespace_installer.rb +16 -3
  315. data/lib/hecks/router.rb +56 -1
  316. data/lib/hecks/runtime/aggregate_lock.rb +20 -11
  317. data/lib/hecks/runtime/boot_gates.rb +21 -3
  318. data/lib/hecks/runtime/caller.rb +40 -8
  319. data/lib/hecks/runtime/capability_graph.rb +13 -2
  320. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
  321. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
  322. data/lib/hecks/runtime/command_interpreter.rb +195 -113
  323. data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
  324. data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
  325. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  326. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  327. data/lib/hecks/runtime/command_rules/references.rb +145 -55
  328. data/lib/hecks/runtime/command_rules.rb +3 -0
  329. data/lib/hecks/runtime/dependency_planning.rb +56 -11
  330. data/lib/hecks/runtime/dispatcher.rb +263 -92
  331. data/lib/hecks/runtime/entity_element.rb +304 -67
  332. data/lib/hecks/runtime/entity_interpreter.rb +149 -88
  333. data/lib/hecks/runtime/errors.rb +37 -23
  334. data/lib/hecks/runtime/event.rb +15 -10
  335. data/lib/hecks/runtime/identity.rb +91 -23
  336. data/lib/hecks/runtime/instance.rb +103 -18
  337. data/lib/hecks/runtime/interpreting.rb +24 -16
  338. data/lib/hecks/runtime/invocation.rb +358 -0
  339. data/lib/hecks/runtime/loader.rb +105 -19
  340. data/lib/hecks/runtime/outbox.rb +164 -26
  341. data/lib/hecks/runtime/policy_interpreter.rb +73 -60
  342. data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
  343. data/lib/hecks/runtime/query_interpreter.rb +93 -74
  344. data/lib/hecks/runtime/reaction_invocation.rb +73 -28
  345. data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
  346. data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
  347. data/lib/hecks/runtime/reference_hop.rb +48 -6
  348. data/lib/hecks/runtime/refusal_wording.rb +142 -115
  349. data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
  350. data/lib/hecks/runtime/registry/verification.rb +153 -28
  351. data/lib/hecks/runtime/registry.rb +202 -32
  352. data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
  353. data/lib/hecks/runtime/routing.rb +36 -154
  354. data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
  355. data/lib/hecks/runtime/saga_interpreter.rb +90 -76
  356. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  357. data/lib/hecks/runtime/tenant_check.rb +33 -13
  358. data/lib/hecks/runtime/tenant_scope.rb +23 -5
  359. data/lib/hecks/runtime/value/admission.rb +75 -30
  360. data/lib/hecks/runtime/value/coercion.rb +555 -142
  361. data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
  362. data/lib/hecks/runtime/value.rb +71 -21
  363. data/lib/hecks/runtime.rb +39 -7
  364. data/lib/hecks/storehouse.rb +368 -72
  365. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
  366. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
  367. data/lib/hecks/version.rb +3 -3
  368. data/lib/hecks/vocabulary.rb +205 -4
  369. data/lib/hecks.rb +91 -11
  370. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
  371. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
  372. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
  373. metadata +28 -2
@@ -4,7 +4,7 @@ require_relative "../../runtime/value"
4
4
  module Hecks
5
5
  module QuerySpecification
6
6
  module Common
7
- # THE ONE COMPARATOR TABLE. There were two, near-identical copies of
7
+ # **The one comparator table**. There were two, near-identical copies of
8
8
  # this — `Ports::Query::InMemory` (the path a Memory- or Heki-backed
9
9
  # aggregate query actually runs) and `Runtime::QueryInterpreter`
10
10
  # (entity/sub-list queries, and any adapter implementing no `:query`)
@@ -15,23 +15,25 @@ module Hecks
15
15
  # real bugs twice already, both recorded in the comments those files
16
16
  # carried. `none_in_state` was added to one copy only, so an ordinary
17
17
  # Memory query's `none_in_state` clause fell to the `else` branch and
18
- # silently excluded every row. And `comparable` DIVERGED without
19
- # anyone noticing: one copy took the FIRST numeric member of a value
20
- # object, the other only unwrapped when there was EXACTLY ONE — so a
18
+ # silently excluded every row. And `comparable` diverged without
19
+ # anyone noticing: one copy took the first numeric member of a value
20
+ # object, the other only unwrapped when there was exactly one — so a
21
21
  # value object with two numeric members compared as a number on one
22
22
  # path and as a whole Hash on the other.
23
23
  #
24
- # The callers still differ in how they REACH a value — one takes a
24
+ # The callers still differ in how they reach a value — one takes a
25
25
  # registry argument, the other closes over `@registry`; one digs the
26
26
  # field through `FieldPath`, the other is handed it — so they keep
27
27
  # their own resolution and share only the comparison itself.
28
28
  module Comparison
29
29
  module_function
30
30
 
31
+ # Unwraps a value object to the one scalar a comparison can mean.
32
+ #
31
33
  # A value object compared as a scalar, when which scalar is meant
32
34
  # is not in doubt. Exactly one numeric member is unambiguous; a
33
35
  # single-member value object is unambiguous whatever its type.
34
- # Anything else is returned UNCHANGED rather than guessed at — two
36
+ # Anything else is returned unchanged rather than guessed at — two
35
37
  # numeric members give no reason to prefer either, and picking the
36
38
  # first silently compares a field the author never named.
37
39
  #
@@ -40,6 +42,11 @@ module Hecks
40
42
  # so this branch is a backstop rather than the primary guard: which
41
43
  # member is meant is knowable at declaration time, and a refusal
42
44
  # naming the candidates is worth more than any runtime reading.
45
+ #
46
+ # @param value [Runtime::Value, Hash, Object, nil] a held or wanted value; a
47
+ # `Runtime::Value` is read through its `to_h`
48
+ # @return [Object, nil] the sole numeric member, or the sole member, of a Hash-shaped
49
+ # value; otherwise `value` unchanged (a `Runtime::Value` comes back as its Hash)
43
50
  def comparable(value)
44
51
  value = value.to_h if value.is_a?(Runtime::Value)
45
52
  return value unless value.is_a?(Hash)
@@ -51,9 +58,15 @@ module Hecks
51
58
  value
52
59
  end
53
60
 
54
- # Which members a value object offers a scalar comparison, for a
61
+ # Lists the members a value object offers a scalar comparison, for a
55
62
  # refusal that can name them. Empty when the value object is
56
63
  # unambiguous — nothing to report.
64
+ #
65
+ # @param value_object [Class<Bluebook::ValueObject>] the declared shape (a subclass
66
+ # minted by `Bluebook::ValueObject.declare`, closed sets included) a query field names
67
+ # @return [Array<Symbol>] every attribute name when no single member can be meant;
68
+ # `[]` when there is exactly one attribute, or exactly one typed `Integer`,
69
+ # `Float` or `Numeric`
57
70
  def ambiguous_members(value_object)
58
71
  numerics = value_object.attributes.select { |a| NUMERIC_TYPES.include?(a.type.to_s) }
59
72
  return [] if numerics.size == 1 || value_object.attributes.size == 1
@@ -63,15 +76,32 @@ module Hecks
63
76
 
64
77
  NUMERIC_TYPES = %w[Integer Float Numeric].freeze
65
78
 
66
- # A `case` over a closed, declared set (Vocabulary::QueryComparator,
79
+ # Decides whether one where-clause comparison holds between the value
80
+ # a record holds and the value the query wants.
81
+ #
82
+ # A `case` over a closed, declared set (`Vocabulary::QueryComparator`,
67
83
  # held equal to this list by spec/vocabulary_table_spec — see the
68
- # `else` branch's own comment) is the whole point of THE ONE
69
- # COMPARATOR TABLE this file's header describes: one place naming
84
+ # `else` branch's own comment) is the whole point of the one
85
+ # comparator table this file's header describes: one place naming
70
86
  # every comparator, not one method per comparator scattered across
71
87
  # a module.
88
+ #
89
+ # @param operation [Symbol, String] the comparator name: `eq`, `ne`, `lt`, `lte`,
90
+ # `gt`, `gte`, `in`, `contains` or `none_in_state`
91
+ # @param held [Object, nil] the record's own value for the field; `nil` against a
92
+ # non-nil `want` satisfies no comparator except `none_in_state` (see
93
+ # `NullPolicy.unmatchable?`)
94
+ # @param want [Object, nil] the value compared against; for `in` an Array or a
95
+ # comma-separated String, for `none_in_state` an `"Aggregate:state"` String
96
+ # @param registry [Runtime::Registry, nil] used only by `none_in_state` to look the
97
+ # target aggregate up; `nil` makes that comparator hold
98
+ # @return [Boolean] whether the comparison holds; an ordered comparator over a
99
+ # non-`Numeric` operand is `false`
100
+ # @raise [Runtime::WiringError] if `operation` names no comparator in this table, or
101
+ # `none_in_state`'s target aggregate has no repository that can be wired
72
102
  # rubocop:disable-next Metrics/CyclomaticComplexity
73
103
  def holds?(operation, held, want, registry: nil)
74
- # A NULL SATISFIES NO COMPARISON — NullPolicy.unmatchable? owns
104
+ # A NULL satisfies no comparison — NullPolicy.unmatchable? owns
75
105
  # the rule and the reasoning, including why `none_in_state` is
76
106
  # exempt from it.
77
107
  return false if NullPolicy.unmatchable?(operation, held, want)
@@ -100,28 +130,51 @@ module Hecks
100
130
  end
101
131
  end
102
132
 
133
+ # Checks that both operands of an ordered comparison are numbers.
134
+ #
103
135
  # gt/gte/lt/lte are numeric-only and silently false otherwise — a
104
136
  # where-clause never raises the way a given does, and that contract
105
137
  # predates the extraction (lt was already exactly this permissive).
138
+ #
139
+ # @param held [Object, nil] the record's own value for the field
140
+ # @param want [Object, nil] the value compared against
141
+ # @return [Boolean] `true` only when both are `Numeric`
106
142
  def ordered?(held, want) = held.is_a?(Numeric) && want.is_a?(Numeric)
107
143
 
144
+ # Reads a comparator's list operand as the Strings membership is tested against.
145
+ #
108
146
  # `in` reads a comma-separated list — a real Array survives
109
147
  # untouched (a bluebook's own in-process value, before any wire
110
148
  # serialisation), each element unwrapped the same way a scalar
111
- # field is. This is `in`'s reading of ITS ARGUMENT (a caller may
149
+ # field is. This is `in`'s reading of its argument (a caller may
112
150
  # legitimately pass "a,b,c" meaning "any of these") — unrelated to
113
- # `contains`, which reads the STORED field. See `contains?`.
151
+ # `contains`, which reads the stored field. See `contains?`.
152
+ #
153
+ # @param value [Array, String, Object, nil] an Array of elements, or anything whose
154
+ # `to_s` is a comma-separated list such as `"a, b,c"`
155
+ # @return [Array<String>] one String per member: Array elements unwrapped through
156
+ # `comparable` then `to_s`; split parts stripped of surrounding whitespace; `[]`
157
+ # for `nil` or an empty String
114
158
  def members(value)
115
159
  return value.map { |element| comparable(element).to_s } if value.is_a?(Array)
116
160
 
117
161
  value.to_s.split(",").map(&:strip)
118
162
  end
119
163
 
164
+ # Answers `in`: whether the held value, or any element of a held Array,
165
+ # occurs in the wanted list.
166
+ #
120
167
  # A folded reference hop asks whether the locally-held identity is
121
168
  # among the matching target identities. A has_many relationship holds
122
169
  # several identities, so the same question becomes an intersection:
123
- # does ANY held identity occur in the wanted set? Scalar `in` retains
170
+ # does any held identity occur in the wanted set? Scalar `in` retains
124
171
  # its existing one-candidate behavior.
172
+ #
173
+ # @param held [Array, Object] the record's own value; an Array contributes each
174
+ # element as a candidate, anything else is the single candidate
175
+ # @param want [Array, String, Object] the wanted set, read through `members`
176
+ # @return [Boolean] whether any candidate, unwrapped through `comparable` and
177
+ # compared as a String, is a wanted member
125
178
  def any_member_in?(held, want)
126
179
  wanted = members(want)
127
180
  candidates = held.is_a?(Array) ? held : [held]
@@ -129,24 +182,35 @@ module Hecks
129
182
  candidates.any? { |candidate| wanted.include?(comparable(candidate).to_s) }
130
183
  end
131
184
 
132
- # `contains` means two different things depending on what is HELD —
133
- # real ELEMENT membership for a `list_of` field (a genuine Array
185
+ # Answers `contains`: element membership for a held Array, substring
186
+ # for anything else.
187
+ #
188
+ # `contains` means two different things depending on what is held —
189
+ # real element membership for a `list_of` field (a genuine Array
134
190
  # arrives already, one element one member, nothing to split), and
135
- # plain SUBSTRING for anything else. It used to fall through to
136
- # `members`' comma-split for the scalar case too, silently reading
137
- # a free-text field's own comma as a separator — which the SQL
138
- # side's `instr`/`position` never did, so the two disagreed the
139
- # moment a scalar's real content held a comma. Matching SQL's
191
+ # plain substring for anything else. The scalar case deliberately
192
+ # stays out of `members`' comma-split: that silently reads a
193
+ # free-text field's own comma as a separator — which the SQL
194
+ # side's `instr`/`position` never does, so the two disagree the
195
+ # moment a scalar's real content holds a comma. Matching SQL's
140
196
  # substring reading here keeps every engine answering `contains`
141
197
  # identically for the same declared field.
198
+ #
199
+ # @param held [Array, Object] the record's own value; anything but an Array is
200
+ # read through `to_s`
201
+ # @param want [Object] the element or substring looked for, compared as its `to_s`
202
+ # @return [Boolean] whether `held` has `want` as a member (Array) or a substring
142
203
  def contains?(held, want)
143
204
  return members(held).include?(want.to_s) if held.is_a?(Array)
144
205
 
145
206
  held.to_s.include?(want.to_s)
146
207
  end
147
208
 
148
- # A CROSS-AGGREGATE ANTI-JOIN — `where ref: { none_in_state:
149
- # "Claim:held" }` holds when NO record in the named aggregate,
209
+ # Answers `none_in_state` by looking the held identity up in another
210
+ # aggregate's repository and reading that record's state.
211
+ #
212
+ # **A cross-aggregate anti-join** — `where ref: { none_in_state:
213
+ # "Claim:held" }` holds when no record in the named aggregate,
150
214
  # keyed by this record's own field value, is in the named state.
151
215
  # No registry — no way to look the target up — reads as "not
152
216
  # excluded", the same graceful default a missing record already
@@ -154,6 +218,17 @@ module Hecks
154
218
  # loaded domain; ambiguity (two domains declaring one name) picks
155
219
  # the first match rather than refusing, since a where-clause never
156
220
  # raises (see `ordered?`).
221
+ #
222
+ # @param held [Object, nil] this record's own field value, used as the target
223
+ # record's identity
224
+ # @param want [String, Symbol] `"Aggregate:state"` — the target aggregate's bare
225
+ # name and the excluded state, split on the first colon
226
+ # @param registry [Runtime::Registry, nil] the booted registry to find the target
227
+ # aggregate and its repository in
228
+ # @return [Boolean] `false` only when the target record exists and its lifecycle
229
+ # field (or `:state`, absent a lifecycle) equals the named state; `true` when
230
+ # there is no registry, no such aggregate or no such record
231
+ # @raise [Runtime::WiringError] if the target aggregate's repository cannot be wired
157
232
  def none_in_state?(held, want, registry)
158
233
  return true unless registry
159
234
 
@@ -165,14 +240,14 @@ module Hecks
165
240
  record = registry.repository(target_domain, target_ir).find(held)
166
241
  return true unless record
167
242
 
168
- # THE FIELD A STATE LIVES ON, READ FROM THE TARGET'S OWN
169
- # DECLARATION — not assumed to be literally named `state`. Every
243
+ # The field a state lives on, read from the target's own
244
+ # declaration — not assumed to be literally named `state`. Every
170
245
  # `none_in_state` fixture this comparator originally shipped
171
246
  # with (spec/query_none_in_state_*_spec.rb) happens to declare a
172
247
  # plain `attribute :state, ...` rather than a real `lifecycle`,
173
248
  # which is how the previous hardcoded `record.state[:state]`
174
249
  # passed every one of them while being wrong for the shape this
175
- # whole comparator exists to answer about: a real state MACHINE.
250
+ # whole comparator exists to answer about: a real state machine.
176
251
  # `lifecycle :field, default: ... do ... end` stores its state
177
252
  # under `field` (`Instance#assign_creation_attributes`'s own
178
253
  # `state[aggregate.lifecycle.field.to_sym] = ...`), and this
@@ -181,7 +256,7 @@ module Hecks
181
256
  # `lifecycle :status` among many others) — so the hardcoded key
182
257
  # silently read `nil` from every real lifecycle-backed target,
183
258
  # comparable(nil) != state was true unconditionally, and
184
- # `none_in_state` against ANY lifecycle aggregate answered
259
+ # `none_in_state` against any lifecycle aggregate answered
185
260
  # "not excluded" for every row, always, no matter its actual
186
261
  # state. Found chasing `QualityControl::Bug.AwaitingClearance`
187
262
  # (qa/bluebook/quality_control.bluebook), which is exactly this
@@ -194,6 +269,13 @@ module Hecks
194
269
  comparable(record.state[field]) != state
195
270
  end
196
271
 
272
+ # Searches every loaded domain for an aggregate by its bare name, taking
273
+ # the first match in the registry's load order.
274
+ #
275
+ # @param registry [Runtime::Registry] the booted registry whose bluebooks are searched
276
+ # @param name [String, nil] the aggregate's bare `hecks_name`, such as `"Claim"`
277
+ # @return [Array(String, Bluebook::Aggregate), nil] the owning domain's name and the
278
+ # aggregate; `nil` when no loaded domain declares one by that name
197
279
  def find_aggregate_by_name(registry, name)
198
280
  registry.bluebooks.each do |domain, bluebook|
199
281
  aggregate = bluebook.aggregates.find { |a| a.hecks_name == name }
@@ -17,15 +17,27 @@ module Hecks
17
17
  # ReadModelBuilder so the two specification kinds parse the same
18
18
  # clauses identically rather than each carrying its own copy.
19
19
  module DSL
20
- # RENAMED FROM `where`/`order_by`/`limit`/`offset`/`authorize`
21
- # (all below) — item #13's full metaprogrammed dispatch (slice
22
- # 4c). A SHARED mixin, same shape `attribute_impl` proved in
23
- # slice 3: ONE renamed method each, both Query and ReadModel
24
- # Keyword rows name it in `calls:`. `where`/`order_by` are
25
- # bootstrap-reachable (every core chapter's own `read_model`
26
- # filters its roster with them), so both are in
27
- # BOOTSTRAP_CALLS_FALLBACK for the ReadModel context; `limit`/
28
- # `offset`/`authorize` are not (checked directly).
20
+ # Records one `WhereClause` per `field => value` pair — what the
21
+ # `where` DSL word forwards to.
22
+ #
23
+ # `where`/`order_by`/`limit`/`offset`/`authorize` (all below) carry
24
+ # an `_impl` name because the words themselves are dispatched by
25
+ # the grammar table rather than defined as methods: both the Query
26
+ # and the ReadModel Keyword rows name the `_impl` method in
27
+ # `calls:`, the same shape `attribute_impl` has, and a shared mixin
28
+ # means one method each. `where`/`order_by` are bootstrap-reachable
29
+ # (every core chapter's own `read_model` filters its roster with
30
+ # them), so they must resolve through
31
+ # `GenericDispatch::BOOTSTRAP_CALLS_FALLBACK` while the grammar
32
+ # table is still being built.
33
+ #
34
+ # @param clauses [Hash{Symbol => Object}] field name (a dotted or slashed path is
35
+ # one Symbol) to either a literal, a Symbol naming a query argument, or a
36
+ # one-pair Hash `{ comparator => operand }` such as `{ gte: :minimum }`; a bare
37
+ # value means `eq`
38
+ # @return [void]
39
+ # @raise [ArgumentError] if a Hash value does not have exactly one pair, or names a
40
+ # comparator outside `COMPARATORS`
29
41
  def where_impl(clauses)
30
42
  @wheres ||= []
31
43
  clauses.each do |field, value|
@@ -34,15 +46,59 @@ module Hecks
34
46
  end
35
47
  end
36
48
 
49
+ # Records the query's single ordering, replacing any declared earlier.
50
+ #
51
+ # @param field [Symbol, String] the field to order by; a dotted path such as
52
+ # `:"order.value"` reaches a value object's member
53
+ # @param direction [Symbol, String] `:asc` or `:desc`
54
+ # @return [OrderBy] the ordering just recorded
37
55
  def order_by_impl(field, direction = :asc)
38
56
  @order_by = OrderBy.new(field: field, direction: direction)
39
57
  end
40
58
 
59
+ # Records the most rows the query returns.
60
+ #
61
+ # @param value [Integer, Symbol] a literal row count, or a Symbol naming the query
62
+ # argument that supplies it
63
+ # @return [LimitSpec] the limit just recorded
41
64
  def limit_impl(value) = @limit = LimitSpec.new(value: value)
65
+
66
+ # Records how many matched rows the query skips before the limit applies.
67
+ #
68
+ # @param value [Integer, Symbol] a literal row count, or a Symbol naming the query
69
+ # argument that supplies it
70
+ # @return [OffsetSpec] the offset just recorded
42
71
  def offset_impl(value) = @offset = OffsetSpec.new(value: value)
72
+
73
+ # Records a cursor declaration. The word parses and round-trips, but both builders
74
+ # refuse it at `build` (`seal_cursor`) because no interpreter applies it.
75
+ #
76
+ # @param value [Symbol, Object] the query argument carrying the cursor, such as
77
+ # `:after`, or a literal
78
+ # @return [CursorSpec] the cursor just recorded
43
79
  def cursor(value) = @cursor = CursorSpec.new(value: value)
80
+
81
+ # Records the authorization the query declares, and the argument that scopes it
82
+ # to one tenant.
83
+ #
84
+ # @param policy [Symbol, String] name of the access policy, such as `:vault_access`
85
+ # @param tenant [Symbol, String, nil] the field every ask must supply a value for
86
+ # and is filtered by (`Runtime::TenantScope`); `nil` declares no tenant scoping
87
+ # @return [AuthorizationSpec] the authorization just recorded
44
88
  def authorize_impl(policy, tenant: nil) = @authorization = AuthorizationSpec.new(policy: policy, tenant: tenant)
89
+
90
+ # Records where a null sorts, overriding the `native` default.
91
+ #
92
+ # @param mode [Symbol, String] `:first` or `:last`; anything else orders as
93
+ # `:native` does (see `NullPolicy.order`)
94
+ # @return [NullSemantics] the null policy just recorded
45
95
  def nulls(mode) = @null_semantics = NullSemantics.new(mode: mode)
96
+
97
+ # Records that the query asks its adapter to expose the query it generates.
98
+ #
99
+ # @param mode [Symbol, String] what to expose; `:sql` is the only mode
100
+ # `Ports::Query.validate!` lets an adapter without `inspect_query` serve
101
+ # @return [InspectionSpec] the inspection request just recorded
46
102
  def inspect_query(mode = :sql) = @inspection = InspectionSpec.new(mode: mode)
47
103
 
48
104
  private
@@ -10,39 +10,67 @@ module Hecks
10
10
  module NullPolicy
11
11
  module_function
12
12
 
13
- # STABLE on purpose : rows arrive already in identity order from
14
- # Ports::Query::Ordering, and that base is what makes a tie deterministic
13
+ # Sorts records in memory by one key, placing the null-keyed ones where
14
+ # the policy says and keeping ties in their incoming order.
15
+ #
16
+ # Stable on purpose : rows arrive already in identity order from
17
+ # `Ports::Query::Ordering`, and that base is what makes a tie deterministic
15
18
  # rather than store-dependent. A plain sort_by is not stable in Ruby, so
16
19
  # equal keys would shuffle and the identity tier would be lost exactly
17
20
  # where it is needed. Descending reverses both partitions, so a tie reads
18
21
  # identity-descending too — the same total order sql_order renders as
19
22
  # `field DESC, id DESC`.
23
+ #
24
+ # @param records [Array<Object>] the rows to order, already in identity order
25
+ # @param direction [Symbol, String] `desc` in any letter case sorts descending;
26
+ # anything else ascending
27
+ # @param policy [NullSemantics, nil] where nulls go: mode `first` or `last`; `nil` or
28
+ # any other mode (`native`) puts nulls first ascending and last descending
29
+ # @yield reads the sort key out of one record
30
+ # @yieldparam record [Object] one element of `records`
31
+ # @yieldreturn [Comparable, nil] the key, mutually comparable across records; `nil`
32
+ # marks the record as null-keyed
33
+ # @return [Array<Object>] a new Array holding every record in the requested order
34
+ # @raise [ArgumentError] if two non-nil keys cannot be compared with each other
20
35
  def order(records, direction:, policy: nil, &key)
36
+ descending = direction.to_s.downcase == "desc"
21
37
  null_rows, valued_rows = records.partition { |record| key.call(record).nil? }
22
38
  sorted = valued_rows.each_with_index.sort_by { |record, index| [key.call(record), index] }.map(&:first)
23
- if direction.to_s == "desc"
39
+ if descending
24
40
  sorted.reverse!
25
41
  null_rows.reverse!
26
42
  end
27
43
  case policy&.mode.to_s
28
44
  when "first" then null_rows + sorted
29
45
  when "last" then sorted + null_rows
30
- else direction.to_s == "desc" ? sorted + null_rows : null_rows + sorted
46
+ else descending ? sorted + null_rows : null_rows + sorted
31
47
  end
32
48
  end
33
49
 
34
- # M3 — an UNDECLARED (`native`) null policy used to render no
35
- # `NULLS ...` clause at all here, leaving each dialect's own
36
- # default to decide: Postgres puts nulls LAST on ASC (and FIRST
50
+ # Renders the `ORDER BY` terms for one expression, with an explicit
51
+ # `NULLS FIRST`/`NULLS LAST` and an `id` tiebreak in the same direction.
52
+ #
53
+ # M3 — an undeclared (`native`) null policy still renders a
54
+ # `NULLS ...` clause. Rendering none leaves each dialect's own
55
+ # default to decide: Postgres puts nulls last on ASC (and first
37
56
  # on DESC), while `#order` above — this same "native" default,
38
- # for Memory — puts nulls FIRST on ASC (and LAST on DESC), the
57
+ # for Memory — puts nulls first on ASC (and last on DESC), the
39
58
  # SQLite convention. Same query, same data, different row order
40
59
  # depending only on which adapter ran it. Rendered explicitly
41
- # here instead, so an undeclared policy means the SAME total
60
+ # here instead, so an undeclared policy means the same total
42
61
  # order everywhere rather than "whatever this store already does"
43
62
  # — matching `#order`'s own default rather than the other way
44
63
  # round, since that default is unconditional (Memory/Heki have no
45
64
  # dialect to defer to) and SQLite already agrees with it natively.
65
+ #
66
+ # @param expression [String] the SQL expression to order by, already quoted or
67
+ # built by the adapter; interpolated as is
68
+ # @param direction [Symbol, String] `desc` in any letter case renders `DESC`;
69
+ # anything else `ASC`
70
+ # @param policy [NullSemantics, nil] mode `first` or `last` pins the nulls; `nil` or
71
+ # any other mode renders `NULLS FIRST` ascending and `NULLS LAST` descending
72
+ # @return [String] the terms without the `ORDER BY` keyword, such as
73
+ # `"price ASC NULLS FIRST, id ASC"`
46
74
  def sql_order(expression, direction, policy)
47
75
  direction = direction.to_s.downcase == "desc" ? "DESC" : "ASC"
48
76
  nulls = case policy&.mode.to_s
@@ -53,36 +81,54 @@ module Hecks
53
81
  "#{expression} #{direction}#{nulls}, id #{direction}"
54
82
  end
55
83
 
56
- # THE COMPARATORS A NULL CANNOT SATISFY — the other half of
57
- # `sql_predicate` below. That one answers "the value COMPARED TO is
84
+ # **The comparators a NULL cannot satisfy** — the other half of
85
+ # `sql_predicate` below. That one answers "the value compared to is
58
86
  # null" (`eq: nil` -> IS NULL, `ne: nil` -> IS NOT NULL, a real
59
87
  # convention both adapters already shared). This one answers the
60
- # case nothing covered: the ROW'S OWN value is null and the value
88
+ # case nothing covered: the row's own value is null and the value
61
89
  # compared to is not.
62
90
  #
63
91
  # SQL says unknown. `NULL <> 'red'` is NULL, not true, so the row
64
92
  # is not returned. Ruby says `nil != "red"` is true, so it is. The
65
- # two adapters therefore answered the SAME query on the SAME data
93
+ # two adapters therefore answered the same query on the same data
66
94
  # differently — Memory returning a row SQLite omitted — which is
67
95
  # not a difference of opinion a caller can plan around.
68
96
  #
69
97
  # Resolved toward SQL, and not because SQL is the store: an absent
70
- # or null field is UNKNOWN, not a value, and a comparison against
98
+ # or null field is unknown, not a value, and a comparison against
71
99
  # unknown is unknown rather than true. Making SQL match Ruby
72
100
  # instead would mean compiling every `ne:` to
73
101
  # `(col <> $1 OR col IS NULL)` — more to get right in two
74
102
  # dialects, and a reliable way to lose an index — to make a real
75
103
  # query engine agree with an in-memory one.
76
104
  #
77
- # `none_in_state` is deliberately NOT here: it is a 9th, vendored
105
+ # `none_in_state` is deliberately not here: it is a 9th, vendored
78
106
  # comparator whose `held` is a reference id, and a row holding no
79
107
  # reference is genuinely "not in that state" rather than unknown.
80
108
  NULL_UNMATCHABLE = %w[eq ne lt lte gt gte in contains].freeze
81
109
 
110
+ # Decides whether a comparison is lost before it starts because the row's
111
+ # own value is null — the rule `NULL_UNMATCHABLE`'s comment argues for.
112
+ #
113
+ # @param operation [Symbol, String] the comparator name, such as `:ne`
114
+ # @param held [Object, nil] the row's own value for the field
115
+ # @param want [Object, nil] the value compared against
116
+ # @return [Boolean] `true` when `held` is `nil`, `want` is not, and the comparator is
117
+ # one of `NULL_UNMATCHABLE`; `none_in_state` is never unmatchable
82
118
  def unmatchable?(operation, held, want)
83
119
  held.nil? && !want.nil? && NULL_UNMATCHABLE.include?(operation.to_s)
84
120
  end
85
121
 
122
+ # Renders the SQL for a comparison against a null value — `eq: nil` as
123
+ # `IS NULL`, `ne: nil` as `IS NOT NULL` — so an adapter never binds a
124
+ # `NULL` parameter to `=` or `<>`.
125
+ #
126
+ # @param expression [String] the SQL expression for the field, interpolated as is
127
+ # @param operation [Symbol, String] the comparator name
128
+ # @param value [Object, nil] the resolved value compared against
129
+ # @return [Array(String, Array), nil] the predicate text and its (empty) bind
130
+ # parameters; `nil` when `value` is not `nil` or the comparator is neither `eq`
131
+ # nor `ne`, leaving the adapter to render the clause itself
86
132
  def sql_predicate(expression, operation, value)
87
133
  if value.nil? && operation.to_s == "eq"
88
134
  ["#{expression} IS NULL", []]
@@ -4,6 +4,10 @@ module Hecks
4
4
  NullSemantics = Struct.new(:mode, keyword_init: true) do
5
5
  def to_h = { mode: mode.to_s }
6
6
 
7
+ # Builds the policy a query has when it never writes `nulls`: each
8
+ # engine orders nulls first ascending and last descending (see `NullPolicy`).
9
+ #
10
+ # @return [NullSemantics] a new instance whose mode is `:native`
7
11
  def self.default = new(mode: :native)
8
12
  end
9
13
  end
@@ -13,6 +13,18 @@ module Hecks
13
13
  attr_reader :wheres, :order_by, :limit, :offset, :cursor,
14
14
  :authorization, :null_semantics, :inspection
15
15
 
16
+ # @param wheres [Array<WhereClause>] the filter clauses, all of which must hold
17
+ # @param order_by [OrderBy, nil] the single ordering; `nil` leaves identity order
18
+ # @param limit [LimitSpec, nil] the most rows returned; `nil` is unbounded
19
+ # @param offset [OffsetSpec, nil] rows skipped before the limit; `nil` skips none
20
+ # @param cursor [CursorSpec, nil] the cursor declaration; `nil` when none is declared
21
+ # @param authorization [AuthorizationSpec, nil] the declared policy and tenant field;
22
+ # `nil` when the query declares no `authorize`
23
+ # @param inspection [InspectionSpec, nil] the `inspect_query` request; `nil` when
24
+ # the query asks for none
25
+ # @param null_semantics [NullSemantics, nil] where nulls sort; stored as given, so
26
+ # an explicit `nil` (what `ReadModelBuilder` passes when `nulls` was never
27
+ # written) stays `nil` rather than becoming the `native` default
16
28
  def initialize(wheres: [], order_by: nil, limit: nil, offset: nil, cursor: nil,
17
29
  authorization: nil,
18
30
  inspection: nil, null_semantics: NullSemantics.default)
@@ -26,12 +38,25 @@ module Hecks
26
38
  @inspection = inspection
27
39
  end
28
40
 
41
+ # Serializes every shared option, declared or not, so a subclass's `to_h`
42
+ # has one fixed set of keys to build on.
43
+ #
44
+ # @return [Hash{Symbol => Array<Hash>, Hash, nil}] keys `:wheres` (an Array of clause
45
+ # Hashes, `[]` when none), `:order_by`, `:limit`, `:offset`, `:cursor`,
46
+ # `:authorization`, `:null_semantics` and `:inspection`, each that spec's own
47
+ # `to_h` or `nil` when undeclared
29
48
  def options_to_h
30
49
  { wheres: @wheres.map(&:to_h), order_by: @order_by&.to_h, limit: @limit&.to_h,
31
50
  offset: @offset&.to_h, cursor: @cursor&.to_h, authorization: @authorization&.to_h,
32
51
  null_semantics: @null_semantics&.to_h, inspection: @inspection&.to_h }
33
52
  end
34
53
 
54
+ # Serializes only the declared options beyond the settled three, so a
55
+ # construct that never wrote one keeps its wire shape unchanged.
56
+ #
57
+ # @return [Hash{Symbol => Hash}] the subset of `options_to_h` that is set, without
58
+ # `:wheres`, `:order_by` and `:limit` (which the subclass emits itself) and
59
+ # without a `:null_semantics` of `{ mode: "native" }`; `{}` when nothing is
35
60
  def extra_options_to_h
36
61
  options_to_h.reject do |key, value|
37
62
  value.nil? || value == [] || (key == :null_semantics && value == { mode: "native" })