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,758 @@
1
+ require "json"
2
+
3
+ require_relative "../../../../adapters/driven/sql_query_builder"
4
+ require_relative "postgres_era/lineage"
5
+ require_relative "postgres_era/lineage_manager"
6
+ require_relative "../../../../ports/persistence/append_only"
7
+ require_relative "../../../../query_specification/common/order_by"
8
+ require_relative "../../../../runtime/errors"
9
+ require_relative "../../../../runtime/event"
10
+ require_relative "../../../../runtime/instance"
11
+ require_relative "../../../../runtime/registry"
12
+
13
+ module Hecks
14
+ module Adapters
15
+ # The enforcement-grade persistence adapter — and the only one that
16
+ # declares the LINEAGE capability: it may act on shape drift
17
+ # (translate, fork, merge) where every other adapter can only refuse
18
+ # toward it. Sibling to the plain `Postgres` adapter (postgres.rb),
19
+ # which is the same database with none of this machinery — pick
20
+ # PostgresEra only once a domain actually needs to survive a shape
21
+ # change live. See docs/implemented/postgres-era-adapter-split-plan.md for why
22
+ # the two are split and what each one carries.
23
+ #
24
+ # Storage model (see postgres_era/lineage.rb for the DDL):
25
+ # - One journal per DOMAIN, list-partitioned by era, one ordinal
26
+ # sequence spanning partitions. Appends go there; nothing updates
27
+ # or deletes a journal row (immutability by privilege — UPDATE and
28
+ # DELETE revoked; a deployment's app role connects as a non-owner).
29
+ # - Per aggregate, the HEAD is derived: era 1 reads a plain view
30
+ # (latest save per id); later eras read a view overlaying the
31
+ # materialized, translated ancestor tail with live current-era
32
+ # rows. `project` is therefore a no-op — old entries are never
33
+ # rewritten, and the head is never a table anything writes.
34
+ # - State is ONE jsonb column. jsonb normalizes key order (and drops
35
+ # duplicate keys), so anything comparing stored state — the corpus
36
+ # history gate above all — must compare CANONICALIZED state, never
37
+ # raw bytes; `bin/canonicalise` deep-sorts keys, which is exactly
38
+ # why the gate survives this normalization.
39
+ # - Query pushdown is the shared SqlQueryBuilder: every declared
40
+ # operator compiles fully into SQL, or the query refuses loudly.
41
+ class PostgresEra
42
+ include SqlQueryBuilder
43
+
44
+ attr_reader :aggregate
45
+
46
+ def persistence_capabilities = [:atomic_put]
47
+
48
+ # The capability idiom: only PostgresEra answers true, and only
49
+ # PostgresEra carries an era_check! for the boot gate to delegate to.
50
+ def self.lineage_capable? = true
51
+
52
+ # TENANT-CAPABLE — see Runtime::TenantCheck's own header for the
53
+ # full reasoning. `connect_for`'s own `schema:` setting (the
54
+ # Storehouse shared-instance mechanism, already built, already
55
+ # proven for eras) is what keeps two tenant boots' tables apart:
56
+ # each boot's own `SET search_path` means every unqualified
57
+ # reference this adapter and its lineage classes construct
58
+ # resolves into that boot's own schema, never another tenant's —
59
+ # proven for real, not assumed, by tenant_isolation_spec.rb, the
60
+ # same discipline lineage_capable? already holds itself to.
61
+ def self.tenant_capable? = true
62
+
63
+ def self.era_check!(registry:, bluebook:, current_text:, settings:, directory: nil)
64
+ LineageManager.check!(
65
+ registry: registry, bluebook: bluebook, current_text: current_text,
66
+ settings: settings, directory: directory
67
+ )
68
+ end
69
+
70
+ # BOTH SPELLINGS OF A SETTING ARE HONORED — a world's settings hash
71
+ # may arrive symbol-keyed (built straight in Ruby) or string-keyed
72
+ # (round-tripped through JSON), and a domain that names one is not
73
+ # obligated to also skip the other. `key?` decides which spelling
74
+ # actually exists, never `||` — `||` cannot tell a genuinely stored
75
+ # `false` apart from an absent key, and would silently prefer the
76
+ # OTHER spelling (or `default`) instead of returning the real, held
77
+ # answer. See Hecks::QuerySpecification::FieldPath#read for the same
78
+ # discipline applied to stored state instead of settings.
79
+ def self.setting(settings, key, default: nil)
80
+ return settings[key] if settings.key?(key)
81
+
82
+ str_key = key.to_s
83
+ return settings[str_key] if settings.key?(str_key)
84
+
85
+ default
86
+ end
87
+
88
+ def self.connect_for(name, settings)
89
+ # LAZY, ON PURPOSE — same reasoning as Sqlite's own initialize:
90
+ # a domain that never wires PostgresEra should never need the gem.
91
+ require "pg"
92
+
93
+ declared = setting(settings, :database)
94
+ if declared.to_s.empty?
95
+ raise Runtime::WiringError,
96
+ "#{name} binds PostgresEra, which needs a database connection, " \
97
+ "but its world declares no \"database\"."
98
+ end
99
+
100
+ connection =
101
+ if declared.start_with?("postgres://", "postgresql://")
102
+ PG.connect(declared)
103
+ else
104
+ PG.connect(dbname: declared)
105
+ end
106
+
107
+ # SHARED-INSTANCE ISOLATION. A domain that declares `schema` is
108
+ # sharing its Postgres instance with other domains (the
109
+ # storehouse) — every unqualified table/view/function reference
110
+ # this adapter and its lineage classes ever construct resolves
111
+ # through search_path, so this one SET is what makes ALTER
112
+ # TABLE ... SET SCHEMA migrations transparent to the rest of the
113
+ # adapter. A domain with no `schema` setting keeps Postgres's
114
+ # own default search_path (public), same as before this existed.
115
+ schema = setting(settings, :schema)
116
+ if schema.to_s != ""
117
+ # THE SCHEMA ITSELF, IDEMPOTENTLY — a domain naming a `schema:`
118
+ # nobody has created yet used to fail on its FIRST table-
119
+ # creation attempt with Postgres's own "no schema has been
120
+ # selected to create in", found live provisioning tenant_
121
+ # isolation_spec.rb's own multi-schema fixture by hand before
122
+ # this existed. `CREATE SCHEMA IF NOT EXISTS` is exactly the
123
+ # same self-healing idempotency this adapter's own table/era
124
+ # provisioning already holds itself to (see the comment right
125
+ # below on `client_min_messages`) — a schema that already
126
+ # exists is the ORDINARY case for every boot after the first,
127
+ # not news.
128
+ connection.exec("CREATE SCHEMA IF NOT EXISTS #{connection.quote_ident(schema)}")
129
+ connection.exec("SET search_path TO #{connection.quote_ident(schema)}")
130
+ end
131
+
132
+ # QUIET ON PURPOSE. Provisioning re-runs its own idempotent
133
+ # `CREATE ... IF NOT EXISTS` checks on every boot — a schema that
134
+ # already exists is the ORDINARY case, not news, and Postgres
135
+ # surfaces every one as a NOTICE by default. `bin/set-password`
136
+ # boots a real registry just to mint an Identity, and nobody
137
+ # setting a password needs to see a page of "relation ...
138
+ # already exists, skipping" to do it. WARNING and above (real
139
+ # problems) still surface.
140
+ connection.exec("SET client_min_messages = warning")
141
+ connection
142
+ rescue PG::Error => error
143
+ raise Runtime::WiringError,
144
+ "cannot bind PostgresEra at #{declared} for #{name}: #{error.message.strip}"
145
+ end
146
+
147
+ def initialize(aggregate:, settings: {}, root: nil)
148
+ @aggregate = aggregate
149
+ @db = self.class.connect_for(aggregate.name, settings)
150
+ # The domain names the journal (one journal per lineage). The
151
+ # factory injects it; a directly-instantiated adapter (specs,
152
+ # consoles) journals under the aggregate's own name.
153
+ @domain = self.class.setting(settings, :domain, default: aggregate.storage_name).to_s
154
+ @lineage = Lineage.new(@db, @domain)
155
+ @lineage.ensure_base!
156
+ # The era gate resolves which era this boot IS (an old checkout
157
+ # boots a held-but-superseded era and keeps writing its own
158
+ # partition); a directly-instantiated adapter defaults to the
159
+ # newest.
160
+ #
161
+ # NOT `self.class.setting(...)` here — RepositoryFactory#build
162
+ # always merges `era: registry.resolved_eras[domain]` into
163
+ # settings, so the key is genuinely PRESENT (not absent) for
164
+ # any domain the era boot gate hasn't resolved yet (or that
165
+ # doesn't have one at all), just holding `nil`. `setting`'s own
166
+ # presence-over-truthiness discipline (correct for a field like
167
+ # `:role`, where a stored `false` is a real, distinct answer
168
+ # from "unset") does the wrong thing for `:era` specifically:
169
+ # `nil` can never be a meaningful era override — only a real
170
+ # ordinal or "not resolved, self-resolve" ever apply — so
171
+ # coalescing here, not `setting`'s presence check, is what
172
+ # actually honors this comment's own "defaults to the newest"
173
+ # promise.
174
+ @era = settings.key?(:era) ? settings[:era] : settings["era"]
175
+ @era ||= @lineage.current_era
176
+ # Unconditional and idempotent, regardless of era — belt-and-
177
+ # suspenders self-healing (compile_head! already ensures this for
178
+ # a freshly-minted era's own name; ensure_first_head! for era 1's)
179
+ # against any boot-ordering surprise, at the cost of one
180
+ # CREATE TABLE IF NOT EXISTS nobody pays for twice.
181
+ @lineage.ensure_head_snapshot!(table, @era)
182
+ @lineage.ensure_first_head!(table) if @era == 1
183
+ # THE READ-CACHE SIDE OF THE ERA WORKAROUND (Track C,
184
+ # docs/implemented/postgres-era-adapter-split-plan.md §3) — one row-cache
185
+ # table per `where`-field this aggregate's own declared queries
186
+ # (and its entities' own) actually use, derived automatically
187
+ # (principle 3 — no bluebook keyword), self-healing and
188
+ # idempotent like everything else booted here. `@field_caches`
189
+ # maps field -> cache-table name; `query` below consults it to
190
+ # decide whether a declared query can skip the DISTINCT ON
191
+ # reduction entirely.
192
+ @field_caches = ensure_field_caches!
193
+ create_event_table!
194
+ create_saga_table!
195
+ end
196
+
197
+ def table = @aggregate.storage_name
198
+
199
+ def find(id)
200
+ result = @db.exec_params(%(SELECT id, state FROM #{quoted_head} WHERE id = $1), [id.to_s])
201
+ return nil if result.ntuples.zero?
202
+
203
+ instance(result[0])
204
+ end
205
+
206
+ # order_by IS A RUNTIME VALUE, not framework-authored bluebook source
207
+ # like every other caller of order_expression — a query param off an
208
+ # HTTP request, in the console's case. Whitelisted against the
209
+ # aggregate's own real attributes (plus its lifecycle field) before
210
+ # it ever reaches order_expression, unlike a declared query's
211
+ # order_by, which the language itself already only lets name a real
212
+ # attribute at parse time. Without this, an unknown field wouldn't
213
+ # error — query_expression degrades a nil attribute to a harmless
214
+ # no-op path — it would just silently sort by nothing.
215
+ def all(order_by: nil, direction: :asc)
216
+ return @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY id)).map { |row| instance(row) } unless order_by
217
+
218
+ name = order_by.to_s.split(".").first
219
+ raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
220
+
221
+ spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
222
+ @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY #{order_clause(spec, nil)})).map { |row| instance(row) }
223
+ end
224
+
225
+ def count = @db.exec(%(SELECT COUNT(*) FROM #{quoted_head}))[0]["count"].to_i
226
+
227
+ # THE TWO-PHASE SHORTCUT (Track C, docs/implemented/postgres-era-adapter-
228
+ # split-plan.md §3). `SqlQueryBuilder#query` (`super`, unmodified per
229
+ # principle 2) always runs correctly here — it filters against
230
+ # `head_view`, which is already the fully-reduced current state —
231
+ # but for a domain that has minted a second era, that reduction
232
+ # itself is the expensive part, and no index on the jsonb path
233
+ # changes that (see field_cache.rb's own header for the SQL-
234
+ # semantics reason why). When every `where` clause this query
235
+ # declares either targets a cached field or is eligible to (an
236
+ # ordinary comparator, not a null-vs-value special case — see
237
+ # `cache_eligible?`), skip the reduction: look candidate ids up in
238
+ # the cache table(s) first (cheap, indexed, no reduction involved),
239
+ # then read ONLY those ids' current state from `head_view` — safe
240
+ # THROUGH the reduction because `id` is its own partition key. Any
241
+ # clause that ISN'T cache-eligible (an uncached field, or a null
242
+ # comparison) is simply re-checked against `head_view` in the
243
+ # second phase, exactly as `super` would have checked it anyway —
244
+ # this can only ever NARROW what phase two has to look at, never
245
+ # change what a clause means.
246
+ #
247
+ # FALLS BACK TO `super` WHENEVER NO CLAUSE CAN BE ACCELERATED — a
248
+ # query with no `where` at all (order_by-only — no cache table
249
+ # exists for these, see field_cache.rb), a query whose only clauses
250
+ # target fields with no cache table, or a domain that has never
251
+ # minted a second era at all (`@field_caches` is never empty just
252
+ # because era 1 has no reduction to skip — the cache tables still
253
+ # exist and still accelerate era 1 the same way, but the fallback
254
+ # path is already just as cheap there since head_view IS the
255
+ # snapshot table verbatim for era 1; skipping straight to `super`
256
+ # in that case would be a valid FUTURE optimization, not attempted
257
+ # here to keep this one code path correct for every era uniformly).
258
+ def query(declared, args = {}, context: {})
259
+ return super if @field_caches.empty? || declared.wheres.empty?
260
+
261
+ evaluated = declared.wheres.map { |clause| [clause, query_value(clause.value, args)] }
262
+ cached, uncached = evaluated.partition { |clause, value| cache_eligible?(clause, value) }
263
+ return super if cached.empty?
264
+
265
+ ids = cache_phase(cached)
266
+ return [] if ids.empty?
267
+
268
+ head_phase(declared, uncached, ids, args)
269
+ end
270
+
271
+ # HELD FOR THE WHOLE TRANSACTION, not just around the INSERT — the
272
+ # ordinal is assigned by the column's own `nextval()` default, inside
273
+ # this same statement, so the lock has to already be held before that
274
+ # default evaluates. A DIFFERENT key from `mint_era!`/`merge_tail!`'s
275
+ # `hecks_eras:domain` : this serializes plain writes against EACH
276
+ # OTHER, never against a mint. See postgres/lineage.rb's own comment
277
+ # for why only that half of the race is closed.
278
+ # The journal insert and the snapshot upsert/delete happen in the
279
+ # SAME transaction — real ACID atomicity, not the append-then-
280
+ # project two-step a file-based adapter needs a crash-recovery
281
+ # replay for (see Heki). If this transaction commits, the snapshot
282
+ # is already exactly as current as the journal; if it doesn't,
283
+ # neither happened. `project` stays uninvolved on purpose — it
284
+ # still runs, cheaply, during AppendOnly#recover!'s full replay on
285
+ # every boot (see `project` below), and a second write there would
286
+ # make that replay pay real DB cost for a snapshot that's already
287
+ # correct.
288
+ def append(entry)
289
+ @db.transaction do
290
+ lock_writes!
291
+ append_and_project!(entry)
292
+ end
293
+ entry
294
+ end
295
+
296
+ # Outcome detection, journal append and every derived projection share
297
+ # the SAME transaction and domain write lock. The lineage-aware head
298
+ # determines whether this id is already visible; no repository `find`
299
+ # occurs before entering this adapter-native operation.
300
+ def atomic_put(entry, insert_only: false)
301
+ status = nil
302
+ @db.transaction do
303
+ lock_writes!
304
+ exists = !@db.exec_params(
305
+ "SELECT 1 FROM #{quoted_head} WHERE id = $1 LIMIT 1",
306
+ [entry.id.to_s]
307
+ ).ntuples.zero?
308
+ if insert_only && exists
309
+ status = :conflicted
310
+ next
311
+ end
312
+ status = exists ? :replaced : :inserted
313
+ append_and_project!(entry)
314
+ end
315
+ status
316
+ end
317
+
318
+ # The head is DERIVED — projecting is reading, so there is nothing
319
+ # to write here. `append` above already keeps the snapshot the head
320
+ # view reads from current, transactionally. The instance is still
321
+ # built (and validated) so a save returns what every other adapter
322
+ # returns.
323
+ def project(entry)
324
+ return if entry.delete?
325
+
326
+ Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
327
+ end
328
+
329
+ def entries
330
+ @db.exec_params(
331
+ "SELECT aggregate_id, operation, state, mirrors FROM #{@lineage.quoted_journal} " \
332
+ "WHERE aggregate = $1 ORDER BY ordinal",
333
+ [table]
334
+ ).map do |row|
335
+ state = row["state"] && JSON.parse(row["state"])
336
+ Ports::Persistence::Entry.new(
337
+ operation: row["operation"] || "save",
338
+ id: row["aggregate_id"],
339
+ state: state&.transform_keys(&:to_sym),
340
+ mirrors: row["mirrors"] && JSON.parse(row["mirrors"])
341
+ )
342
+ end
343
+ end
344
+
345
+ # The journal carries FORCE ROW LEVEL SECURITY with exactly two
346
+ # policies — hecks_current_era's INSERT and hecks_read_all's
347
+ # SELECT (advance_era! above) — and no DELETE policy at all, for
348
+ # anyone. FORCE means even the table's own owner is fenced by
349
+ # that (only an actual Postgres superuser or a role granted
350
+ # BYPASSRLS sits above it — see lineage.rb's own header), so a
351
+ # plain `DELETE ... WHERE aggregate = $1` from an ordinary
352
+ # connection silently matches zero rows: no privilege error, no
353
+ # exception, just a no-op that looks like success. Counting
354
+ # before and comparing to what the DELETE itself reports is what
355
+ # tells "nothing to delete" apart from "RLS silently ate the
356
+ # delete" — the same row count, from the same statement, either
357
+ # way, with no separate query racing the DELETE for an answer.
358
+ def reset!
359
+ before = @db.exec_params(
360
+ "SELECT count(*) FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table]
361
+ )[0]["count"].to_i
362
+ result = @db.exec_params("DELETE FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table])
363
+ if before.positive? && result.cmd_tuples.zero?
364
+ raise Runtime::WiringError,
365
+ "reset! deleted 0 of #{before} row(s) for #{table} in #{@lineage.quoted_journal} — " \
366
+ "FORCE ROW LEVEL SECURITY admits no DELETE policy on the journal, so this connection's " \
367
+ "DELETE silently matched nothing. reset! only works connected as an actual Postgres " \
368
+ "superuser or a role granted BYPASSRLS, not as the provisioner or an app role."
369
+ end
370
+ self
371
+ end
372
+
373
+ def save(instance)
374
+ entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
375
+ append(entry)
376
+ project(entry)
377
+ end
378
+
379
+ def delete(id)
380
+ entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
381
+ append(entry)
382
+ true
383
+ end
384
+
385
+ def record_event(event)
386
+ @db.exec_params(
387
+ "INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
388
+ [event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at]
389
+ )
390
+ end
391
+
392
+ def events
393
+ @db.exec("SELECT * FROM events ORDER BY id").map do |row|
394
+ Runtime::Event.new(
395
+ name: row["name"],
396
+ aggregate: row["aggregate"],
397
+ id: row["aggregate_id"],
398
+ payload: JSON.parse(row["payload"], symbolize_names: true),
399
+ occurred_at: row["occurred_at"]
400
+ )
401
+ end
402
+ end
403
+
404
+ # ── the OPTIONAL saga-persistence capability (Ports::Persistence's
405
+ # own three-method shape, §2) — one row per (domain, process_manager,
406
+ # correlation), `domain` kept as an explicit column even under
407
+ # schema isolation so two domains sharing one schema (neither
408
+ # declares its own `schema`) still isolate correctly, matching
409
+ # `hecks_eras`' own precedent (postgres/lineage/provisioning.rb).
410
+ # No advisory lock of its own: every call here already runs inside
411
+ # `SagaInterpreter`'s own mutex (§7) serializing IN-PROCESS writers,
412
+ # and gets the SAME cross-process safety an aggregate's own writes
413
+ # get from this adapter — no better, no worse.
414
+ def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
415
+ @db.exec_params(
416
+ "INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
417
+ "VALUES ($1, $2, $3, $4, $5, $6) " \
418
+ "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
419
+ "SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
420
+ "completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
421
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
422
+ )
423
+ end
424
+
425
+ def delete_saga(process_manager:, correlation:)
426
+ @db.exec_params(
427
+ "DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
428
+ [@domain, process_manager.to_s, correlation.to_s]
429
+ )
430
+ end
431
+
432
+ def each_saga
433
+ return enum_for(:each_saga) unless block_given?
434
+
435
+ @db.exec_params(
436
+ "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = $1",
437
+ [@domain]
438
+ ).each do |row|
439
+ yield row["process_manager"], row["correlation"], row["state"],
440
+ JSON.parse(row["memory"], symbolize_names: true),
441
+ JSON.parse(row["completed_compensations"] || "[]", symbolize_names: true)
442
+ end
443
+ end
444
+
445
+ private
446
+
447
+ def lock_writes!
448
+ @db.exec_params(
449
+ "SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))",
450
+ [@lineage.domain]
451
+ )
452
+ end
453
+
454
+ def append_and_project!(entry)
455
+ state_json = entry.state && JSON.generate(entry.state)
456
+ ordinal = @db.exec_params(
457
+ "INSERT INTO #{@lineage.quoted_journal} (era, aggregate, aggregate_id, operation, state, mirrors) " \
458
+ "VALUES ($1, $2, $3, $4, $5, $6) RETURNING ordinal",
459
+ [@era, table, entry.id, entry.operation, state_json,
460
+ entry.mirrors && JSON.generate(entry.mirrors)]
461
+ )[0]["ordinal"]
462
+
463
+ if entry.save?
464
+ @db.exec_params(
465
+ "INSERT INTO #{quoted_head_snapshot} (id, ordinal, operation, state) VALUES ($1, $2, 'save', $3) " \
466
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
467
+ "state = EXCLUDED.state WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
468
+ [entry.id, ordinal, state_json]
469
+ )
470
+ # SAME TRANSACTION, SAME ORDINAL — every field cache stays
471
+ # exactly as current as the snapshot it's derived from, for
472
+ # the identical reason `postgres_era.rb`'s own header comment
473
+ # gives for the journal/snapshot pair: if this transaction
474
+ # commits, every cache row is already correct; if it doesn't,
475
+ # none of them changed.
476
+ @field_caches.each do |field, cache_table|
477
+ @lineage.upsert_field_cache_row!(cache_table, entry.id, ordinal, state_json, query_expression(field))
478
+ end
479
+ else
480
+ # A TOMBSTONE ROW, NOT A BARE DELETE — H3 (docs/audits/2026-08-
481
+ # 10-main-bug-audit.md). `DELETE FROM head_snapshot` used to be
482
+ # the whole story here, which is correct in isolation but wrong
483
+ # once an ancestor era is in the picture: for a record carried
484
+ # into this era from an ancestor, removing this era's row left
485
+ # NOTHING on the current-era side of `compile_head!`'s union to
486
+ # outrank the ancestor matview's own (still-present, still
487
+ # `save`) row, so `DISTINCT ON` picked the ancestor's row and
488
+ # the "deleted" record kept reading back forever. Upserting a
489
+ # tombstone (`operation = 'delete'`, `state` NULL) instead
490
+ # means this era always has ITS OWN newest-ordinal row for the
491
+ # id, exactly like a real re-save already did ("re-saves are
492
+ # masked correctly" — the audit's own phrasing for why that
493
+ # half of this was never broken) — it just carries `operation
494
+ # = 'delete'` instead of `'save'`, so `head_view`'s own `WHERE
495
+ # operation = 'save'` still correctly hides it. Ordinal-guarded
496
+ # the same as every other upsert here, so an out-of-order
497
+ # replay can never let a stale delete clobber a newer save.
498
+ @db.exec_params(
499
+ "INSERT INTO #{quoted_head_snapshot} (id, ordinal, operation, state) VALUES ($1, $2, 'delete', NULL) " \
500
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
501
+ "state = EXCLUDED.state WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
502
+ [entry.id, ordinal]
503
+ )
504
+ @field_caches.each_value { |cache_table| @lineage.delete_field_cache_row!(cache_table, entry.id) }
505
+ end
506
+
507
+ ordinal
508
+ end
509
+
510
+ # ── SqlQueryBuilder's dialect hooks ─────────────────────────────
511
+
512
+ def select_list = "id, state"
513
+ def from_relation = quoted_head
514
+ def dialect_name = "PostgresEra"
515
+ def empty_in_clause = "FALSE"
516
+
517
+ def placeholder(binds, value)
518
+ binds << value
519
+ "$#{binds.size}"
520
+ end
521
+
522
+ def contains_clause(expression, placeholder)
523
+ "position(#{placeholder} in #{expression}) > 0"
524
+ end
525
+
526
+ def list_contains_clause(column, member, placeholder)
527
+ target = member.empty? ? "elem #>> '{}'" : "elem ->> #{text_literal(member)}"
528
+ elements = "jsonb_array_elements(state #> ARRAY[#{text_literal(column)}]::text[]) AS elem"
529
+ "EXISTS (SELECT 1 FROM #{elements} WHERE #{target} = #{placeholder})"
530
+ end
531
+
532
+ def plain_column(name) = jsonb_path([name])
533
+
534
+ def nested_expression(name, path, member)
535
+ segments = path.empty? ? [name, (member || "value").to_s] : [name, *path]
536
+ jsonb_path(segments)
537
+ end
538
+
539
+ def comparable_expression(expression, value)
540
+ value.is_a?(Numeric) ? "(#{expression})::numeric" : expression
541
+ end
542
+
543
+ def execute_query(sql, binds)
544
+ @db.exec_params(sql, binds).map { |row| instance(row) }
545
+ end
546
+
547
+ # ── the rest of the dialect ─────────────────────────────────────
548
+
549
+ def instance(row)
550
+ Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row["state"]))
551
+ end
552
+
553
+ def decode(state_json)
554
+ # Deep symbols, exactly what the Sqlite adapter's per-column
555
+ # `symbolize_names:` decode produces — value-object members and
556
+ # list elements arrive symbol-keyed either way.
557
+ JSON.parse(state_json, symbolize_names: true)
558
+ end
559
+
560
+ def quote_ident(name) = PG::Connection.quote_ident(name.to_s)
561
+ def quoted_head = quote_ident(@lineage.head_view(table))
562
+ def quoted_head_snapshot = quote_ident(@lineage.head_snapshot(table, @era))
563
+
564
+ def order_expression(field)
565
+ expression = query_expression(field)
566
+ numeric_field?(field) ? "(#{expression})::numeric" : expression
567
+ end
568
+
569
+ # Postgres defaults to NULLS LAST on ASC; the port's in-memory
570
+ # semantics (NullPolicy.order, which SQLite's own default happens
571
+ # to match) put null rows FIRST ascending and LAST
572
+ # descending. Compile the placement explicitly so a declared query
573
+ # answers identically no matter which adapter serves it.
574
+ def order_clause(order_by, policy)
575
+ direction = order_by.direction.to_s.downcase == "desc" ? "DESC" : "ASC"
576
+ nulls = case policy&.mode.to_s
577
+ when "first" then " NULLS FIRST"
578
+ when "last" then " NULLS LAST"
579
+ else direction == "DESC" ? " NULLS LAST" : " NULLS FIRST"
580
+ end
581
+ "#{order_expression(order_by.field)} #{direction}#{nulls}, id #{direction}"
582
+ end
583
+
584
+ # One shared walk decides numericness at ANY depth — this used to
585
+ # inspect only the first nested segment, so a two-level path
586
+ # (pizza.price_cents.cents) skipped the ::numeric cast and ordered
587
+ # as text: "900" above "1200".
588
+ def numeric_field?(field)
589
+ name, *path = field.to_s.split(".")
590
+ QuerySpecification::FieldPath.numeric?(@aggregate.attribute(name), path) do |type|
591
+ @aggregate.value_object(type)
592
+ end
593
+ end
594
+
595
+ # ARRAY[...] of individually-escaped literals, never the hand-rolled
596
+ # '{a,b,c}' array-literal SYNTAX — a segment is a field or
597
+ # value-object member name, and while today's callers only ever
598
+ # pass schema-declared names, this method has no way to know
599
+ # that, and the '{...}' form has no escaping at all: a segment
600
+ # containing a single quote closes the string early and whatever
601
+ # follows becomes live SQL. Measured, not assumed — a crafted
602
+ # field name of `x}' = '' OR $1::text = $1::text -- ` made a
603
+ # `where(secret: "public")` clause return every row regardless,
604
+ # against the OLD form; the ARRAY[] form below closes it, verified
605
+ # against the identical payload.
606
+ def jsonb_path(segments)
607
+ "state #>> ARRAY[#{segments.map { |segment| text_literal(segment) }.join(', ')}]::text[]"
608
+ end
609
+
610
+ def text_literal(text) = "'#{text.to_s.gsub("'", "''")}'"
611
+
612
+ # ── the field-cache read shortcut (Track C) ─────────────────────
613
+
614
+ # EVERY declared `where`-field this aggregate's own queries and its
615
+ # entities' own queries use, minus anything a cache table can't
616
+ # represent (see `cacheable_field?`) — never `order_by`-only
617
+ # fields, which never needed a cache in the first place (sorting
618
+ # the reduced output was never blocked by the reduction; only
619
+ # filtering was — see field_cache.rb's own header).
620
+ def ensure_field_caches!
621
+ cached_where_fields.to_h do |field|
622
+ [field, @lineage.ensure_field_cache!(table, @era, field, query_expression(field))]
623
+ end
624
+ end
625
+
626
+ def cached_where_fields
627
+ declared_queries.flat_map { |q|
628
+ q.wheres.map { |clause|
629
+ clause.field.to_s
630
+ }
631
+ }.uniq.select { |field| cacheable_field?(field) }
632
+ end
633
+
634
+ def declared_queries
635
+ @aggregate.queries + @aggregate.entities.flat_map(&:queries)
636
+ end
637
+
638
+ # LIST-TYPED FIELDS ARE EXCLUDED, same boundary the plain `Postgres`
639
+ # and `Sqlite`/`D1` adapters independently landed on for their own
640
+ # automatic indexing: `contains` means element membership, and a
641
+ # (id, ordinal, ONE value) cache row has nowhere to put more than
642
+ # one element. Everything else — a plain scalar, the lifecycle
643
+ # field, or a non-list value-object member path — reduces to
644
+ # exactly one comparable value per id, which is the one shape this
645
+ # cache table represents.
646
+ def cacheable_field?(field)
647
+ name = field.to_s.split(".").first
648
+ return true if @aggregate.lifecycle&.field.to_s == name
649
+
650
+ attribute = @aggregate.attribute(name)
651
+ !attribute.nil? && !attribute.list?
652
+ end
653
+
654
+ # A clause can be served by the cache when its field has a cache
655
+ # table AND it isn't the null-vs-value special case
656
+ # `QuerySpecification::Common::NullPolicy` intercepts before
657
+ # `where_clause` ever runs (see `query`'s own header comment) — a
658
+ # clause that fails either check simply flows to `head_phase`
659
+ # unaccelerated, exactly as `super` would have evaluated it.
660
+ def cache_eligible?(clause, value)
661
+ @field_caches.key?(clause.field.to_s) &&
662
+ QuerySpecification::Common::NullPolicy.sql_predicate(query_expression(clause.field, value: value), clause.op,
663
+ value).nil?
664
+ end
665
+
666
+ # PHASE ONE — candidate ids, no reduction touched. One SELECT per
667
+ # cached clause against its own narrow (id, ordinal, value) table,
668
+ # `INTERSECT`ed into the set that satisfies every cached clause at
669
+ # once. Reuses `where_clause` (SqlQueryBuilder, private, already
670
+ # mixed into this class) UNCHANGED against the cache table's own
671
+ # `value` column instead of a jsonb path expression — the exact
672
+ # same operator compilation (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`in`)
673
+ # a live query already gets against the real column, so a cached
674
+ # field supports every comparator `super` would have, not just the
675
+ # `eq` example in the plan doc's own illustration.
676
+ def cache_phase(cached)
677
+ binds = []
678
+ clauses = cached.map do |clause, value|
679
+ cache_table = @lineage.field_cache(table, @era, clause.field.to_s)
680
+ "SELECT id FROM #{quote_ident(cache_table)} WHERE #{where_clause(clause.op.to_s, quote_ident('value'), value, binds,
681
+ field: clause.field)}"
682
+ end
683
+ @db.exec_params(clauses.join("\nINTERSECT\n"), binds).map { |row| row["id"] }
684
+ end
685
+
686
+ # PHASE TWO — `head_view`, restricted to phase one's candidate ids
687
+ # PLUS whatever clauses phase one couldn't accelerate, applied
688
+ # exactly the way `SqlQueryBuilder#query` (`super`) already applies
689
+ # every clause today: against the fully-reduced view, which is
690
+ # already correct regardless of caching (see this file's own
691
+ # `query` comment — a cache is a SPEED shortcut, never a
692
+ # correctness fix; head_view was always safe to filter directly,
693
+ # just expensive to reduce in the first place). Duplicates a small
694
+ # slice of `SqlQueryBuilder#query`'s own tail assembly (order_by/
695
+ # limit/offset) rather than reaching into it — principle 2
696
+ # (docs/implemented/postgres-era-adapter-split-plan.md): SqlQueryBuilder stays
697
+ # untouched by the era workaround, so this stays local to
698
+ # PostgresEra rather than growing a shared hook only one adapter
699
+ # would ever call.
700
+ def head_phase(declared, uncached, ids, args)
701
+ binds = []
702
+ clauses = ["id IN (#{ids.map { |id| placeholder(binds, id) }.join(', ')})"]
703
+ uncached.each do |clause, value|
704
+ expression = query_expression(clause.field, value: value)
705
+ if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
706
+ clauses << null_predicate.first
707
+ next
708
+ end
709
+ clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
710
+ end
711
+
712
+ sql = +"SELECT #{select_list} FROM #{from_relation} WHERE #{clauses.join(' AND ')}"
713
+ sql << if declared.order_by
714
+ " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}"
715
+ else
716
+ " ORDER BY id"
717
+ end
718
+ sql << " LIMIT #{placeholder(binds, query_value(declared.limit.value, args).to_i)}" if declared.limit
719
+ sql << unbounded_limit if !declared.limit && declared.offset
720
+ sql << " OFFSET #{placeholder(binds, query_value(declared.offset.value, args).to_i)}" if declared.offset
721
+ execute_query(sql, binds)
722
+ end
723
+
724
+ def create_event_table!
725
+ @db.exec(<<~SQL)
726
+ CREATE TABLE IF NOT EXISTS events (
727
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
728
+ name text NOT NULL,
729
+ aggregate text NOT NULL,
730
+ aggregate_id text NOT NULL,
731
+ payload jsonb,
732
+ occurred_at text
733
+ )
734
+ SQL
735
+ end
736
+
737
+ def create_saga_table!
738
+ @db.exec(<<~SQL)
739
+ CREATE TABLE IF NOT EXISTS hecks_saga_instances (
740
+ domain text NOT NULL,
741
+ process_manager text NOT NULL,
742
+ correlation text NOT NULL,
743
+ state text NOT NULL,
744
+ memory jsonb NOT NULL,
745
+ completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb,
746
+ updated_at timestamptz NOT NULL DEFAULT now(),
747
+ PRIMARY KEY (domain, process_manager, correlation)
748
+ )
749
+ SQL
750
+ # `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
751
+ # this same domain already created before this column existed —
752
+ # the same idiom `rust/host/src/journal.rs`'s own
753
+ # `sagas_backfilled` column addition already uses.
754
+ @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb")
755
+ end
756
+ end
757
+ end
758
+ end