hecks 1.0.2 → 1.2.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 (247) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d13701a926b6be040b496d5e582a9d4fd99ad878372f5345636ef074aa6e4e8
4
- data.tar.gz: 95fc7593502393bd0d698aeaac1b9419dc6e00b78e12f11ad6740a2008a0df0e
3
+ metadata.gz: 0d66cdbdb775a92f3cfcf3f626637cffd6fb81915d7edbcccfe21e1b7c980606
4
+ data.tar.gz: a43786075ea299d26d6d1f23075cf2176fdad15b7efa235234c4a6e321ec15f3
5
5
  SHA512:
6
- metadata.gz: 9b65062cd42566cb0f2e4d5c45b12b1990133850964e117a6827b917d5b293245855a292ccd698c12d982de268a1554029a7a079a03c2cd7e99148eba1f7af47
7
- data.tar.gz: 9b98481d47cfcbd09e72e5d53226287fbed298d2d1ae77ebbd48b133c7fb9100be1531daca0be92728976ef11eb87979e5847306b4eed628308dfebfdf83014f
6
+ metadata.gz: ac3883e9e6ebeef723718dfa39785d2dcdd0e57e613f6ca21a9519040eb11278f22eedbe597965d1b28e3dfa08b98ac2d0a989686cd7b8aec0d8d97dd476acec
7
+ data.tar.gz: f610074205ca1a39121169ef02109445b60ca66bf517c1a8351e8c761a1863fccfe0f9b41075fa00cc8225c017c82eefdb47c38566469fe378d0879aec115860
@@ -36,7 +36,7 @@ module Hecks
36
36
 
37
37
  SYSTEM_PREFIX = "You are assisting a domain-modeling interview for the hecks " \
38
38
  "event-sourced framework. Reply with EXACTLY ONE JSON object, no prose " \
39
- "before or after it, no markdown code fence. "
39
+ "before or after it, no markdown code fence. ".freeze
40
40
 
41
41
  module_function
42
42
 
@@ -97,13 +97,13 @@ module Hecks
97
97
  # ── transport ───────────────────────────────────────────────────
98
98
 
99
99
  def call(system:, payload:)
100
- stdout, status = Timeout.timeout(TIMEOUT_SECONDS) {
100
+ stdout, status = Timeout.timeout(TIMEOUT_SECONDS) do
101
101
  Open3.capture2(
102
102
  "claude", "-p", "--output-format", "json",
103
103
  "--append-system-prompt", system, "--allowedTools", "",
104
104
  stdin_data: JSON.generate(payload)
105
105
  )
106
- }
106
+ end
107
107
  raise Ports::Agent::Unavailable, "claude exited #{status.exitstatus}: #{stdout}" unless status.success?
108
108
 
109
109
  unwrap(stdout)
@@ -31,7 +31,7 @@ module Hecks
31
31
  # per execute, not a persistent connection — D1 has no connection to
32
32
  # hold open.
33
33
  class Connection
34
- ENDPOINT = "https://api.cloudflare.com/client/v4"
34
+ ENDPOINT = "https://api.cloudflare.com/client/v4".freeze
35
35
 
36
36
  def initialize(account_id:, database_id:, api_token:)
37
37
  @uri = URI("#{ENDPOINT}/accounts/#{account_id}/d1/database/#{database_id}/query")
@@ -65,36 +65,41 @@ module Hecks
65
65
  request.body = JSON.generate(payload)
66
66
 
67
67
  response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: true) { |http| http.request(request) }
68
- body =
69
- begin
70
- JSON.parse(response.body)
71
- rescue JSON::ParserError
72
- raise Runtime::WiringError, "D1 query failed: non-JSON response (HTTP #{response.code}): #{response.body}"
73
- end
68
+ body = parse_d1_body(response)
69
+ messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
74
70
 
75
- unless body["success"]
76
- messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
77
- raise Runtime::WiringError, "D1 query failed: #{messages.empty? ? response.body : messages}"
78
- end
71
+ raise Runtime::WiringError, "D1 query failed: #{messages.empty? ? response.body : messages}" unless body["success"]
79
72
 
80
73
  results = body.fetch("result")
81
74
  failed = results.find { |result| result["success"] == false }
82
- if failed
83
- messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
84
- detail =
85
- if failed.key?("error")
86
- failed["error"]
87
- elsif failed.key?("message")
88
- failed["message"]
89
- else
90
- messages
91
- end
92
- raise Runtime::WiringError, "D1 query failed: #{detail.to_s.empty? ? 'a batched statement failed' : detail}"
93
- end
75
+ raise Runtime::WiringError, "D1 query failed: #{failed_statement_detail(failed, messages)}" if failed
94
76
 
95
77
  results
96
78
  end
97
79
 
80
+ def parse_d1_body(response)
81
+ JSON.parse(response.body)
82
+ rescue JSON::ParserError
83
+ raise Runtime::WiringError, "D1 query failed: non-JSON response (HTTP #{response.code}): #{response.body}"
84
+ end
85
+
86
+ # `messages` is the whole-response :errors fallback, tried LAST —
87
+ # a per-statement `failed["error"]`/`failed["message"]` (checked
88
+ # by key presence, not truthiness, so an explicit `nil` still
89
+ # counts as "the key was there") is always more specific to WHICH
90
+ # statement failed, when either is present.
91
+ def failed_statement_detail(failed, messages)
92
+ detail =
93
+ if failed.key?("error")
94
+ failed["error"]
95
+ elsif failed.key?("message")
96
+ failed["message"]
97
+ else
98
+ messages
99
+ end
100
+ detail.to_s.empty? ? "a batched statement failed" : detail
101
+ end
102
+
98
103
  public
99
104
 
100
105
  def get_first_row(sql, binds = [])
@@ -170,7 +175,10 @@ module Hecks
170
175
  order_sql = "ORDER BY id"
171
176
  if order_by
172
177
  name = order_by.to_s.split(".").first
173
- raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
178
+ unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
179
+ raise Runtime::WiringError,
180
+ "#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
181
+ end
174
182
 
175
183
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
176
184
  order_sql = "ORDER BY #{order_clause(spec, nil)}"
@@ -256,6 +264,16 @@ module Hecks
256
264
  # matching Sqlite#atomic_put's `next` (skip append AND project both,
257
265
  # together) with no second HTTP call and no gap for another writer to
258
266
  # land in between the check and the write.
267
+ # Three SQL statements, built here and batched together as ONE
268
+ # transaction below — see the comment above on the real TOCTOU gap
269
+ # this exact shape closes (the existence check moved INSIDE the
270
+ # batch, not run as a separate earlier round trip). Splitting the
271
+ # per-statement builders out would still need columns/values/slots/
272
+ # not_exists/quoted_table threaded into each, and would separate
273
+ # three pieces of ONE atomic batch across methods with no single
274
+ # place left to see that they are, together, the fix.
275
+ # rubocop:disable-next Metrics/AbcSize
276
+ # rubocop:disable-next Metrics/MethodLength
259
277
  def atomic_put(entry, insert_only: false)
260
278
  instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
261
279
  columns = (["id"] + persisted_fields.map { |field| field[:name].to_s }).map { |column| quote_ident(column) }
@@ -343,9 +361,10 @@ module Hecks
343
361
  # already uses through `Connection#execute`.
344
362
  def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
345
363
  @db.execute(
346
- "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
347
- "VALUES (?, ?, ?, ?, ?, ?)",
348
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
364
+ "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, " \
365
+ "completed_compensations) VALUES (?, ?, ?, ?, ?, ?)",
366
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
367
+ JSON.generate(completed_compensations)]
349
368
  )
350
369
  end
351
370
 
@@ -360,7 +379,8 @@ module Hecks
360
379
  return enum_for(:each_saga) unless block_given?
361
380
 
362
381
  @db.execute(
363
- "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = ?",
382
+ "SELECT process_manager, correlation, state, memory, completed_compensations " \
383
+ "FROM hecks_saga_instances WHERE domain = ?",
364
384
  [@domain]
365
385
  ).each do |row|
366
386
  yield row["process_manager"], row["correlation"], row["state"],
@@ -2,10 +2,17 @@ require_relative "../../vocabulary"
2
2
 
3
3
  module Hecks
4
4
  module Adapters
5
+ # The filesystem-backed loader — glob-and-`Kernel.load` a domain's own
6
+ # `.port`/`.adapter`/`.bluebook`/`.hecksagon`/`.world`/environment-overlay
7
+ # files off disk, in the order `Vocabulary.fetch("LoadOrder")` requires,
8
+ # and locate a domain's own root directory by walking up from any file
9
+ # inside it looking for a `.hecksagon`. What `Hecks.boot(path)` uses when
10
+ # a caller names a directory rather than an explicit file list (see
11
+ # `Loader.boot_files` for that other path).
5
12
  class Folder
6
13
  DOMAIN_ORDER = Hecks::Vocabulary.fetch("LoadOrder")
7
- PORTS = "ports"
8
- ADAPTERS = "adapters"
14
+ PORTS = "ports".freeze
15
+ ADAPTERS = "adapters".freeze
9
16
 
10
17
  def initialize(settings: {}, root: nil)
11
18
  @settings = settings
@@ -117,7 +124,7 @@ module Hecks
117
124
  return unless File.directory?(directory)
118
125
 
119
126
  patterns.each do |pattern|
120
- Dir[File.join(directory, pattern)].sort.each { |file| Kernel.load(file) }
127
+ Dir[File.join(directory, pattern)].each { |file| Kernel.load(file) }
121
128
  end
122
129
  end
123
130
 
@@ -30,7 +30,7 @@ module Hecks
30
30
  # no defaults, on purpose: a login mechanism silently
31
31
  # half-configured is worse than one that refuses to boot at all.
32
32
  module GoogleAuthentication
33
- ISSUER = "https://accounts.google.com"
33
+ ISSUER = "https://accounts.google.com".freeze
34
34
 
35
35
  module_function
36
36
 
@@ -80,9 +80,7 @@ module Hecks
80
80
  require "oauth2"
81
81
  require "google-id-token"
82
82
 
83
- unless state && expected_state && state == expected_state
84
- raise Ports::Authentication::ValidationError, "state mismatch"
85
- end
83
+ raise Ports::Authentication::ValidationError, "state mismatch" unless state && expected_state && state == expected_state
86
84
 
87
85
  token = client.auth_code.get_token(code, redirect_uri: ENV.fetch("GOOGLE_REDIRECT_URI"))
88
86
  id_token = token.params["id_token"] or raise Ports::Authentication::ValidationError, "no id_token in the response"
@@ -29,7 +29,7 @@ module Hecks
29
29
  case entry.fetch("operation")
30
30
  when "save" then records[id] = entry.fetch("state")
31
31
  when "delete" then records.delete(id)
32
- else raise Malformed, "#{@journal_path}: unknown journal operation #{entry.fetch("operation").inspect}"
32
+ else raise Malformed, "#{@journal_path}: unknown journal operation #{entry.fetch('operation').inspect}"
33
33
  end
34
34
  end
35
35
  records
@@ -18,12 +18,12 @@ module Hecks
18
18
  include Snapshot
19
19
  include Journal
20
20
 
21
- MAGIC = "HEKI"
21
+ MAGIC = "HEKI".freeze
22
22
  HEADER_BYTES = 8
23
23
 
24
24
  class Malformed < StandardError; end
25
25
 
26
- attr_reader :aggregate, :path
26
+ attr_reader :aggregate, :path, :events
27
27
 
28
28
  def initialize(aggregate:, settings: {}, root: nil)
29
29
  @aggregate = aggregate
@@ -114,8 +114,6 @@ module Hecks
114
114
 
115
115
  def record_event(event) = @events << event
116
116
 
117
- def events = @events
118
-
119
117
  # ── the OPTIONAL saga-persistence capability (§2) — Heki's own
120
118
  # shape (a sibling snapshot+journal file pair, `SagaStore`,
121
119
  # heki/saga_store.rb) rather than a table in a store this adapter
@@ -128,7 +126,7 @@ module Hecks
128
126
  saga_store.delete_saga(@domain, process_manager.to_s, correlation.to_s)
129
127
  end
130
128
 
131
- def each_saga(&block) = saga_store.each_saga(@domain, &block)
129
+ def each_saga(&) = saga_store.each_saga(@domain, &)
132
130
 
133
131
  private
134
132
 
@@ -24,7 +24,10 @@ module Hecks
24
24
  return records unless order_by
25
25
 
26
26
  name = order_by.to_s.split(".").first
27
- raise Runtime::WiringError, "#{aggregate.name} has no attribute #{order_by.inspect} to order by" unless aggregate.lifecycle&.field.to_s == name || aggregate.attribute(name)
27
+ unless aggregate.lifecycle&.field.to_s == name || aggregate.attribute(name)
28
+ raise Runtime::WiringError,
29
+ "#{aggregate.name} has no attribute #{order_by.inspect} to order by"
30
+ end
28
31
 
29
32
  path = sortable_path(aggregate, order_by)
30
33
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
@@ -123,11 +123,11 @@ module Hecks
123
123
  # full "Domain::Aggregate#id" string — callers already know which
124
124
  # aggregate they're asking this instance about.
125
125
  def instances
126
- @client.read.fetch("instances", {}).filter_map { |key, state|
126
+ @client.read.fetch("instances", {}).filter_map do |key, state|
127
127
  next unless key.start_with?(@prefix)
128
128
 
129
129
  [key.delete_prefix(@prefix), build_instance(key.delete_prefix(@prefix), state)]
130
- }.to_h
130
+ end.to_h
131
131
  end
132
132
 
133
133
  # Lambda's own JSON response is already Ruby-decoded with STRING
@@ -0,0 +1,3 @@
1
+ Hecks.adapter "LocalStorage" do
2
+ port "persistence"
3
+ end
@@ -0,0 +1,120 @@
1
+ require_relative "../../ports/persistence/append_only"
2
+ require_relative "../../ports/query/in_memory"
3
+ require_relative "in_memory_ordering"
4
+ require_relative "../../runtime/instance"
5
+
6
+ module Hecks
7
+ module Adapters
8
+ # A BROWSER-HOSTED DOMAIN'S OWN DECLARED INTENT — not a second Memory
9
+ # wearing a different name. Ruby has no way to reach a real browser's
10
+ # `window.localStorage` at all (it is per-tab, per-origin, JS-only,
11
+ # unreachable over any network the way D1's own REST API is) — so
12
+ # this Ruby-side adapter is honestly a stand-in: in-process, ephemeral,
13
+ # mechanically identical to Memory. What earns it a name of its own
14
+ # is what it DECLARES, not what it happens to do in Ruby: `persisted_by
15
+ # "LocalStorage"` says "this domain expects real, durable, single-
16
+ # device storage the moment it's actually running where it's meant to
17
+ # run" — the same distinction Heki (real local durability) already
18
+ # draws against Memory (deliberately ephemeral, test/example-only),
19
+ # one adapter over. `bin/console`, the fuzzer, `spec/`, `bin/
20
+ # model_check` all get a domain that boots and behaves correctly
21
+ # against this adapter; only a real browser gets the real durability.
22
+ #
23
+ # THE REAL BROWSER HALF lives outside this file entirely: `rust/web`'s
24
+ # `dispatch(json)` (docs/implemented/decisions/0015) takes an optional
25
+ # `"seed"` (the exact `"instances"` shape it also answers with) plus
26
+ # `"steps"` — a host rehydrates from a prior snapshot and replays only
27
+ # the new command(s), rather than the whole history every call. A
28
+ # page bound to this adapter is expected to hold that snapshot in
29
+ # `window.localStorage` itself (get on load, set after every
30
+ # `dispatch`) — the seed/instances round trip IS the adapter, once
31
+ # you're in the one runtime that can actually reach the storage this
32
+ # name promises.
33
+ class LocalStorage
34
+ # TENANT-CAPABLE TRIVIALLY, same reasoning as Memory's own — a
35
+ # browser tab is exactly one origin, exactly one user; there is no
36
+ # second tenant this in-process Hash could ever confuse a first
37
+ # one with.
38
+ def self.tenant_capable? = true
39
+ def persistence_capabilities = [:atomic_put]
40
+
41
+ attr_reader :aggregate, :events
42
+
43
+ def initialize(aggregate:, settings: {}, root: nil)
44
+ @aggregate = aggregate
45
+ @records = {}
46
+ @events = []
47
+ @entries = []
48
+ end
49
+
50
+ def find(id) = @records[id.to_s]
51
+ def count = @records.size
52
+
53
+ def all(order_by: nil, direction: :asc)
54
+ InMemoryOrdering.ordered(@records.values, aggregate: @aggregate, order_by: order_by, direction: direction)
55
+ end
56
+
57
+ # THE DECISION THE GUIDE ASKS FOR, MADE EXPLICITLY: no compiled
58
+ # dialect of its own, same as Heki/Memory — a personal-scale local
59
+ # store answering by walking `all` is correct on day one, and
60
+ # nothing about a browser tab's own data volume asks for pushdown.
61
+ def query(specification, args = {}, context: {})
62
+ Ports::Query::InMemory.execute(all, specification, args, registry: context[:registry])
63
+ end
64
+
65
+ def append(entry)
66
+ @entries << entry
67
+ entry
68
+ end
69
+
70
+ def project(entry)
71
+ if entry.save?
72
+ @records[entry.id] = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state.dup)
73
+ else
74
+ @records.delete(entry.id)
75
+ end
76
+ end
77
+
78
+ def save(instance)
79
+ entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
80
+ append(entry)
81
+ project(entry)
82
+ end
83
+
84
+ def atomic_put(entry, insert_only: false)
85
+ exists = @records.key?(entry.id.to_s)
86
+ return :conflicted if insert_only && exists
87
+
88
+ status = exists ? :replaced : :inserted
89
+ append(entry)
90
+ project(entry)
91
+ status
92
+ end
93
+
94
+ def delete(id)
95
+ entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
96
+ append(entry)
97
+ project(entry)
98
+ end
99
+
100
+ def record_event(event) = @events << event
101
+
102
+ def entries = @entries.dup
103
+
104
+ def reset!
105
+ @records = {}
106
+ @events = []
107
+ @entries = []
108
+ self
109
+ end
110
+
111
+ # NOT lineage_capable? — deliberately absent, the same trade Heki
112
+ # makes and states plainly (writing-an-adapter.md's own section on
113
+ # it): a domain bound here has no edge for its own shape to travel
114
+ # across if it ever changes; that must be hand-migrated, or the
115
+ # shape must not change. A browser-local personal store is exactly
116
+ # the small-adapter case that guide names as a fine place to make
117
+ # that trade.
118
+ end
119
+ end
120
+ end
@@ -5,6 +5,13 @@ require_relative "../../runtime/instance"
5
5
 
6
6
  module Hecks
7
7
  module Adapters
8
+ # The default, no-external-dependency persistence/query adapter — a
9
+ # plain in-process Hash of `Runtime::Instance` records per aggregate,
10
+ # with an append-only `@entries`/`@events` log alongside it. Every
11
+ # behaviors suite must resolve to this one (see
12
+ # Behaviors::Expectations#guard_memory_only!) precisely because a fresh
13
+ # instance really is a fresh store, with nothing shared across tests or
14
+ # tenants.
8
15
  class Memory
9
16
  # TENANT-CAPABLE TRIVIALLY — see Runtime::TenantCheck's own header
10
17
  # for the full reasoning. `@records` is a plain instance variable;
@@ -15,13 +22,14 @@ module Hecks
15
22
  def self.tenant_capable? = true
16
23
  def persistence_capabilities = [:atomic_put]
17
24
 
18
- attr_reader :aggregate
25
+ attr_reader :aggregate, :events
19
26
 
20
27
  def initialize(aggregate:, settings: {}, root: nil)
21
28
  @aggregate = aggregate
22
29
  @records = {}
23
30
  @events = []
24
31
  @entries = []
32
+ @outbox = []
25
33
  end
26
34
 
27
35
  def find(id) = @records[id.to_s]
@@ -75,8 +83,6 @@ module Hecks
75
83
 
76
84
  def record_event(event) = @events << event
77
85
 
78
- def events = @events
79
-
80
86
  def entries = @entries.dup
81
87
 
82
88
  # Every other driven adapter (Postgres/PostgresEra/Sqlite/D1)
@@ -96,8 +102,47 @@ module Hecks
96
102
  @records = {}
97
103
  @events = []
98
104
  @entries = []
105
+ @outbox = []
99
106
  self
100
107
  end
108
+
109
+ # No rollback here — a Hash has no transaction to join. Memory
110
+ # implements `transaction` so `Interpreting#run_dispatch_order` has
111
+ # one shape to call, and the outbox so a spec can watch rows move
112
+ # pending → claimed → delivered without a database (the same
113
+ # reason Memory records `events`). See `Runtime::Outbox`.
114
+ def transaction = yield
115
+
116
+ def outbox_enqueue(rows)
117
+ rows.filter_map do |row|
118
+ next nil if @outbox.any? { |held| held.delivery_id == row.delivery_id }
119
+
120
+ row.id = @outbox.size + 1
121
+ @outbox << row
122
+ row
123
+ end
124
+ end
125
+
126
+ def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
127
+ row = @outbox.find { |held| held.id == id }
128
+ return false unless row&.pending?
129
+
130
+ row.status = "claimed"
131
+ row.attempts += 1
132
+ true
133
+ end
134
+
135
+ def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
136
+ row = @outbox.find { |held| held.id == id } or return false
137
+ row.status = status.to_s
138
+ row.error = error
139
+ true
140
+ end
141
+
142
+ def outbox_rows(status: nil)
143
+ rows = status ? @outbox.select { |row| row.status == status.to_s } : @outbox
144
+ rows.map(&:dup)
145
+ end
101
146
  end
102
147
  end
103
148
  end
@@ -20,7 +20,9 @@ module Hecks
20
20
  { name: attribute.name, attribute: attribute, sql_type: sql_type(attribute) }
21
21
  end
22
22
  lifecycle = @aggregate.lifecycle
23
- fields << { name: lifecycle.field, attribute: nil, sql_type: "text" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
23
+ fields << { name: lifecycle.field, attribute: nil, sql_type: "text" } if lifecycle && fields.none? do |field|
24
+ field[:name] == lifecycle.field
25
+ end
24
26
  # `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO — see
25
27
  # Sqlite::Codec#persisted_fields' own comment; identical reasoning,
26
28
  # `text` to match this file's own lowercase SQL type spelling.
@@ -0,0 +1,100 @@
1
+ require "json"
2
+ require_relative "../../../runtime/outbox"
3
+
4
+ module Hecks
5
+ module Adapters
6
+ # THE OUTBOX, POSTGRES-SHAPED — shared verbatim by `Postgres` and the
7
+ # era plugin's `PostgresEra`, the same way their `events` and
8
+ # `hecks_saga_instances` DDL is copied between them: nothing here is
9
+ # lineage-specific. Needs `@db` (a `PG::Connection`) and `table`
10
+ # (the aggregate's storage name) from the including class. See
11
+ # `Runtime::Outbox` for what the four verbs mean, and Sqlite's copy
12
+ # for the SQL idioms (`ON CONFLICT DO NOTHING` = idempotent enqueue,
13
+ # `WHERE status = 'pending'` = the compare-and-set claim).
14
+ module PostgresOutbox
15
+ # RE-ENTRANT — `Interpreting#run_dispatch_order` opens one
16
+ # transaction around save+emit+outbox and the adapter's own
17
+ # `append`/`atomic_put`/`delete` each open theirs; PG refuses
18
+ # BEGIN inside BEGIN, so an inner call joins the open one.
19
+ def transaction(&)
20
+ return yield unless @db.transaction_status == PG::PQTRANS_IDLE
21
+
22
+ @db.transaction(&)
23
+ rescue PG::ConnectionBad
24
+ reconnect!
25
+ raise
26
+ end
27
+
28
+ def outbox_enqueue(rows)
29
+ rows.filter_map do |row|
30
+ result = pg_exec_params(
31
+ "INSERT INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, status, attempts) " \
32
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0) ON CONFLICT (delivery_id) DO NOTHING RETURNING id",
33
+ [row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer, JSON.generate(row.event)]
34
+ )
35
+ next nil if result.ntuples.zero?
36
+
37
+ row.id = result[0]["id"].to_i
38
+ row.status = "pending"
39
+ row
40
+ end
41
+ end
42
+
43
+ def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
44
+ pg_exec_params(
45
+ "UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = now() " \
46
+ "WHERE id = $1 AND status = 'pending'",
47
+ [id]
48
+ ).cmd_tuples == 1
49
+ end
50
+
51
+ def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
52
+ pg_exec_params(
53
+ "UPDATE hecks_outbox SET status = $2, error = $3, settled_at = now() WHERE id = $1",
54
+ [id, status.to_s, error]
55
+ ).cmd_tuples == 1
56
+ end
57
+
58
+ def outbox_rows(status: nil)
59
+ sql = "SELECT * FROM hecks_outbox WHERE aggregate = $1"
60
+ binds = [table]
61
+ if status
62
+ sql << " AND status = $2"
63
+ binds << status.to_s
64
+ end
65
+ pg_exec_params("#{sql} ORDER BY id", binds).map do |row|
66
+ Runtime::Outbox::Row.new(
67
+ id: row["id"].to_i, delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
68
+ domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
69
+ event: JSON.parse(row["event"], symbolize_names: true), status: row["status"],
70
+ attempts: row["attempts"].to_i, error: row["error"]
71
+ )
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def create_outbox_table!
78
+ @db.exec(<<~SQL)
79
+ CREATE TABLE IF NOT EXISTS hecks_outbox (
80
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
81
+ delivery_id text NOT NULL UNIQUE,
82
+ event_uid text NOT NULL,
83
+ aggregate text NOT NULL,
84
+ domain text NOT NULL,
85
+ kind text NOT NULL,
86
+ consumer text NOT NULL,
87
+ event jsonb NOT NULL,
88
+ status text NOT NULL DEFAULT 'pending',
89
+ attempts integer NOT NULL DEFAULT 0,
90
+ error text,
91
+ enqueued_at timestamptz NOT NULL DEFAULT now(),
92
+ claimed_at timestamptz,
93
+ settled_at timestamptz
94
+ )
95
+ SQL
96
+ @db.exec("CREATE INDEX IF NOT EXISTS idx_hecks_outbox_status ON hecks_outbox(aggregate, status)")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,57 @@
1
+ module Hecks
2
+ module Adapters
3
+ # SELF-HEALING CONNECTION — shared verbatim by `Postgres` and the era
4
+ # plugin's `PostgresEra`, the same way `PostgresOutbox` (outbox.rb) is:
5
+ # nothing here is lineage-specific, and `PostgresOutbox`'s own
6
+ # `@db.exec*` calls route through this module's `pg_exec`/
7
+ # `pg_exec_params` too, since both classes mix it in. Needs `@db` (a
8
+ # `PG::Connection`), `@aggregate`, and `@settings` from the including
9
+ # class — the same three `self.class.connect_for(@aggregate.name,
10
+ # @settings)` already needs to build one in the first place.
11
+ #
12
+ # A backend killed out from under an adapter (a DBA's own
13
+ # `pg_terminate_backend`, a load balancer's failover, a restart) —
14
+ # chaos-tested against the plain `Postgres` adapter: `PG::ConnectionBad`
15
+ # on the query that hit it, and PERMANENTLY on every query after,
16
+ # since nothing ever replaced `@db` with a live connection.
17
+ # `pg_exec`/`pg_exec_params` are the two primitives every other method
18
+ # in either class funnels through — wrapping them here, once,
19
+ # self-heals `@db` for the NEXT caller. THE CURRENT CALL STILL
20
+ # RAISES — reconnecting cannot tell a caller whether ITS OWN write
21
+ # reached the server before the connection died, so silently
22
+ # retrying it here could silently double it; that ambiguity is
23
+ # exactly why `Runtime::SagaInterpreter`'s own defect-retry exists
24
+ # ONE LAYER UP, where a dispatch is retried as a whole (fresh
25
+ # hydrate, fresh `given`s), not as a lone SQL statement.
26
+ module PostgresReconnect
27
+ def pg_exec(sql)
28
+ @db.exec(sql)
29
+ rescue PG::ConnectionBad
30
+ reconnect!
31
+ raise
32
+ end
33
+
34
+ def pg_exec_params(sql, binds)
35
+ @db.exec_params(sql, binds)
36
+ rescue PG::ConnectionBad
37
+ reconnect!
38
+ raise
39
+ end
40
+
41
+ private
42
+
43
+ # BEST-EFFORT — a reconnect attempt that itself fails (the server
44
+ # is actually down, not just this one backend) leaves `@db`
45
+ # unchanged; the `PG::ConnectionBad` already being re-raised by
46
+ # `pg_exec`/`pg_exec_params` above still reaches the caller either
47
+ # way, so swallowing a failed RECONNECT attempt here loses no
48
+ # information — it only avoids masking the original error with a
49
+ # second one.
50
+ def reconnect!
51
+ @db = self.class.connect_for(@aggregate.name, @settings)
52
+ rescue PG::Error
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end