hecks 1.0.2 → 1.1.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 (239) 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 +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -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: e2957772a5d25ada566a632461d6aa1829b6fb8016d4b563377ba7c856c1ed7d
4
+ data.tar.gz: 0e92b2b30420c37c8b92c087e26873223e47cdadcf88d7cbed1583b4a39729ee
5
5
  SHA512:
6
- metadata.gz: 9b65062cd42566cb0f2e4d5c45b12b1990133850964e117a6827b917d5b293245855a292ccd698c12d982de268a1554029a7a079a03c2cd7e99148eba1f7af47
7
- data.tar.gz: 9b98481d47cfcbd09e72e5d53226287fbed298d2d1ae77ebbd48b133c7fb9100be1531daca0be92728976ef11eb87979e5847306b4eed628308dfebfdf83014f
6
+ metadata.gz: 7a9641aeac769fa7e43131942276f8d5acf121daa2adc5b9d9e1375d5f509588ed3a5bd09b6995bb4c1214dd074dfe27666e8490a44e53f7042131a22f3fd8cd
7
+ data.tar.gz: 6cfb8e4e7852d8cbeaf417cf20ae952a94df5d3c12acf79cb8f4c4a0e454ca01a1e04d17956fa2de4f359776f3b6c3b66b199a0fa6eb8fcf7b4389bcdfc834d8
@@ -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,97 @@
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
+ end
24
+
25
+ def outbox_enqueue(rows)
26
+ rows.filter_map do |row|
27
+ result = @db.exec_params(
28
+ "INSERT INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, status, attempts) " \
29
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0) ON CONFLICT (delivery_id) DO NOTHING RETURNING id",
30
+ [row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer, JSON.generate(row.event)]
31
+ )
32
+ next nil if result.ntuples.zero?
33
+
34
+ row.id = result[0]["id"].to_i
35
+ row.status = "pending"
36
+ row
37
+ end
38
+ end
39
+
40
+ def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
41
+ @db.exec_params(
42
+ "UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = now() " \
43
+ "WHERE id = $1 AND status = 'pending'",
44
+ [id]
45
+ ).cmd_tuples == 1
46
+ end
47
+
48
+ def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
49
+ @db.exec_params(
50
+ "UPDATE hecks_outbox SET status = $2, error = $3, settled_at = now() WHERE id = $1",
51
+ [id, status.to_s, error]
52
+ ).cmd_tuples == 1
53
+ end
54
+
55
+ def outbox_rows(status: nil)
56
+ sql = "SELECT * FROM hecks_outbox WHERE aggregate = $1"
57
+ binds = [table]
58
+ if status
59
+ sql << " AND status = $2"
60
+ binds << status.to_s
61
+ end
62
+ @db.exec_params("#{sql} ORDER BY id", binds).map do |row|
63
+ Runtime::Outbox::Row.new(
64
+ id: row["id"].to_i, delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
65
+ domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
66
+ event: JSON.parse(row["event"], symbolize_names: true), status: row["status"],
67
+ attempts: row["attempts"].to_i, error: row["error"]
68
+ )
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def create_outbox_table!
75
+ @db.exec(<<~SQL)
76
+ CREATE TABLE IF NOT EXISTS hecks_outbox (
77
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
78
+ delivery_id text NOT NULL UNIQUE,
79
+ event_uid text NOT NULL,
80
+ aggregate text NOT NULL,
81
+ domain text NOT NULL,
82
+ kind text NOT NULL,
83
+ consumer text NOT NULL,
84
+ event jsonb NOT NULL,
85
+ status text NOT NULL DEFAULT 'pending',
86
+ attempts integer NOT NULL DEFAULT 0,
87
+ error text,
88
+ enqueued_at timestamptz NOT NULL DEFAULT now(),
89
+ claimed_at timestamptz,
90
+ settled_at timestamptz
91
+ )
92
+ SQL
93
+ @db.exec("CREATE INDEX IF NOT EXISTS idx_hecks_outbox_status ON hecks_outbox(aggregate, status)")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -17,7 +17,7 @@ module Hecks
17
17
  def create_aggregate_table!
18
18
  columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
19
19
  @db.exec(
20
- "CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{columns.empty? ? '' : ', '}#{columns.join(', ')})"
20
+ "CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
21
21
  )
22
22
  # SELF-HEALING, SAME IDIOM AS `ensure_indexes!` BELOW —
23
23
  # `CREATE TABLE IF NOT EXISTS` above does NOT retroactively add a
@@ -84,7 +84,8 @@ module Hecks
84
84
  # — the same idiom `rust/host/src/journal.rs`'s own
85
85
  # `sagas_backfilled` column addition already uses, for the
86
86
  # identical reason.
87
- @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb")
87
+ @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb " \
88
+ "NOT NULL DEFAULT '[]'::jsonb")
88
89
  end
89
90
 
90
91
  def sql_type(attr)