hecks 0.2.0 → 1.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 (419) hide show
  1. checksums.yaml +5 -5
  2. data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +127 -0
  4. data/lib/hecks/adapters/driven/d1.adapter +12 -0
  5. data/lib/hecks/adapters/driven/d1.rb +427 -0
  6. data/lib/hecks/adapters/driven/folder.adapter +3 -0
  7. data/lib/hecks/adapters/driven/folder.rb +199 -0
  8. data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
  9. data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
  10. data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
  11. data/lib/hecks/adapters/driven/governance_authorization.rb +101 -0
  12. data/lib/hecks/adapters/driven/heki/journal.rb +56 -0
  13. data/lib/hecks/adapters/driven/heki/saga_store.rb +99 -0
  14. data/lib/hecks/adapters/driven/heki/snapshot.rb +65 -0
  15. data/lib/hecks/adapters/driven/heki.adapter +4 -0
  16. data/lib/hecks/adapters/driven/heki.rb +179 -0
  17. data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
  18. data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
  19. data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
  20. data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
  21. data/lib/hecks/adapters/driven/lambda.adapter +4 -0
  22. data/lib/hecks/adapters/driven/lambda.rb +145 -0
  23. data/lib/hecks/adapters/driven/memory.adapter +3 -0
  24. data/lib/hecks/adapters/driven/memory.rb +103 -0
  25. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
  26. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
  27. data/lib/hecks/adapters/driven/postgres/codec.rb +88 -0
  28. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +207 -0
  29. data/lib/hecks/adapters/driven/postgres.adapter +5 -0
  30. data/lib/hecks/adapters/driven/postgres.rb +438 -0
  31. data/lib/hecks/adapters/driven/postgres_era.adapter +17 -0
  32. data/lib/hecks/adapters/driven/prism.adapter +3 -0
  33. data/lib/hecks/adapters/driven/prism.rb +80 -0
  34. data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
  35. data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
  36. data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
  37. data/lib/hecks/adapters/driven/sqlite/codec.rb +80 -0
  38. data/lib/hecks/adapters/driven/sqlite/projection.rb +172 -0
  39. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +192 -0
  40. data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
  41. data/lib/hecks/adapters/driven/sqlite.rb +306 -0
  42. data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
  43. data/lib/hecks/adapters/driven/system_clock.rb +14 -0
  44. data/lib/hecks/adapters/driven.rb +54 -0
  45. data/lib/hecks/adapters.rb +6 -0
  46. data/lib/hecks/behaviors/dsl.rb +125 -0
  47. data/lib/hecks/behaviors/expectations.rb +340 -0
  48. data/lib/hecks/behaviors/ir.rb +31 -0
  49. data/lib/hecks/behaviors/rspec.rb +42 -0
  50. data/lib/hecks/behaviors/runner.rb +96 -0
  51. data/lib/hecks/behaviors.rb +25 -0
  52. data/lib/hecks/bluebook/aggregate.rb +108 -0
  53. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
  54. data/lib/hecks/bluebook/assembly/build.rb +48 -0
  55. data/lib/hecks/bluebook/assembly/contract.rb +112 -0
  56. data/lib/hecks/bluebook/assembly/contracts.rb +438 -0
  57. data/lib/hecks/bluebook/assembly/marks.rb +228 -0
  58. data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
  59. data/lib/hecks/bluebook/assembly.rb +91 -0
  60. data/lib/hecks/bluebook/attribute.rb +96 -0
  61. data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
  62. data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
  63. data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
  64. data/lib/hecks/bluebook/behaviour/command.rb +116 -0
  65. data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
  66. data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
  67. data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
  68. data/lib/hecks/bluebook/behaviour/lifecycle.rb +68 -0
  69. data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
  70. data/lib/hecks/bluebook/behaviour/process_manager.rb +51 -0
  71. data/lib/hecks/bluebook/behaviour/query.rb +10 -0
  72. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
  73. data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
  74. data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
  75. data/lib/hecks/bluebook/chapter.rb +78 -0
  76. data/lib/hecks/bluebook/command.rb +124 -0
  77. data/lib/hecks/bluebook/domain_port.rb +102 -0
  78. data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
  79. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +1018 -0
  80. data/lib/hecks/bluebook/dsl/attribute_collector.rb +348 -0
  81. data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
  82. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1087 -0
  83. data/lib/hecks/bluebook/dsl/command_builder.rb +714 -0
  84. data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
  85. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
  86. data/lib/hecks/bluebook/dsl/entity_builder.rb +430 -0
  87. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
  88. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
  89. data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
  90. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
  91. data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
  92. data/lib/hecks/bluebook/dsl/policy_builder.rb +135 -0
  93. data/lib/hecks/bluebook/dsl/port_builder.rb +39 -0
  94. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
  95. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +306 -0
  96. data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
  97. data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
  98. data/lib/hecks/bluebook/dsl/rule_reference.rb +173 -0
  99. data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
  100. data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
  101. data/lib/hecks/bluebook/dsl/word_gate.rb +228 -0
  102. data/lib/hecks/bluebook/dsl/world_builder.rb +117 -0
  103. data/lib/hecks/bluebook/dsl.rb +45 -0
  104. data/lib/hecks/bluebook/entity.rb +103 -0
  105. data/lib/hecks/bluebook/expression/canonical_form.rb +123 -0
  106. data/lib/hecks/bluebook/expression/evaluator.rb +304 -0
  107. data/lib/hecks/bluebook/expression/projection.json +218 -0
  108. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
  109. data/lib/hecks/bluebook/expression/resolver.rb +781 -0
  110. data/lib/hecks/bluebook/expression.rb +13 -0
  111. data/lib/hecks/bluebook/hexagon.rb +60 -0
  112. data/lib/hecks/bluebook/lifecycle.rb +42 -0
  113. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
  114. data/lib/hecks/bluebook/meta_validator/judge.rb +621 -0
  115. data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
  116. data/lib/hecks/bluebook/meta_validator/port_judge.rb +51 -0
  117. data/lib/hecks/bluebook/meta_validator/readings.rb +360 -0
  118. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +351 -0
  119. data/lib/hecks/bluebook/meta_validator/shapes.rb +266 -0
  120. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
  121. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
  122. data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
  123. data/lib/hecks/bluebook/meta_validator.rb +542 -0
  124. data/lib/hecks/bluebook/model_check.rb +445 -0
  125. data/lib/hecks/bluebook/pattern_subset.rb +184 -0
  126. data/lib/hecks/bluebook/policy.rb +41 -0
  127. data/lib/hecks/bluebook/process_manager.rb +128 -0
  128. data/lib/hecks/bluebook/project_discovery.rb +30 -0
  129. data/lib/hecks/bluebook/project_loader.rb +40 -0
  130. data/lib/hecks/bluebook/project_register.rb +82 -0
  131. data/lib/hecks/bluebook/query.rb +61 -0
  132. data/lib/hecks/bluebook/read_model.rb +109 -0
  133. data/lib/hecks/bluebook/reference.rb +74 -0
  134. data/lib/hecks/bluebook/smoke_test.rb +166 -0
  135. data/lib/hecks/bluebook/synthesizer.rb +95 -0
  136. data/lib/hecks/bluebook/translation.rb +92 -0
  137. data/lib/hecks/bluebook/value_object.rb +66 -0
  138. data/lib/hecks/bluebook.rb +74 -0
  139. data/lib/hecks/codemod.rb +342 -0
  140. data/lib/hecks/construct.rb +71 -0
  141. data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
  142. data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
  143. data/lib/hecks/deploy/oidc.json +18 -0
  144. data/lib/hecks/doc/reference.rb +410 -0
  145. data/lib/hecks/embryonaut_bluebook.rb +75 -0
  146. data/lib/hecks/facade/cli_door.rb +119 -0
  147. data/lib/hecks/facade/cli_runner.rb +190 -0
  148. data/lib/hecks/facade/command_request.rb +105 -0
  149. data/lib/hecks/facade/handle.rb +173 -0
  150. data/lib/hecks/facade/json_door.rb +166 -0
  151. data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
  152. data/lib/hecks/facade/surface/chapter.rb +107 -0
  153. data/lib/hecks/facade/surface.rb +48 -0
  154. data/lib/hecks/facade.rb +44 -0
  155. data/lib/hecks/forms/app.rb +341 -0
  156. data/lib/hecks/forms/command_form_renderer.rb +113 -0
  157. data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
  158. data/lib/hecks/forms/field_renderer.rb +177 -0
  159. data/lib/hecks/forms/field_shape.rb +232 -0
  160. data/lib/hecks/forms/html.rb +84 -0
  161. data/lib/hecks/forms/index_renderer.rb +35 -0
  162. data/lib/hecks/forms/page.rb +157 -0
  163. data/lib/hecks/forms/params.rb +161 -0
  164. data/lib/hecks/forms/port_argument.rb +46 -0
  165. data/lib/hecks/forms/query_form_renderer.rb +114 -0
  166. data/lib/hecks/forms/record_renderer.rb +119 -0
  167. data/lib/hecks/forms/record_table.rb +68 -0
  168. data/lib/hecks/forms/reference_options.rb +30 -0
  169. data/lib/hecks/forms/value_object_shape.rb +46 -0
  170. data/lib/hecks/forms.rb +54 -0
  171. data/lib/hecks/fqn.rb +94 -0
  172. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
  173. data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
  174. data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
  175. data/lib/hecks/framework/bluebook/governance.bluebook +145 -0
  176. data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
  177. data/lib/hecks/framework/oidc.json +39 -0
  178. data/lib/hecks/framework.rb +90 -0
  179. data/lib/hecks/freezer.rb +67 -0
  180. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  181. data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
  182. data/lib/hecks/fuzzing/isolated_boot.rb +286 -0
  183. data/lib/hecks/fuzzing/properties.rb +1192 -0
  184. data/lib/hecks/fuzzing/replay.rb +668 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +80 -0
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +250 -0
  189. data/lib/hecks/fuzzing/sequence_generator.rb +131 -0
  190. data/lib/hecks/fuzzing/value_generator.rb +200 -0
  191. data/lib/hecks/fuzzing.rb +12 -0
  192. data/lib/hecks/grammar/evolve.rb +324 -0
  193. data/lib/hecks/grammar/expression.bluebook +420 -0
  194. data/lib/hecks/grammar/expression_operators.json +1648 -0
  195. data/lib/hecks/grammar/grammar.hecksagon +20 -0
  196. data/lib/hecks/grammar/oidc.json +69 -0
  197. data/lib/hecks/grammar/translation.bluebook +203 -0
  198. data/lib/hecks/grammar.rb +178 -0
  199. data/lib/hecks/ir.rb +126 -0
  200. data/lib/hecks/language/adapter.bluebook +116 -0
  201. data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
  202. data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
  203. data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
  204. data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
  205. data/lib/hecks/language/bluebook/command.bluebook +471 -0
  206. data/lib/hecks/language/bluebook/entity.bluebook +392 -0
  207. data/lib/hecks/language/bluebook/policy.bluebook +189 -0
  208. data/lib/hecks/language/bluebook/process_manager.bluebook +381 -0
  209. data/lib/hecks/language/bluebook/projection.bluebook +267 -0
  210. data/lib/hecks/language/bluebook/query.bluebook +245 -0
  211. data/lib/hecks/language/bluebook/shape.bluebook +292 -0
  212. data/lib/hecks/language/bluebook/syntax.bluebook +448 -0
  213. data/lib/hecks/language/bluebook/vocabulary.bluebook +379 -0
  214. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
  215. data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
  216. data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
  217. data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
  218. data/lib/hecks/language/oidc.json +333 -0
  219. data/lib/hecks/language/port.bluebook +120 -0
  220. data/lib/hecks/language/translation/translation.bluebook +110 -0
  221. data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
  222. data/lib/hecks/language/world/wiring.bluebook +62 -0
  223. data/lib/hecks/language/world/world.bluebook +84 -0
  224. data/lib/hecks/literal.rb +125 -0
  225. data/lib/hecks/naming.rb +174 -0
  226. data/lib/hecks/ports/access_control.port +9 -0
  227. data/lib/hecks/ports/access_control.rb +62 -0
  228. data/lib/hecks/ports/agent/answers.rb +104 -0
  229. data/lib/hecks/ports/agent.port +8 -0
  230. data/lib/hecks/ports/agent.rb +167 -0
  231. data/lib/hecks/ports/authentication.port +6 -0
  232. data/lib/hecks/ports/authentication.rb +50 -0
  233. data/lib/hecks/ports/authorization.port +7 -0
  234. data/lib/hecks/ports/authorization.rb +62 -0
  235. data/lib/hecks/ports/clock.port +5 -0
  236. data/lib/hecks/ports/clock.rb +62 -0
  237. data/lib/hecks/ports/extraction.port +5 -0
  238. data/lib/hecks/ports/extraction.rb +37 -0
  239. data/lib/hecks/ports/identity_assignment.port +5 -0
  240. data/lib/hecks/ports/identity_assignment.rb +45 -0
  241. data/lib/hecks/ports/identity_generation.port +5 -0
  242. data/lib/hecks/ports/identity_generation.rb +49 -0
  243. data/lib/hecks/ports/identity_resolution.port +5 -0
  244. data/lib/hecks/ports/identity_resolution.rb +40 -0
  245. data/lib/hecks/ports/loading.port +4 -0
  246. data/lib/hecks/ports/loading.rb +13 -0
  247. data/lib/hecks/ports/persistence/append_only.rb +138 -0
  248. data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
  249. data/lib/hecks/ports/persistence/execution.rb +23 -0
  250. data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
  251. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  252. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +169 -0
  253. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +128 -0
  254. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +161 -0
  255. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +61 -0
  256. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +304 -0
  257. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +171 -0
  258. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +190 -0
  259. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +478 -0
  260. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +166 -0
  261. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +314 -0
  262. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +168 -0
  263. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +170 -0
  264. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +134 -0
  265. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +137 -0
  266. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +89 -0
  267. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +85 -0
  268. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
  269. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +132 -0
  270. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +74 -0
  271. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +758 -0
  272. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +120 -0
  273. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +31 -0
  274. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +46 -0
  275. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +102 -0
  276. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +46 -0
  277. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +70 -0
  278. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +72 -0
  279. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +120 -0
  280. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +183 -0
  281. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +41 -0
  282. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +28 -0
  283. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +30 -0
  284. data/lib/hecks/ports/persistence/plugins/era/translation.rb +11 -0
  285. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  286. data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
  287. data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
  288. data/lib/hecks/ports/persistence.port +4 -0
  289. data/lib/hecks/ports/persistence.rb +39 -0
  290. data/lib/hecks/ports/projection.port +4 -0
  291. data/lib/hecks/ports/projection.rb +96 -0
  292. data/lib/hecks/ports/query/in_memory.rb +60 -0
  293. data/lib/hecks/ports/query/ordering.rb +41 -0
  294. data/lib/hecks/ports/query.rb +36 -0
  295. data/lib/hecks/ports.rb +27 -0
  296. data/lib/hecks/projections/diagrams.rb +642 -0
  297. data/lib/hecks/projections/ir.rb +18 -0
  298. data/lib/hecks/projections/model/deviations.rb +98 -0
  299. data/lib/hecks/projections/model.rb +145 -0
  300. data/lib/hecks/projections/oidc.rb +110 -0
  301. data/lib/hecks/projections/parser_table.rb +159 -0
  302. data/lib/hecks/projections/reference.rb +38 -0
  303. data/lib/hecks/projections/shape.rb +44 -0
  304. data/lib/hecks/projections/statements.rb +110 -0
  305. data/lib/hecks/projections/vocabulary.rb +100 -0
  306. data/lib/hecks/projections.rb +32 -0
  307. data/lib/hecks/projector/cli_projector.rb +446 -0
  308. data/lib/hecks/projector/docs_projector.rb +321 -0
  309. data/lib/hecks/projector/exporter.rb +158 -0
  310. data/lib/hecks/projector/ir_projector.rb +18 -0
  311. data/lib/hecks/projector/narrate_projector.rb +243 -0
  312. data/lib/hecks/projector/target.rb +97 -0
  313. data/lib/hecks/projector.rb +186 -0
  314. data/lib/hecks/query_ir.rb +411 -0
  315. data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
  316. data/lib/hecks/query_specification/common/comparators.rb +25 -0
  317. data/lib/hecks/query_specification/common/comparison.rb +174 -0
  318. data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
  319. data/lib/hecks/query_specification/common/dsl.rb +58 -0
  320. data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
  321. data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
  322. data/lib/hecks/query_specification/common/null_policy.rb +92 -0
  323. data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
  324. data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
  325. data/lib/hecks/query_specification/common/options.rb +32 -0
  326. data/lib/hecks/query_specification/common/order_by.rb +9 -0
  327. data/lib/hecks/query_specification/common/specification.rb +10 -0
  328. data/lib/hecks/query_specification/common/where_clause.rb +9 -0
  329. data/lib/hecks/query_specification/field_path.rb +107 -0
  330. data/lib/hecks/query_specification/hop_path.rb +132 -0
  331. data/lib/hecks/query_specification/read_model/specification.rb +18 -0
  332. data/lib/hecks/query_specification.rb +14 -0
  333. data/lib/hecks/rendering.rb +48 -0
  334. data/lib/hecks/router/namespace_installer.rb +157 -0
  335. data/lib/hecks/router.rb +70 -0
  336. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  337. data/lib/hecks/runtime/boot_gates.rb +41 -0
  338. data/lib/hecks/runtime/caller.rb +66 -0
  339. data/lib/hecks/runtime/capability_graph.rb +44 -0
  340. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +135 -0
  341. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +260 -0
  342. data/lib/hecks/runtime/command_interpreter.rb +497 -0
  343. data/lib/hecks/runtime/command_rules/admissibility.rb +357 -0
  344. data/lib/hecks/runtime/command_rules/arithmetic.rb +260 -0
  345. data/lib/hecks/runtime/command_rules/authorization.rb +65 -0
  346. data/lib/hecks/runtime/command_rules/emission.rb +34 -0
  347. data/lib/hecks/runtime/command_rules/references.rb +189 -0
  348. data/lib/hecks/runtime/command_rules.rb +28 -0
  349. data/lib/hecks/runtime/dependency_planning.rb +245 -0
  350. data/lib/hecks/runtime/dispatcher.rb +301 -0
  351. data/lib/hecks/runtime/entity_element.rb +253 -0
  352. data/lib/hecks/runtime/entity_interpreter.rb +299 -0
  353. data/lib/hecks/runtime/errors.rb +122 -0
  354. data/lib/hecks/runtime/event.rb +51 -0
  355. data/lib/hecks/runtime/identity.rb +156 -0
  356. data/lib/hecks/runtime/instance.rb +175 -0
  357. data/lib/hecks/runtime/interpreting.rb +90 -0
  358. data/lib/hecks/runtime/loader.rb +184 -0
  359. data/lib/hecks/runtime/policy_interpreter.rb +366 -0
  360. data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
  361. data/lib/hecks/runtime/query_interpreter.rb +286 -0
  362. data/lib/hecks/runtime/reaction_invocation.rb +253 -0
  363. data/lib/hecks/runtime/read_model_interpreter.rb +341 -0
  364. data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
  365. data/lib/hecks/runtime/reference_hop.rb +99 -0
  366. data/lib/hecks/runtime/refusal_wording.rb +126 -0
  367. data/lib/hecks/runtime/registry/saga_persistence.rb +142 -0
  368. data/lib/hecks/runtime/registry/verification.rb +246 -0
  369. data/lib/hecks/runtime/registry.rb +279 -0
  370. data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
  371. data/lib/hecks/runtime/routing.rb +96 -0
  372. data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
  373. data/lib/hecks/runtime/saga_interpreter.rb +503 -0
  374. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  375. data/lib/hecks/runtime/tenant_check.rb +84 -0
  376. data/lib/hecks/runtime/tenant_scope.rb +55 -0
  377. data/lib/hecks/runtime/value/admission.rb +130 -0
  378. data/lib/hecks/runtime/value/coercion.rb +565 -0
  379. data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
  380. data/lib/hecks/runtime/value.rb +125 -0
  381. data/lib/hecks/runtime.rb +107 -0
  382. data/lib/hecks/storehouse.rb +632 -0
  383. data/lib/hecks/version.rb +16 -0
  384. data/lib/hecks/vocabulary.rb +218 -0
  385. data/lib/hecks.rb +123 -6
  386. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  387. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  388. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  389. metadata +412 -222
  390. data/bin/hecks +0 -7
  391. data/bin/hecks-package +0 -65
  392. data/bin/hecks_console +0 -12
  393. data/bin/hecks_serverless +0 -6
  394. data/lib/cli/build.rb +0 -14
  395. data/lib/cli/command_runner.rb +0 -28
  396. data/lib/cli/console.rb +0 -10
  397. data/lib/cli/generate.rb +0 -37
  398. data/lib/cli/hecks-cli.rb +0 -27
  399. data/lib/cli/test.rb +0 -57
  400. data/lib/console/commands.rb +0 -8
  401. data/lib/console/hecks-console.rb +0 -1
  402. data/lib/packager/README.md +0 -0
  403. data/lib/packager/app_runner.rb +0 -21
  404. data/lib/packager/args.rb +0 -26
  405. data/lib/packager/compatibility/fixnum.rb +0 -6
  406. data/lib/packager/hecks.rb +0 -39
  407. data/lib/packager/query_runner.rb +0 -21
  408. data/lib/packager/resources/Dockerfile +0 -11
  409. data/lib/packager/resources/app_binary +0 -7
  410. data/lib/packager/resources/bundle_config +0 -3
  411. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
  412. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
  413. data/lib/serverless/Domain +0 -32
  414. data/lib/serverless/cli.rb +0 -75
  415. data/lib/serverless/resources/command_name.js +0 -5
  416. data/lib/serverless/resources/environment.js +0 -7
  417. data/lib/serverless/resources/handler.js.tt +0 -28
  418. data/lib/serverless/resources/run_binary.js +0 -22
  419. data/lib/serverless/resources/serverless.yml +0 -20
@@ -0,0 +1,221 @@
1
+ require_relative "../../forms/value_object_shape"
2
+ require_relative "../../query_specification/common/null_policy"
3
+ require_relative "../../query_specification/field_path"
4
+ require_relative "../../runtime/errors"
5
+ require_relative "../../runtime/value"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ # The one SQL query compilation, shared by the SQLite and Postgres
10
+ # adapters — each used to carry its own copy of this walk, near-line-
11
+ # identical, and the copies could only drift. Every declared operator
12
+ # compiles fully into SQL or the query refuses loudly; the null-policy
13
+ # predicate, the comma-separated `in` convention, and the identity
14
+ # ORDER BY fallback are spelled once, here.
15
+ #
16
+ # What a dialect actually differs on is narrow, and each adapter
17
+ # supplies exactly those hooks:
18
+ #
19
+ # placeholder(binds, value) — "?" or "$N", recording the bind
20
+ # contains_clause(expr, ph) — instr() or position(), for a SCALAR field
21
+ # list_contains_clause(name, member, ph) — real element containment,
22
+ # for a `list_of` field (json_each /
23
+ # jsonb_array_elements)
24
+ # empty_in_clause — "0" or "FALSE"
25
+ # comparable_expression(e, v) — ::numeric cast, where the dialect casts
26
+ # plain_column(name) — a real column or a jsonb path
27
+ # nested_expression(name, path, member) — json_extract or state #>>
28
+ # order_clause(order_by, policy)
29
+ # execute_query(sql, binds) — run it, return instances
30
+ # dialect_name — for the refusal message
31
+ module SqlQueryBuilder
32
+ COMPARATORS = {
33
+ "eq" => "=", "ne" => "<>", "gt" => ">", "gte" => ">=", "lt" => "<", "lte" => "<="
34
+ }.freeze
35
+
36
+ def query(declared, args = {}, context: {})
37
+ sql = +"SELECT #{select_list} FROM #{from_relation}"
38
+ clauses = []
39
+ binds = []
40
+ declared.wheres.each do |clause|
41
+ value = query_value(clause.value, args)
42
+ expression = query_expression(clause.field, value: value)
43
+ if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
44
+ clauses << null_predicate.first
45
+ next
46
+ end
47
+ clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
48
+ end
49
+ sql << " WHERE #{clauses.join(' AND ')}" unless clauses.empty?
50
+ sql << if declared.order_by
51
+ " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}"
52
+ else
53
+ " ORDER BY id"
54
+ end
55
+ sql << " LIMIT #{placeholder(binds, query_value(declared.limit.value, args).to_i)}" if declared.limit
56
+ # An offset with no declared limit — SQLite refuses a bare OFFSET
57
+ # outright (LIMIT -1 is its unbounded idiom) while Postgres accepts
58
+ # one, so the dialect supplies its own unbounded spelling. Found by
59
+ # the adapter-agreement gate on its first run, not by either
60
+ # adapter's own spec: each was self-consistent, they just disagreed.
61
+ sql << unbounded_limit if !declared.limit && declared.offset
62
+ sql << " OFFSET #{placeholder(binds, query_value(declared.offset.value, args).to_i)}" if declared.offset
63
+ execute_query(sql, binds)
64
+ end
65
+
66
+ private
67
+
68
+ def where_clause(op, expression, value, binds, field: nil)
69
+ case op
70
+ when "eq", "ne", "gt", "gte", "lt", "lte"
71
+ "#{comparable_expression(expression, value)} #{COMPARATORS.fetch(op)} #{placeholder(binds, value)}"
72
+ when "contains"
73
+ member = field && list_member(field)
74
+ if member
75
+ list_contains_clause(field.to_s, member, placeholder(binds, value.to_s))
76
+ else
77
+ contains_clause(expression, placeholder(binds, value.to_s))
78
+ end
79
+ when "in"
80
+ members = in_members(value)
81
+ return empty_in_clause if members.empty?
82
+
83
+ # BOTH SIDES AS TEXT. `in` is a textual reading everywhere else
84
+ # — Ports::Query::InMemory#holds? compares `held.to_s` against
85
+ # stringified members, and `in_members` above stringifies its
86
+ # own — so a numeric field was the one shape where the engines
87
+ # could not agree: `in_members` binds '100', while the column
88
+ # expression yields the number 100, and `100 IN ('100','300')`
89
+ # is false in SQLite (a json_extract result carries no column
90
+ # affinity to coerce it) where Memory matched. Casting the
91
+ # expression rather than typing the members keeps one rule for
92
+ # every field: no type inference, and `in` means the same thing
93
+ # on a String, an Integer, and a value-object member.
94
+ #
95
+ # `eq`/`gt`/`lt` never had this problem because they bind the
96
+ # value's OWN type and route through `comparable_expression`,
97
+ # which Postgres overrides to cast numerics.
98
+ "CAST(#{expression} AS TEXT) IN (#{members.map { |member| placeholder(binds, member) }.join(', ')})"
99
+ else
100
+ raise ArgumentError, "#{dialect_name} query adapter does not support #{op.inspect}"
101
+ end
102
+ end
103
+
104
+ # A REAL ARRAY ALREADY SAYS WHERE ITS MEMBERS END. Splitting one on
105
+ # commas re-reads a boundary it already drew — and an id is a
106
+ # domain value (Naming::IDENTITY_JOIN joins a composite identity's
107
+ # own parts, and the parts it joins are whatever an identity path
108
+ # pulled out of real data), so a name carrying a comma would
109
+ # silently become two members matching the wrong rows, or nothing.
110
+ # This branch used to call `value.to_s` unconditionally, which
111
+ # meant only a comma-joined STRING ever worked here, while
112
+ # Ports::Query::InMemory and QueryInterpreter's own `members`
113
+ # already handled a real Array — three implementations of `in`,
114
+ # two readings of it. Narrower than those two on one point,
115
+ # deliberately: they unwrap a value-object/Hash element to its
116
+ # own scalar first (`comparable`) before stringifying, a rule
117
+ # this doesn't reproduce — every real caller of `in` today, and
118
+ # the cross-aggregate hop this exists for, passes plain scalar
119
+ # ids, never a value-object element.
120
+ def in_members(value)
121
+ return value.map(&:to_s).reject(&:empty?) if value.is_a?(Array)
122
+
123
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
124
+ end
125
+
126
+ # Every declared field compiles into an expression over the stored
127
+ # shape — or the query never runs. The member-picking rules are the
128
+ # same in both dialects: a value-object field compares through the
129
+ # member the bound value (or the declared types) say is numeric,
130
+ # falling back to the one-field convention `value`.
131
+ #
132
+ # A REFERENCE IS AN ID, stored as a bare scalar (never wrapped —
133
+ # `Runtime::Value.refuse_object_reference` guarantees it), so it
134
+ # takes the SAME plain-column path as any other non-value-object
135
+ # attribute. Excluding it into the value-object member-picking logic
136
+ # compiles a nested read against a row that has no nested key — a
137
+ # path that can never match. Measured, not assumed: a `where` on a
138
+ # has_one/belongs_to/reference_to field returned zero rows against
139
+ # real data until that exclusion was removed.
140
+ def query_expression(field, value: nil)
141
+ name, *path = field.to_s.split(".")
142
+ attribute = @aggregate.attribute(name)
143
+
144
+ return plain_column(name) if path.empty? && @aggregate.lifecycle&.field.to_s == name
145
+ return plain_column(name) if path.empty? && attribute && !value_object?(attribute)
146
+
147
+ member = if path.empty? && value
148
+ hash = value.is_a?(Runtime::Value) ? value.to_h : value
149
+ numeric = hash.is_a?(Hash) && hash.find { |_key, item| item.is_a?(Numeric) }
150
+ numeric ? numeric.first : nil
151
+ end
152
+ member ||= if path.empty? && attribute && value_object?(attribute)
153
+ object = @aggregate.value_object(attribute.type)
154
+ Forms::ValueObjectShape.numeric_member(object)&.name
155
+ end
156
+ nested_expression(name, path, member)
157
+ end
158
+
159
+ # A NUMERIC MEMBER WINS if the value object has one (Price, Money —
160
+ # what every ordered comparison in the corpus until now compared),
161
+ # otherwise the SAME single-field fallback `query_expression`'s own
162
+ # `nested_expression(name, path, member || "value")` already
163
+ # makes for the COLUMN side — a plain `attribute :value, String`
164
+ # value object (IdentityId, RoleName, ...) has no numeric member at
165
+ # all, and returning nil there silently turned an equality
166
+ # comparison into `IS NULL`, matching nothing. Both sides of one
167
+ # comparison have to agree on which field they mean, or they
168
+ # compare two different things and call it a where clause.
169
+ def query_value(value, args)
170
+ value = args[value] if value.is_a?(Symbol)
171
+ hash = value.is_a?(Runtime::Value) ? value.to_h : value
172
+ if hash.is_a?(Hash)
173
+ numeric = hash.find { |_key, item| item.is_a?(Numeric) }
174
+ return numeric.last if numeric
175
+ return hash[:value] if hash.key?(:value)
176
+ return hash.values.first if hash.size == 1
177
+ end
178
+
179
+ Runtime::Value.scalar(value)
180
+ rescue Runtime::TypeMismatch
181
+ value.is_a?(Hash) && value.size == 1 ? value.values.first : value
182
+ end
183
+
184
+ def value_object?(attr)
185
+ !attr.list? && !@aggregate.value_object(attr.type).nil?
186
+ end
187
+
188
+ # `contains` on a `list_of` field means real ELEMENT membership, not
189
+ # a substring search over the column's raw JSON text — the reading
190
+ # QueryInterpreter#holds? and Ports::Query::InMemory already give a
191
+ # real Array, and the SQL side used to disagree with it (a substring
192
+ # match over `[{"value":"not_high_risk"}]` falsely matches
193
+ # "high_risk"). Returns nil for a non-list field (the caller falls
194
+ # back to `contains_clause`, unchanged), "" for a list of bare
195
+ # scalars (no member to walk into), or the one field name a
196
+ # single-field value-object element carries. A list of a
197
+ # MULTI-field value object has no one scalar to compare against and
198
+ # is refused rather than guessed.
199
+ def list_member(field)
200
+ return nil if field.to_s.include?(".")
201
+
202
+ attribute = @aggregate.attribute(field.to_s)
203
+ return nil unless attribute&.list?
204
+
205
+ object = @aggregate.value_object(attribute.type)
206
+ return "" unless object
207
+ return object.attributes.first.name.to_s if object.attributes.size == 1
208
+
209
+ raise ArgumentError,
210
+ "#{dialect_name} query adapter cannot compile contains on #{field} — " \
211
+ "#{attribute.type} carries more than one field, so no single scalar to compare"
212
+ end
213
+
214
+ # The dialect that casts nothing overrides nothing.
215
+ def comparable_expression(expression, _value) = expression
216
+
217
+ # The dialect that accepts a bare OFFSET overrides nothing.
218
+ def unbounded_limit = ""
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,80 @@
1
+ require_relative "../../../runtime/value"
2
+
3
+ module Hecks
4
+ module Adapters
5
+ class Sqlite
6
+ # How a state crosses the column boundary: which fields persist,
7
+ # and how each one encodes into (and decodes out of) its column.
8
+ module Codec
9
+ private
10
+
11
+ def persisted_fields
12
+ fields = @aggregate.attributes.reject { |attribute| attribute.name == :id }.map do |attribute|
13
+ { name: attribute.name, attribute: attribute, sql_type: sql_type(attribute) }
14
+ end
15
+ lifecycle = @aggregate.lifecycle
16
+ fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
17
+ # `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO —
18
+ # `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
19
+ # both write one straight into `Instance#state` the same as
20
+ # any other field, so a column has to exist to hold it or
21
+ # `save`'s own `columns =`/`values =` build (this file, not
22
+ # here) silently drops it on every write. No `attribute` of
23
+ # their own to carry (`ProjectedField` is a bare name/
24
+ # reference/remote_field triple, not a typed attribute) —
25
+ # treated as a raw scalar, same as the lifecycle field just
26
+ # above: every real corpus use projects a status/lifecycle
27
+ # string, and `encode_field`/`decode` both already pass a
28
+ # `attribute: nil` field through untouched, not JSON-encoded.
29
+ @aggregate.projected_fields.each do |field|
30
+ next if fields.any? { |f| f[:name] == field.name }
31
+
32
+ fields << { name: field.name, attribute: nil, sql_type: "TEXT" }
33
+ end
34
+ fields
35
+ end
36
+
37
+ def encode(attr, value)
38
+ # NEVER SET IS NOT EMPTY. `then_set ... append:` starts a list at `[]`
39
+ # the moment the first element lands, but a list attribute nothing has
40
+ # ever appended to — the shape every OTHER aggregate in the corpus
41
+ # happened not to carry, until one declared a list-typed head
42
+ # attribute that no creating command populates by default — has to
43
+ # stay NULL to answer the same as Memory does. Forcing it to `[]`
44
+ # here is how a persistence topology invented data no dispatch wrote.
45
+ return (value.nil? ? nil : JSON.generate(value)) if attr.list?
46
+ return JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Runtime::Value)
47
+
48
+ value
49
+ end
50
+
51
+ def encode_field(field, value)
52
+ return value unless field[:attribute]
53
+
54
+ encode(field[:attribute], value)
55
+ end
56
+
57
+ def decode(row)
58
+ persisted_fields.each_with_object({}) do |field, state|
59
+ attr = field[:attribute]
60
+ unless attr
61
+ state[field[:name]] = row[field[:name].to_s]
62
+ next
63
+ end
64
+ raw = row[attr.name.to_s]
65
+ state[attr.name] =
66
+ if attr.list? || value_object?(attr)
67
+ raw ? JSON.parse(raw, symbolize_names: true) : nil
68
+ else
69
+ # A REFERENCE lands here now, with the ordinary scalars. It holds
70
+ # the id of a head, which is text — `JSON.parse("acct-1")` raises,
71
+ # so reading it as JSON was only ever survivable while the column
72
+ # held an object.
73
+ raw
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,172 @@
1
+ require_relative "../../../runtime/instance"
2
+ require_relative "../../../runtime/value"
3
+ require_relative "../../../runtime/errors"
4
+ require_relative "../../../runtime/refusal_wording"
5
+ require_relative "../../../rendering"
6
+ require_relative "../../../ports/query/in_memory"
7
+
8
+ # The subclass needs its parent — and sqlite.rb requires this file at
9
+ # its BOTTOM, after class Sqlite is defined, so the require cycle this
10
+ # creates resolves correctly from either entry point.
11
+ require_relative "../sqlite"
12
+
13
+ module Hecks
14
+ module Adapters
15
+ # Rebuilds a read store from the authoritative journal, entry by entry.
16
+ # A value object is written as its JSON object and a reference as the
17
+ # bare id it holds — the same shapes the command path writes, so there
18
+ # is no second representation to accept here any more.
19
+ class SqliteProjection < Sqlite
20
+ def project(entry)
21
+ return @db.execute("DELETE FROM #{quoted_table} WHERE id = ?", [entry.id]) if entry.delete?
22
+
23
+ columns = (["id"] + persisted_fields.map { |field| field[:name].to_s }).map { |column| quote_ident(column) }
24
+ values = [entry.id.to_s] + persisted_fields.map { |field| encode_field(field, entry.state[field[:name]]) }
25
+ @db.execute(
26
+ "INSERT OR REPLACE INTO #{quoted_table} (#{columns.join(', ')}) VALUES (#{Array.new(columns.size,
27
+ '?').join(', ')})", values
28
+ )
29
+ entry
30
+ end
31
+
32
+ # Execute a declared read model against the projected aggregate-head
33
+ # tables. The report shape is assembled from SQL-selected rows, rather
34
+ # than scanning repositories and matching references in Ruby.
35
+ #
36
+ # M19 (docs/audits/2026-08-10-main-bug-audit.md,
37
+ # docs/audits/2026-08-11-bug-triage.md) — this used to diverge from
38
+ # `Runtime::ReadModelInterpreter#project` (the in-process path) on
39
+ # two counts, both fixed here to agree with it:
40
+ #
41
+ # MISSING ROOT: the in-process path's own `fetch` refuses with
42
+ # `NotFound` when the reference argument names no record —
43
+ # `query_read_model` used to answer a silent `{root: nil, ...}`
44
+ # instead, the one path a caller could dispatch a read model
45
+ # against a record that never existed and get back something that
46
+ # LOOKS like an empty report rather than the refusal every other
47
+ # path gives.
48
+ #
49
+ # CHAINED-INCLUDE JOIN SCOPE: a non-root head was always matched
50
+ # against the ROOT's own id, regardless of what it actually
51
+ # references — correct for a head that references the root
52
+ # directly, silently EMPTY for one that references another
53
+ # included head instead (`Leaf` -> `Mid` -> `Root`, `Leaf` itself
54
+ # has no attribute referencing `Root` at all, so `references`
55
+ # was always `[]`). The in-process path's own root-first fix
56
+ # (`ReadModelInterpreter#project`'s "ROOT FIRST, ALWAYS" comment)
57
+ # already matches a head against ANY already-projected source, not
58
+ # only the root — `select_related` now does the same: each head is
59
+ # matched against every source resolved so far (root first, then
60
+ # declared order — the same one-level-of-declaration-order
61
+ # dependency the in-process path itself still has, documented
62
+ # there as L2, not a gap introduced here).
63
+ def query_read_model(_domain, model, args, bluebook = nil)
64
+ raise ArgumentError, "projection query needs its domain bluebook" unless bluebook
65
+
66
+ # The REFERENCE's own shape, read the same way ReadModelInterpreter
67
+ # reads it — not the identity unwrap, which is gone : an identity is
68
+ # declared as a path and followed.
69
+ reference_id = args.fetch(model.reference_name).to_s
70
+ eligible = model.filtered_head_name
71
+
72
+ # ROOT FIRST, ALWAYS — see this method's own header. Mirrors
73
+ # `ReadModelInterpreter#project`'s identical partition, for the
74
+ # identical reason: a later head's own join has to be able to
75
+ # match against a root (or another head) already resolved.
76
+ root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
77
+ projected = []
78
+ reports = {}
79
+ (root_heads + other_heads).each do |head|
80
+ aggregate = bluebook.aggregate(head[:aggregate])
81
+ rows = if head[:aggregate] == model.reference_target
82
+ [select_projected(aggregate, reference_id) ||
83
+ raise(Runtime::NotFound,
84
+ Runtime::RefusalWording.render("NotFound", "read_model_reference_missing",
85
+ aggregate: head[:aggregate],
86
+ offered: Hecks::Rendering.describe(reference_id)))]
87
+ else
88
+ select_related(aggregate, projected)
89
+ end
90
+ rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
91
+ projected << { aggregate: head[:aggregate], rows: rows }
92
+ reports[head[:as]] = if head[:many]
93
+ rows.map { |row| Runtime::Value.materialize(row.to_h) }
94
+ else
95
+ rows.first && Runtime::Value.materialize(rows.first.to_h)
96
+ end
97
+ end
98
+ [reports]
99
+ end
100
+
101
+ private
102
+
103
+ def select_projected(aggregate, id)
104
+ row = @db.get_first_row("SELECT * FROM #{quote_ident(aggregate.storage_name)} WHERE id = ?", [id.to_s])
105
+ row && projected_instance(aggregate, row)
106
+ end
107
+
108
+ # Matched against EVERY source already projected (root first, then
109
+ # declared order — see this class's own `query_read_model` header),
110
+ # not only the root — a head whose own reference points at another
111
+ # included head rather than the root directly used to match nothing
112
+ # at all, since its reference attribute was compared against a
113
+ # target (the root) it never names.
114
+ def select_related(aggregate, projected)
115
+ matches = projected.flat_map do |source|
116
+ references = aggregate.attributes.select do |attribute|
117
+ attribute.reference? && attribute.type.target_name == source[:aggregate].to_s
118
+ end
119
+ next [] if references.empty?
120
+
121
+ ids = source[:rows].map { |row| row.id.to_s }
122
+ next [] if ids.empty?
123
+
124
+ references.product(ids)
125
+ end
126
+ return [] if matches.empty?
127
+
128
+ # A REFERENCE COLUMN HOLDS THE ID, so it compares as itself. The
129
+ # `json_extract(col,'$.value') = ? OR col = ?` this replaced was
130
+ # reading both shapes because both existed — one written by the
131
+ # command path, one by older journals. There is one shape now.
132
+ clauses = matches.map { |attribute, _id| "#{quote_ident(attribute.name)} = ?" }
133
+ bind = matches.map { |_attribute, id| id }
134
+ @db.execute("SELECT * FROM #{quote_ident(aggregate.storage_name)} WHERE #{clauses.join(' OR ')} ORDER BY id", bind)
135
+ .map { |row| projected_instance(aggregate, row) }
136
+ end
137
+
138
+ def projected_instance(aggregate, row)
139
+ Runtime::Instance.new(aggregate: aggregate, id: row["id"], state: decode_for(aggregate, row))
140
+ end
141
+
142
+ def decode_for(aggregate, row)
143
+ fields = aggregate.attributes.map { |attribute| [attribute.name, attribute] }
144
+ if (lifecycle = aggregate.lifecycle) && !fields.any? { |name, _| name == lifecycle.field }
145
+ fields << [lifecycle.field, nil]
146
+ end
147
+ # `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
148
+ # (above) already writes one into its own column via `persisted_fields`
149
+ # (`Codec#persisted_fields`, this class's own superclass module), but
150
+ # this method built its own independent field list that never
151
+ # consulted it — a column `project` populated correctly, silently
152
+ # dropped on every read back out. Same raw-passthrough treatment as
153
+ # the lifecycle field just above: `attribute: nil` down in the loop.
154
+ aggregate.projected_fields.each do |field|
155
+ fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
156
+ end
157
+ fields.each_with_object({}) do |(name, attribute), state|
158
+ raw = row[name.to_s]
159
+ state[name] =
160
+ if attribute.nil?
161
+ raw
162
+ elsif attribute.list? || !aggregate.value_object(attribute.type).nil?
163
+ raw ? JSON.parse(raw, symbolize_names: true) : nil
164
+ else
165
+ # A reference is a scalar id — see Codec#decode.
166
+ raw
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,192 @@
1
+ module Hecks
2
+ module Adapters
3
+ class Sqlite
4
+ # The DDL: one table per aggregate head, an append-only entry table
5
+ # beside it, the shared events table, and the two ALTERs that let an
6
+ # older database grow the columns newer code writes.
7
+ module SchemaBuilder
8
+ private
9
+
10
+ def create_aggregate_table!
11
+ columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
12
+ @db.execute(
13
+ "CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{columns.empty? ? '' : ', '}#{columns.join(', ')})"
14
+ )
15
+ # RIGHT HERE, NOT AS A SEPARATE STEP IN `Sqlite#initialize` —
16
+ # `create_aggregate_table!` is the one piece of DDL `D1`
17
+ # already calls verbatim through this same shared module
18
+ # (`d1.rb`'s own header: "reuses Sqlite::SchemaBuilder and
19
+ # Sqlite::Codec UNCHANGED"). Folding index creation in here,
20
+ # right after the table it indexes exists, is what makes D1's
21
+ # own automatic indexing a real, running thing rather than an
22
+ # aspiration that only Sqlite ever calls — no D1-specific
23
+ # wiring needed, because D1 never has to know this exists.
24
+ ensure_indexes!
25
+ end
26
+
27
+ def create_event_table!
28
+ @db.execute(<<~SQL)
29
+ CREATE TABLE IF NOT EXISTS events (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ name TEXT NOT NULL,
32
+ aggregate TEXT NOT NULL,
33
+ aggregate_id TEXT NOT NULL,
34
+ payload TEXT,
35
+ occurred_at TEXT
36
+ )
37
+ SQL
38
+ end
39
+
40
+ def create_entry_table!
41
+ @db.execute(<<~SQL)
42
+ CREATE TABLE IF NOT EXISTS #{quoted_entry_table} (
43
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ aggregate_id TEXT NOT NULL,
45
+ operation TEXT NOT NULL DEFAULT 'save',
46
+ state TEXT NOT NULL,
47
+ mirrors TEXT
48
+ )
49
+ SQL
50
+ end
51
+
52
+ def ensure_entry_operation_column!
53
+ columns = @db.execute("PRAGMA table_info(#{quoted_entry_table})").map { |row| row["name"] }
54
+ return if columns.include?("operation")
55
+
56
+ @db.execute("ALTER TABLE #{quoted_entry_table} ADD COLUMN operation TEXT NOT NULL DEFAULT 'save'")
57
+ end
58
+
59
+ def ensure_entry_mirrors_column!
60
+ columns = @db.execute("PRAGMA table_info(#{quoted_entry_table})").map { |row| row["name"] }
61
+ return if columns.include?("mirrors")
62
+
63
+ @db.execute("ALTER TABLE #{quoted_entry_table} ADD COLUMN mirrors TEXT")
64
+ end
65
+
66
+ # THE OPTIONAL saga-persistence capability's own table (§2/§4) —
67
+ # shared here so `D1`, which `include`s this module verbatim for
68
+ # its own `events`-table DDL (`d1.rb`), gets this for free too.
69
+ # `domain` stays an explicit column even though SQLite has no
70
+ # schema/namespace concept the way Postgres does — matches
71
+ # `hecks_saga_instances`' own Postgres shape (§3) and covers the
72
+ # (uncommon but real) case of a domain explicitly sharing one
73
+ # `database` file/D1 database with another.
74
+ def create_saga_table!
75
+ @db.execute(<<~SQL)
76
+ CREATE TABLE IF NOT EXISTS hecks_saga_instances (
77
+ domain TEXT NOT NULL,
78
+ process_manager TEXT NOT NULL,
79
+ correlation TEXT NOT NULL,
80
+ state TEXT NOT NULL,
81
+ memory TEXT NOT NULL,
82
+ completed_compensations TEXT NOT NULL DEFAULT '[]',
83
+ PRIMARY KEY (domain, process_manager, correlation)
84
+ )
85
+ SQL
86
+ # `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
87
+ # this same domain already created before this column existed
88
+ # — the same reason Postgres's own `create_saga_table!` needs
89
+ # its own `ADD COLUMN IF NOT EXISTS`. SQLite/D1's own `ALTER
90
+ # TABLE ... ADD COLUMN` has no `IF NOT EXISTS` guard on every
91
+ # version this adapter supports, so a duplicate-column error
92
+ # is caught and treated as "already there" rather than relied
93
+ # on to never happen.
94
+ @db.execute("ALTER TABLE hecks_saga_instances ADD COLUMN completed_compensations TEXT NOT NULL DEFAULT '[]'")
95
+ rescue StandardError => e
96
+ raise unless e.message.include?("duplicate column name")
97
+ end
98
+
99
+ def sql_type(attr)
100
+ return "TEXT" if attr.list?
101
+
102
+ SQL_TYPES.fetch(attr.type, "TEXT")
103
+ end
104
+
105
+ # ── automatic indexing (plan principle 3: derived from existing
106
+ # declared `where`/`order_by` IR, no new DSL keyword — this runs
107
+ # unconditionally on every boot, `CREATE INDEX IF NOT EXISTS`,
108
+ # the same self-healing idiom `postgres_era.rb`'s own
109
+ # `ensure_head_snapshot!`/`ensure_first_head!` already use) ────
110
+
111
+ # Every field a declared query ever filters or sorts on, across
112
+ # the aggregate's OWN queries and every entity's own queries —
113
+ # `query_surfaces` in `dsl/aggregate_builder.rb` walks the
114
+ # identical pair. AN ENTITY'S QUERY STILL RUNS AGAINST THIS
115
+ # TABLE: it compiles through `query_expression`, which is
116
+ # `@aggregate`-scoped, not entity-scoped, so an entity's
117
+ # declared `where`/`order_by` is indexed here too, same as any
118
+ # other declared query — not skipped as "some other table's
119
+ # concern."
120
+ def ensure_indexes!
121
+ declared_query_fields.each { |field| ensure_index_for_field!(field) }
122
+ end
123
+
124
+ def declared_query_fields
125
+ queries = @aggregate.queries + @aggregate.entities.flat_map(&:queries)
126
+ queries.flat_map { |query| query.wheres.map(&:field) + [query.order_by&.field] }
127
+ .compact.map(&:to_s).uniq
128
+ end
129
+
130
+ # One field name resolves to exactly one of three outcomes:
131
+ #
132
+ # - a plain scalar attribute (or the lifecycle field itself)
133
+ # — a real btree index on the column, using `plain_column`'s
134
+ # own `quote_ident(name)` phrasing (reached by calling
135
+ # `query_expression` itself, not a second copy of its
136
+ # plain-vs-nested decision);
137
+ # - a non-list value-object attribute, referenced bare or
138
+ # through a member path (`field` or `field.member`) — an
139
+ # EXPRESSION index over `query_expression(field)`'s own
140
+ # `json_extract(...)` text. Reusing the query compiler's own
141
+ # expression, not re-deriving the string a second way, is
142
+ # the whole point: a textual mismatch between the index and
143
+ # what a real query compiles to is invisible to SQLite's
144
+ # planner, and two independent copies of this logic can only
145
+ # drift apart over time;
146
+ # - a list-typed attribute — NO index. SQLite's `contains`
147
+ # compiles to `EXISTS (SELECT 1 FROM json_each(col) WHERE
148
+ # ...)` (`list_contains_clause`, in `sql_query_builder.rb`)
149
+ # — an element-membership scan a plain index on the raw
150
+ # column (or even an expression index on it) does nothing to
151
+ # speed, since neither indexes the elements individually.
152
+ # SQLite has no inverted/array index short of FTS5/R-tree,
153
+ # and neither fits a `list_of` scalar or value-object field
154
+ # — well beyond this plan's scope. Left unindexed
155
+ # deliberately, not a silent gap.
156
+ #
157
+ # A field naming neither a real attribute nor the lifecycle
158
+ # field can't happen through the DSL today — `seal_query_field`
159
+ # (`dsl/aggregate_builder.rb`) already refuses it at PARSE time.
160
+ # This runs at adapter BOOT, not parse time, so it skips rather
161
+ # than crashes ugly if that invariant is ever violated.
162
+ def ensure_index_for_field!(field)
163
+ name, * = field.to_s.split(".")
164
+ attribute = @aggregate.attribute(name)
165
+ lifecycle_field = @aggregate.lifecycle&.field.to_s == name
166
+
167
+ return if !lifecycle_field && attribute.nil?
168
+ return if attribute&.list?
169
+
170
+ expression = query_expression(field)
171
+ @db.execute(
172
+ "CREATE INDEX IF NOT EXISTS #{quote_ident(index_name(field))} ON #{quoted_table}(#{expression})"
173
+ )
174
+ end
175
+
176
+ # `idx_<table>_<sanitized field>` — TABLE-PREFIXED because
177
+ # SQLite index names are GLOBAL to the database, not scoped per
178
+ # table the way a column name is; two different aggregates each
179
+ # indexing a field called "name" would collide without it. The
180
+ # field itself is sanitized (a dotted path's "." in particular)
181
+ # to stay a valid identifier while remaining visibly tied to
182
+ # what it indexes — collision-free across every field/path this
183
+ # aggregate declares, since `declared_query_fields` already
184
+ # de-duplicates the field strings themselves.
185
+ def index_name(field)
186
+ sanitized = field.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
187
+ "idx_#{table}_#{sanitized}"
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,9 @@
1
+ Hecks.adapter "SqlitePersistence" do
2
+ port "persistence"
3
+ field :database
4
+ end
5
+
6
+ Hecks.adapter "SqliteProjection" do
7
+ port "projection"
8
+ field :database
9
+ end