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,314 @@
1
+ module Hecks
2
+ module Adapters
3
+ class PostgresEra
4
+ class Lineage
5
+ module Provisioning
6
+ # Provisioning is the OWNER's job, and a deployment's app role is
7
+ # deliberately not the owner — it may append and read, and it
8
+ # owns nothing. By the time such a role connects, the base is
9
+ # already built, so its boot verifies rather than builds.
10
+ #
11
+ # Without this guard the per-era fence below is unreachable: the
12
+ # ALTER TABLE and REVOKE here are owner-only, so the very role
13
+ # grant_era! exists to constrain could never finish booting
14
+ # ("must be owner of table hecks_eras"). A fence nothing can
15
+ # reach is not a fence.
16
+ def ensure_base!
17
+ rename_domain! if @formerly_known_as
18
+ return unless provisioner?
19
+
20
+ @db.exec(<<~SQL)
21
+ CREATE TABLE IF NOT EXISTS hecks_eras (
22
+ domain text NOT NULL,
23
+ ordinal int NOT NULL,
24
+ hash text,
25
+ label text,
26
+ held_text text NOT NULL,
27
+ watermark bigint,
28
+ PRIMARY KEY (domain, ordinal)
29
+ )
30
+ SQL
31
+ @db.exec("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_digest text")
32
+ @db.exec("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_projection jsonb")
33
+ # which canonical-form version minted this name — see
34
+ # Runtime::StorageShape::FORM_VERSION; rows minted before the
35
+ # column carry NULL, read as an implicit 1
36
+ @db.exec("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS canon_form int")
37
+ # every frozen text version, archived where an edit cannot
38
+ # reach it — the recovery the hard reattest refusal points at
39
+ @db.exec(<<~SQL)
40
+ CREATE TABLE IF NOT EXISTS hecks_era_texts (
41
+ domain text NOT NULL,
42
+ ordinal int NOT NULL,
43
+ digest text NOT NULL,
44
+ held_text text NOT NULL,
45
+ archived_at timestamptz NOT NULL DEFAULT now(),
46
+ PRIMARY KEY (domain, ordinal, digest)
47
+ )
48
+ SQL
49
+ @db.exec(<<~SQL)
50
+ CREATE TABLE IF NOT EXISTS hecks_approvals (
51
+ domain text NOT NULL,
52
+ from_label text NOT NULL,
53
+ to_label text NOT NULL,
54
+ edge_digest text NOT NULL,
55
+ reviewed_ordinal bigint NOT NULL,
56
+ approved_at timestamptz NOT NULL DEFAULT now()
57
+ )
58
+ SQL
59
+ @db.exec("CREATE SEQUENCE IF NOT EXISTS #{quote(sequence)}")
60
+ # GENERATED ALWAYS AS IDENTITY is the intent, but identity
61
+ # columns on partitioned tables need Postgres 17 — an owned
62
+ # sequence default is the same spanning ordinal on any
63
+ # supported server.
64
+ @db.exec(<<~SQL)
65
+ CREATE TABLE IF NOT EXISTS #{quoted_journal} (
66
+ ordinal bigint NOT NULL DEFAULT nextval('#{sequence}'),
67
+ era int NOT NULL,
68
+ aggregate text NOT NULL,
69
+ aggregate_id text NOT NULL,
70
+ operation text NOT NULL DEFAULT 'save',
71
+ state jsonb,
72
+ mirrors jsonb
73
+ ) PARTITION BY LIST (era)
74
+ SQL
75
+ ensure_partition!(1)
76
+ # Immutability by privilege: nothing updates or deletes journal
77
+ # rows. The owner's implicit rights remain (Postgres has no way
78
+ # to revoke them from the owner itself); a deployment's app
79
+ # role connects as a NON-owner and gets exactly INSERT, per
80
+ # era, at mint time.
81
+ #
82
+ # GUARDED, same reasoning as the RLS ALTER TABLE calls just
83
+ # below: REVOKE still writes pg_class.relacl (and takes the
84
+ # matching lock) even when the resulting privileges are
85
+ # unchanged, so an unconditional reissue on every ordinary
86
+ # reboot raced two concurrent boots into
87
+ # `PG::InternalError: tuple concurrently updated` — read
88
+ # first, touch the catalog only on the boot that actually
89
+ # needs to.
90
+ #
91
+ # `relacl IS NULL`, not `has_table_privilege('public', ...,
92
+ # 'UPDATE')` — found live, not assumed: a brand-new table's
93
+ # PUBLIC privilege is already "no UPDATE" by Postgres's own
94
+ # default (nothing has ever been explicitly granted to
95
+ # PUBLIC), so `has_table_privilege` answers false BOTH before
96
+ # the REVOKE has ever run AND after it has — indistinguishable
97
+ # by that check alone, which meant the very first boot's own
98
+ # REVOKE never actually ran, `relacl` stayed NULL forever, and
99
+ # "journal rows accept no UPDATE/DELETE from PUBLIC" was true
100
+ # only by accident of Postgres's default, not by the explicit
101
+ # privilege revocation this method exists to record.
102
+ # `relacl IS NULL` means "default ACL, nothing explicit yet" —
103
+ # exactly the one-time signal needed, and (like the RLS flags
104
+ # below) `pg_table_is_visible(oid)`, not a bare relname match,
105
+ # for the same shared-instance/storehouse reason.
106
+ relacl_null = @db.exec_params(
107
+ "SELECT relacl IS NULL FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)", [journal]
108
+ ).getvalue(0, 0)
109
+ @db.exec("REVOKE UPDATE, DELETE ON #{quoted_journal} FROM PUBLIC") if relacl_null == "t"
110
+ # RLS goes on AT PROVISIONING, never mid-life — enabling it
111
+ # later would deny every role that has no policy yet, on
112
+ # whatever the shape of the schema happened to be at that
113
+ # moment.
114
+ #
115
+ # FORCE, not merely ENABLE: without it, the table OWNER is
116
+ # exempt from every policy here, by Postgres default — which
117
+ # would leave the schema writable forever to whoever holds
118
+ # the owner's credentials, the one connection this whole
119
+ # design cannot fence. Checked, not assumed: mint_era! never
120
+ # inserts into the journal at all (only hecks_eras/
121
+ # hecks_era_texts, neither RLS-protected), and merge_tail!'s
122
+ # one journal INSERT targets the CURRENT era, which the fence
123
+ # already admits for anyone with base privileges — so FORCE
124
+ # costs the owner nothing operations here actually need.
125
+ #
126
+ # This still exempts an actual Postgres SUPERUSER (or any
127
+ # role granted BYPASSRLS) unconditionally — FORCE only
128
+ # narrows what ENABLE already narrows for the owner
129
+ # specifically, and superuser bypass sits above both. Running
130
+ # migrations as a real superuser (self-hosted Postgres, most
131
+ # commonly) leaves this gap open regardless; a managed
132
+ # provider's admin account is typically NOT a superuser, and
133
+ # is exactly what FORCE closes.
134
+ #
135
+ # GUARDED, not reissued unconditionally — measured, not
136
+ # assumed: `ALTER TABLE ... ENABLE/FORCE ROW LEVEL SECURITY`
137
+ # takes AccessExclusiveLock EVEN WHEN THE SETTING IS ALREADY
138
+ # CORRECT (Postgres does not skip the lock just because the
139
+ # statement would be a no-op). ensure_base! runs on EVERY
140
+ # boot by the owning role, not only the first — so an
141
+ # unconditional reissue here would mean every ordinary
142
+ # reboot of the deployment's own identity re-freezes every
143
+ # concurrent writer, on any era, for as long as that ALTER
144
+ # TABLE has to wait its turn. Read the current state first;
145
+ # touch the catalog only on the boot that actually needs to.
146
+ # pg_table_is_visible, NOT a bare relname match — a shared
147
+ # instance (storehouse) can hold a same-named journal table
148
+ # per schema; catalog lookups here must resolve the SAME way
149
+ # search_path resolves an unqualified SQL reference, or a
150
+ # sibling domain's table satisfies a query meant for this
151
+ # domain's own.
152
+ current = @db.exec_params(
153
+ "SELECT relrowsecurity, relforcerowsecurity FROM pg_class " \
154
+ "WHERE relname = $1 AND pg_table_is_visible(oid)", [journal]
155
+ )[0]
156
+ @db.exec("ALTER TABLE #{quoted_journal} ENABLE ROW LEVEL SECURITY") unless current["relrowsecurity"] == "t"
157
+ @db.exec("ALTER TABLE #{quoted_journal} FORCE ROW LEVEL SECURITY") unless current["relforcerowsecurity"] == "t"
158
+ install_transforms!
159
+ end
160
+
161
+ # A DOMAIN'S OWN IDENTITY CHANGED — bridge its history under the
162
+ # new name, before `provisioner?`/the CREATE TABLE IF NOT EXISTS
163
+ # block below ever run. That ordering is load-bearing, not
164
+ # tidiness: `provisioner?` and every statement in ensure_base!
165
+ # test for the journal under `journal` — the NEW name — and a
166
+ # freshly-renamed domain looks, to those checks, exactly like a
167
+ # domain that has never been provisioned at all. Left where it
168
+ # was written, ensure_base! would happily CREATE TABLE IF NOT
169
+ # EXISTS a brand-new, empty journal under the new name before
170
+ # this method ever got a chance to run — and the ALTER TABLE ...
171
+ # RENAME below would then fail, renaming onto a name that
172
+ # already exists.
173
+ #
174
+ # Three-way precheck, cheapest first:
175
+ # 1. no hecks_eras table at all yet — a genuinely fresh
176
+ # database; nothing to bridge, fall through to the
177
+ # ordinary provisioning path.
178
+ # 2. hecks_eras already has rows under the NEW name — this
179
+ # rename already ran (a prior boot, possibly this one on a
180
+ # retry); idempotent no-op.
181
+ # 3. hecks_eras has rows under the OLD name — run the
182
+ # migration.
183
+ # Anything else (no rows under either name) falls through
184
+ # harmlessly — `formerly_known_as` pointing at a name with no
185
+ # held history is not an error, just inert.
186
+ def rename_domain!
187
+ return unless @db.exec_params("SELECT to_regclass($1)", ["hecks_eras"])[0]["to_regclass"]
188
+ return if @db.exec_params(
189
+ "SELECT 1 FROM hecks_eras WHERE domain = $1 LIMIT 1", [@domain]
190
+ ).ntuples.positive?
191
+ return if @db.exec_params(
192
+ "SELECT 1 FROM hecks_eras WHERE domain = $1 LIMIT 1", [@formerly_known_as]
193
+ ).ntuples.zero?
194
+
195
+ old_journal = "hecks_journal_#{Naming.snake(@formerly_known_as)}"
196
+ old_sequence = "#{old_journal}_ordinal"
197
+ ordinals = @db.exec_params(
198
+ "SELECT ordinal FROM hecks_eras WHERE domain = $1 ORDER BY ordinal", [@formerly_known_as]
199
+ ).map { |row| row["ordinal"].to_i }
200
+
201
+ @db.exec("BEGIN")
202
+ @db.exec("SET LOCAL lock_timeout = '10s'")
203
+ # Fixed order — old before new, the `eras:` family before the
204
+ # `ordinal:` family — so two rename attempts (or a rename
205
+ # racing a mint/plain-write on either name) can only ever
206
+ # queue behind one another, never deadlock.
207
+ [
208
+ "hecks_eras:#{@formerly_known_as}", "hecks_eras:#{@domain}",
209
+ "hecks_ordinal:#{@formerly_known_as}", "hecks_ordinal:#{@domain}"
210
+ ].each do |key|
211
+ @db.exec_params("SELECT pg_advisory_xact_lock(hashtext($1))", [key])
212
+ end
213
+
214
+ @db.exec("ALTER TABLE #{quote(old_journal)} RENAME TO #{quote(journal)}")
215
+ # An owned SERIAL/IDENTITY sequence moves automatically with
216
+ # its table and errors if renamed explicitly — this one is a
217
+ # plain CREATE SEQUENCE the journal's DEFAULT merely points
218
+ # at (see ensure_base!), so it does need its own rename, and
219
+ # the column default survives untouched: Postgres stores
220
+ # nextval('...') as a regclass reference internally, not
221
+ # literal text.
222
+ @db.exec("ALTER SEQUENCE #{quote(old_sequence)} RENAME TO #{quote(sequence)}")
223
+ # ALTER TABLE ... RENAME on the parent does NOT cascade to
224
+ # child partition names — each one needs its own explicit
225
+ # rename, sourced from the ordinals held under the OLD name,
226
+ # captured above before the UPDATE below flips the column.
227
+ ordinals.each do |ordinal|
228
+ old_partition = "#{old_journal}_era_#{ordinal}"
229
+ @db.exec("ALTER TABLE #{quote(old_partition)} RENAME TO #{quote(partition(ordinal))}")
230
+ end
231
+
232
+ %w[hecks_eras hecks_era_texts hecks_approvals].each do |table|
233
+ @db.exec_params("UPDATE #{table} SET domain = $1 WHERE domain = $2", [@domain, @formerly_known_as])
234
+ end
235
+ # Unlike its siblings, hecks_attestations is not created in
236
+ # ensure_base! at all — only lazily, on first reattest! — so a
237
+ # domain that never needed one must not be forced through an
238
+ # UPDATE against a table that doesn't exist.
239
+ @db.exec_params("UPDATE hecks_attestations SET domain = $1 WHERE domain = $2", [@domain, @formerly_known_as]) if @db.exec_params("SELECT to_regclass($1)", ["hecks_attestations"])[0]["to_regclass"]
240
+
241
+ @db.exec("COMMIT")
242
+ rescue PG::LockNotAvailable
243
+ @db.exec("ROLLBACK") rescue nil
244
+ raise Runtime::WiringError,
245
+ "cannot rename #{@formerly_known_as} to #{@domain}: another rename, mint, or write holds " \
246
+ "one of the domain locks — waited 10s; try again shortly"
247
+ rescue PG::Error => error
248
+ @db.exec("ROLLBACK") rescue nil
249
+ raise Runtime::WiringError, "cannot rename #{@formerly_known_as} to #{@domain}: #{error.message.strip}"
250
+ end
251
+
252
+ # Nothing provisioned yet — build it. Provisioned and owned —
253
+ # keep it current. Provisioned by SOMEONE ELSE — this is an app
254
+ # role, and the owner has already done this work.
255
+ def provisioner?
256
+ rows = @db.exec_params(
257
+ "SELECT pg_get_userbyid(relowner) = current_user AS owned FROM pg_class " \
258
+ "WHERE relname = $1 AND pg_table_is_visible(oid)",
259
+ [journal]
260
+ )
261
+ rows.ntuples.zero? || rows[0]["owned"] == "t"
262
+ end
263
+
264
+ # BUILD, THEN ATTACH — never CREATE ... PARTITION OF. The two
265
+ # produce the same partition; only the lock differs, and that
266
+ # difference is the whole availability story of a mint:
267
+ #
268
+ # CREATE TABLE ... PARTITION OF → AccessExclusiveLock (parent)
269
+ # CREATE, then ALTER ... ATTACH → ShareUpdateExclusiveLock
270
+ #
271
+ # AccessExclusive conflicts with every insert in the hierarchy —
272
+ # routed through the parent OR addressed to an existing leaf —
273
+ # so attaching the new era inside the mint transaction stopped
274
+ # every writer for the WHOLE mint, tail materialization
275
+ # included. ShareUpdateExclusive conflicts with neither, so the
276
+ # old checkout keeps writing its own era straight through the
277
+ # build and only pauses for the head swap at the end.
278
+ #
279
+ # That is what makes the fork real DURING a mint rather than
280
+ # merely before and after one. Measured, and pinned by the spec
281
+ # — which writes through a live mint rather than reading a lock
282
+ # mode out of the catalog.
283
+ def ensure_partition!(era)
284
+ return if partition_attached?(era)
285
+
286
+ @db.exec(<<~SQL)
287
+ CREATE TABLE IF NOT EXISTS #{quote(partition(era))} (
288
+ LIKE #{quoted_journal} INCLUDING DEFAULTS
289
+ )
290
+ SQL
291
+ @db.exec(<<~SQL)
292
+ ALTER TABLE #{quoted_journal}
293
+ ATTACH PARTITION #{quote(partition(era))} FOR VALUES IN (#{era.to_i})
294
+ SQL
295
+ end
296
+
297
+ # Attached, not merely present: a crash between the CREATE and
298
+ # the ATTACH leaves a table that is not yet part of the journal,
299
+ # and the next boot must finish the job rather than skip it.
300
+ def partition_attached?(era)
301
+ @db.exec_params(
302
+ "SELECT 1 FROM pg_inherits i " \
303
+ "JOIN pg_class child ON child.oid = i.inhrelid " \
304
+ "JOIN pg_class parent ON parent.oid = i.inhparent " \
305
+ "WHERE child.relname = $1 AND parent.relname = $2 " \
306
+ "AND pg_table_is_visible(child.oid) AND pg_table_is_visible(parent.oid)",
307
+ [partition(era), journal]
308
+ ).ntuples.positive?
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,168 @@
1
+ module Hecks
2
+ module Adapters
3
+ class PostgresEra
4
+ class Lineage
5
+ # THE ONE CHUNKED, LOCK-FREE, RESUMABLE BACKFILL LOOP — shared by
6
+ # `backfill_head_snapshot!` (era 1's existing one-shot blocking
7
+ # backfill, retrofit) and every field-cache table's own initial
8
+ # backfill (new). Governing principle 1 (docs/implemented/postgres-era-adapter-
9
+ # split-plan.md): no operation this plan touches may hold a lock
10
+ # across a scan whose duration scales with table size — a single
11
+ # `INSERT ... SELECT` over the whole journal (what
12
+ # `backfill_head_snapshot!` used to be) is exactly that, and so is
13
+ # a naive "populate every cache row in one statement" field-cache
14
+ # backfill.
15
+ #
16
+ # THE SHAPE: read one bounded chunk (real rows, real ordinals) with
17
+ # a plain SELECT — no lock held across it, so an ordinary reader or
18
+ # writer is never blocked by a backfill in progress — then upsert
19
+ # that chunk under the SAME transactionally-scoped advisory lock +
20
+ # ordinal-guard idiom `append`'s own snapshot upsert already uses
21
+ # (`WHERE ordinal < EXCLUDED.ordinal`), then persist a cursor
22
+ # before moving to the next chunk. Repeat until a chunk reads back
23
+ # short of a full page — that page was the last one.
24
+ #
25
+ # RESUMABLE, not merely restartable. A crash (or a second
26
+ # concurrent boot) mid-backfill leaves the cursor exactly where the
27
+ # last COMMITTED chunk left it — `hecks_backfill_progress` is
28
+ # updated in the SAME transaction as the chunk's own upsert, so
29
+ # cursor and data can never observably disagree (see
30
+ # `run_chunk!`). The next attempt reads that cursor and continues;
31
+ # it does not rescan what a prior attempt already committed.
32
+ # RESTARTABLE would also be CORRECT here (every upsert is
33
+ # idempotent and ordinal-guarded — rerunning an already-done chunk
34
+ # from id 1 changes nothing) but wastes real work on a large
35
+ # table; resumability is what keeps a crash near the END of a
36
+ # large backfill cheap to recover from instead of starting over.
37
+ #
38
+ # THE LOCK KEY PREFIX is `hecks_field_cache:` — deliberately
39
+ # disjoint from the three families already in use elsewhere in
40
+ # this adapter (`hecks_ordinal:`, `hecks_eras:`,
41
+ # `hecks_head_snapshot:` — see lineage.rb/head_compiler.rb/
42
+ # mint_transaction.rb/tail_merge.rb) so a backfill chunk NEVER
43
+ # contends with a plain write, a mint, or a snapshot-table's own
44
+ # first-creation lock. It is held for exactly ONE CHUNK's own
45
+ # transaction, never across the whole backfill — two concurrent
46
+ # backfillers of the SAME target simply take turns one chunk at a
47
+ # time rather than racing to duplicate work; neither blocks an
48
+ # unrelated reader or writer for even an instant.
49
+ module ResumableBackfill
50
+ CHUNK_SIZE = 5_000
51
+
52
+ # Idempotent, unguarded — same idiom as every other DDL helper
53
+ # in this file tree (`ensure_head_snapshot!` et al.): cheap,
54
+ # runs on every boot, only ever does real work once.
55
+ def ensure_backfill_progress_table!
56
+ @db.exec(<<~SQL)
57
+ CREATE TABLE IF NOT EXISTS hecks_backfill_progress (
58
+ target text PRIMARY KEY,
59
+ cursor text,
60
+ completed boolean NOT NULL DEFAULT false,
61
+ updated_at timestamptz NOT NULL DEFAULT now()
62
+ )
63
+ SQL
64
+ end
65
+
66
+ # Drives `target` (an already-created, currently-empty-or-
67
+ # partially-filled table) through chunks until a source read
68
+ # comes back short of `CHUNK_SIZE` rows. Two distinct callables,
69
+ # not one — a head-snapshot row and a field-cache row carry
70
+ # different columns (`state` jsonb vs. a single extracted
71
+ # `value`), so there is no one generic "upsert this row" shape
72
+ # to share; only the LOOP, the lock, and the cursor are generic.
73
+ #
74
+ # source_sql.call(cursor) — given the last-processed id (nil
75
+ # before the first chunk), returns a SQL SELECT whose result
76
+ # has an `id` column (text, ordered ascending) plus whatever
77
+ # other columns `upsert` below needs. Must read `id >
78
+ # cursor` (or unconditional when cursor is nil), `ORDER BY
79
+ # id`, `LIMIT CHUNK_SIZE` — the caller owns the actual
80
+ # column list/source tables; this method only owns the loop,
81
+ # the lock, and the cursor.
82
+ #
83
+ # upsert.call(rows) — given the PG::Result of one chunk's
84
+ # read, performs the actual guarded upsert into `target` and
85
+ # returns nothing meaningful; runs INSIDE the same
86
+ # transaction/advisory-lock scope as the cursor update below,
87
+ # so a crash between "wrote the chunk" and "advanced the
88
+ # cursor" is impossible — they commit together or not at
89
+ # all.
90
+ def chunked_backfill!(target, source_sql:, upsert:)
91
+ ensure_backfill_progress_table!
92
+ loop do
93
+ done = run_chunk!(target, source_sql: source_sql, upsert: upsert)
94
+ break if done
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # ONE CHUNK, ONE TRANSACTION, ONE SHORT-HELD LOCK. Re-reads
101
+ # progress AFTER acquiring the lock (not just before) — a second
102
+ # concurrent booter may have already finished this exact chunk
103
+ # (or the whole backfill) while this process was waiting for the
104
+ # lock; without the re-read, it would redundantly reprocess a
105
+ # chunk another process just committed. Harmless either way
106
+ # (idempotent, ordinal-guarded) but the re-read is what keeps
107
+ # two concurrent boots from both doing the full scan instead of
108
+ # splitting it.
109
+ def run_chunk!(target, source_sql:, upsert:)
110
+ progress = backfill_progress(target)
111
+ return true if progress[:completed]
112
+
113
+ completed = false
114
+ nested_transaction("hecks_backfill_chunk") do
115
+ @db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_field_cache:' || $1))", [target])
116
+ progress = backfill_progress(target)
117
+ if progress[:completed]
118
+ completed = true
119
+ next
120
+ end
121
+
122
+ rows = @db.exec(source_sql.call(progress[:cursor]))
123
+ if rows.ntuples.zero?
124
+ upsert_backfill_progress!(target, cursor: progress[:cursor], completed: true)
125
+ completed = true
126
+ next
127
+ end
128
+
129
+ upsert.call(rows)
130
+ completed = rows.ntuples < CHUNK_SIZE
131
+ # `PG::Result#[]` supports neither an out-of-range index NOR
132
+ # a negative one (unlike a plain Ruby Array) — same trap as
133
+ # `backfill_progress` above, the explicit last-index form.
134
+ last_cursor = rows[rows.ntuples - 1]["id"]
135
+ upsert_backfill_progress!(target, cursor: last_cursor, completed: completed)
136
+ end
137
+ completed
138
+ end
139
+
140
+ # `PG::Result#[]` RAISES IndexError on an out-of-range index —
141
+ # unlike a plain Ruby Array, it does not return nil — so the
142
+ # ENTIRELY ORDINARY case of "no progress row exists yet" (every
143
+ # target's very first check) cannot be read via a bare `[0]`.
144
+ # `ntuples.zero?` first, always.
145
+ def backfill_progress(target)
146
+ result = @db.exec_params(
147
+ "SELECT cursor, completed FROM hecks_backfill_progress WHERE target = $1", [target]
148
+ )
149
+ return { cursor: nil, completed: false } if result.ntuples.zero?
150
+
151
+ row = result[0]
152
+ { cursor: row["cursor"], completed: row["completed"] == "t" }
153
+ end
154
+
155
+ def upsert_backfill_progress!(target, cursor:, completed:)
156
+ @db.exec_params(
157
+ "INSERT INTO hecks_backfill_progress (target, cursor, completed, updated_at) " \
158
+ "VALUES ($1, $2, $3, now()) " \
159
+ "ON CONFLICT (target) DO UPDATE SET cursor = EXCLUDED.cursor, " \
160
+ "completed = EXCLUDED.completed, updated_at = EXCLUDED.updated_at",
161
+ [target, cursor, completed]
162
+ )
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,170 @@
1
+ require_relative "../../../../../../runtime/registry"
2
+
3
+ module Hecks
4
+ module Adapters
5
+ class PostgresEra
6
+ class Lineage
7
+ module TailMerge
8
+ # ── fork observability ─────────────────────────────────────────
9
+
10
+ # Post-cut writes an old era made after the newer era was minted
11
+ # — the divergence between the worlds, observable at any time.
12
+ def diverged_count(old_era)
13
+ cut = eras.find { |era| era[:ordinal] == old_era + 1 }&.dig(:watermark)
14
+ return 0 unless cut
15
+
16
+ @db.exec(
17
+ "SELECT count(*) FROM #{quoted_journal} WHERE era = #{old_era.to_i} AND ordinal > #{cut.to_i}"
18
+ )[0]["count"].to_i
19
+ end
20
+
21
+ # ── tail-merge ─────────────────────────────────────────────────
22
+ #
23
+ # The one deliberate command — it marks a business event (an app
24
+ # retiring), never a shape change. One transaction: advance the
25
+ # watermarks, rebuild the head so the tail interleaves by its
26
+ # recorded global ordinal, append the declared winners, audit —
27
+ # and roll the whole thing back on any refusal. Records touched
28
+ # by both worlds since the cut refuse until each has an explicit
29
+ # winner; resolution itself is append-only (the winner's state
30
+ # re-enters as the newest row and wins structurally — originals
31
+ # stay immutable).
32
+ def merge_tail!(aggregates:, edges:, winners: {}, audit: nil)
33
+ @db.exec("BEGIN")
34
+ @db.exec("SET LOCAL lock_timeout = '10s'")
35
+ @db.exec("SELECT pg_advisory_xact_lock(hashtext('hecks_eras:' || #{text_literal(@domain)}))")
36
+ held = eras
37
+ era = held.last[:ordinal]
38
+ label = held.last[:label]
39
+ if era == 1
40
+ @db.exec("ROLLBACK")
41
+ raise Runtime::WiringError, "nothing to merge — #{@domain} stands at era 1"
42
+ end
43
+ cut = held.last[:watermark].to_i
44
+ tip = last_ordinal
45
+
46
+ conflicts = aggregates.flat_map { |aggregate| conflict_ids(aggregate, edges, era, cut) }
47
+ unresolved = conflicts.reject { |_, id| winners.key?(id) }
48
+ unless unresolved.empty?
49
+ @db.exec("ROLLBACK")
50
+ raise Runtime::WiringError,
51
+ "cannot merge the tail of #{@domain}: touched by both worlds since the cut — " \
52
+ "#{unresolved.map { |storage, id| "#{storage}##{id}" }.sort.join(', ')}. " \
53
+ "Name each winner (--winner <id>=old or --winner <id>=new), then run bin/merge_tail again. " \
54
+ "A winner takes the WHOLE record — the aggregate is the consistency boundary, so the " \
55
+ "loser's edits are discarded even where they touched different attributes"
56
+ end
57
+
58
+ # the new world's pre-merge head states, captured before the
59
+ # rebuild lets the tail interleave
60
+ new_states = {}
61
+ aggregates.each do |aggregate|
62
+ winners.select { |_, side| side == "new" }.each_key do |id|
63
+ row = @db.exec_params("SELECT state FROM #{quote(head_view(aggregate.storage_name))} WHERE id = $1", [id])
64
+ new_states[id] = [aggregate.storage_name, row[0]["state"]] if row.ntuples.positive?
65
+ end
66
+ end
67
+
68
+ @db.exec_params("UPDATE hecks_eras SET watermark = $2 WHERE domain = $1 AND ordinal > 1", [@domain, tip])
69
+ aggregates.each do |aggregate|
70
+ @db.exec("DROP VIEW IF EXISTS #{quote(head_view(aggregate.storage_name))}")
71
+ @db.exec("DROP MATERIALIZED VIEW IF EXISTS #{quote(matview(aggregate.storage_name, era, label))}")
72
+ # full: the watermarks just moved — every ancestor matview's
73
+ # cut is stale, so there is nothing safe to layer on.
74
+ compile_head!(aggregate, era, label, edges, full: true)
75
+ end
76
+
77
+ # rubocop:disable-next Metrics/BlockLength
78
+ winners.each do |id, side|
79
+ aggregates.each do |aggregate|
80
+ state =
81
+ if side == "old"
82
+ row = @db.exec_params(
83
+ "SELECT state FROM #{quote(matview(aggregate.storage_name, era, label))} " \
84
+ "WHERE aggregate_id = $1 AND operation = 'save' ORDER BY ordinal DESC LIMIT 1",
85
+ [id]
86
+ )
87
+ row.ntuples.positive? ? row[0]["state"] : nil
88
+ else
89
+ new_states[id]&.first == aggregate.storage_name ? new_states[id][1] : nil
90
+ end
91
+ next unless state
92
+
93
+ # Same two-step append does for a live write — journal
94
+ # first, snapshot second — because this INSERT bypasses
95
+ # PostgresEra#append entirely (it writes through Lineage
96
+ # directly). Skipping the snapshot half here would mean a
97
+ # merge winner lands in the journal but head_view — which
98
+ # reads this era's live rows from the snapshot table, not
99
+ # by re-scanning the journal — never shows it.
100
+ ordinal = @db.exec_params(
101
+ "INSERT INTO #{quoted_journal} (era, aggregate, aggregate_id, operation, state) " \
102
+ "VALUES ($1, $2, $3, 'save', $4) RETURNING ordinal",
103
+ [era, aggregate.storage_name, id, state]
104
+ )[0]["ordinal"]
105
+ # Always `'save'` — a merge winner is read from either
106
+ # the matview's own `operation = 'save'` rows or the new
107
+ # world's live head (`new_states`, captured from
108
+ # `head_view`, which is save-only by construction), so
109
+ # nothing reaching this INSERT is ever a delete.
110
+ @db.exec_params(
111
+ "INSERT INTO #{quote(head_snapshot(aggregate.storage_name, era))} (id, ordinal, operation, state) " \
112
+ "VALUES ($1, $2, 'save', $3) " \
113
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
114
+ "state = EXCLUDED.state WHERE #{quote(head_snapshot(aggregate.storage_name, era))}.ordinal < EXCLUDED.ordinal",
115
+ [id, ordinal, state]
116
+ )
117
+ end
118
+ end
119
+
120
+ if audit
121
+ violations = audit.call
122
+ unless violations.empty?
123
+ @db.exec("ROLLBACK")
124
+ raise Runtime::WiringError,
125
+ "cannot merge the tail of #{@domain}: the audit refused —\n - #{violations.join("\n - ")}"
126
+ end
127
+ end
128
+
129
+ @db.exec("COMMIT")
130
+ true
131
+ rescue PG::LockNotAvailable
132
+ @db.exec("ROLLBACK") rescue nil
133
+ raise Runtime::WiringError,
134
+ "cannot merge the tail of #{@domain}: another mint or merge holds the domain lock — " \
135
+ "waited 10s; try again shortly"
136
+ rescue PG::Error => error
137
+ @db.exec("ROLLBACK") rescue nil
138
+ raise Runtime::WiringError, "cannot merge the tail of #{@domain}: #{error.message.strip}"
139
+ end
140
+
141
+ # Ids touched by BOTH worlds since the cut — the old world's
142
+ # post-cut tail INTERSECTed with the new world's own writes.
143
+ #
144
+ # KNOWN GAP, not silently risked: this compares raw
145
+ # `aggregate_id` values, with no notion of "these two different
146
+ # ids are the same entity, rekeyed." If a domain's history
147
+ # includes a rekey (see TranslationRekey) and is LATER
148
+ # merged here, a record's pre-rekey and post-rekey rows will
149
+ # never intersect — they just silently survive as two separate,
150
+ # unrelated-looking heads (a duplicate, not corruption: nothing
151
+ # here deletes or clobbers either side). Resolve any such
152
+ # duplicate manually after a merge; teaching this INTERSECT
153
+ # about a rekey mapping is real, separate work, deliberately
154
+ # out of scope for rekey's first pass.
155
+ def conflict_ids(aggregate, edges, era, cut)
156
+ names = names_by_era(aggregate, edges)
157
+ olds = (1...era).map { |ancestor| text_literal(names[:storage][ancestor - 1]) }.join(", ")
158
+ @db.exec(<<~SQL).map { |row| [aggregate.storage_name, row["aggregate_id"]] }
159
+ SELECT aggregate_id FROM #{quoted_journal}
160
+ WHERE era < #{era.to_i} AND aggregate IN (#{olds}) AND ordinal > #{cut.to_i}
161
+ INTERSECT
162
+ SELECT aggregate_id FROM #{quoted_journal}
163
+ WHERE era = #{era.to_i} AND aggregate = #{text_literal(names[:storage][era - 1])}
164
+ SQL
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end