hecks 2.6.0 → 2.8.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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -0,0 +1,132 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Hecks
6
+ module Runtime
7
+ module EraCheck
8
+ # The read side of the era gate, for a deploy operator rather than a boot:
9
+ # asks a running host which era it reports at `GET /version` and compares
10
+ # it with an allow-list file of the eras that should be running.
11
+ #
12
+ # `EraCheck.check!` decides at boot, against the store, whether a host may
13
+ # run on an era. This answers the question after a roll, from outside:
14
+ # did the host that came up report an era the operator expects? The two
15
+ # never share state. The host's own `GET /version` document
16
+ # (`rust/host/src/server.rs`, `version_body`) is the only input, so a
17
+ # rollback to an older image, or a fork of the journal back onto an older
18
+ # era, shows up here as an era missing from the list.
19
+ #
20
+ # ## The allow-list file
21
+ #
22
+ # One era id per line. Blank lines and lines starting with `#` are
23
+ # ignored, and so is a trailing ` # comment`. A file that lists no era
24
+ # means "only check that the host reports one", so a first roll can be
25
+ # checked before any era id is known. The generated `expected-era` file
26
+ # (`Deploy::Scripts`) has this shape.
27
+ #
28
+ # `bin/check_era` is the command-line wrapper.
29
+ module ExpectedEra
30
+ # The host could not be reached, or did not answer with a `200`.
31
+ class Unreachable < StandardError; end
32
+
33
+ # The host answered, but not with a `/version` document carrying an era.
34
+ class BadResponse < StandardError; end
35
+
36
+ # What a comparison concluded.
37
+ #
38
+ # @!attribute [r] status
39
+ # @return [Symbol] `:match`, `:unlisted` (no era on the list, one reported) or `:mismatch`
40
+ # @!attribute [r] era
41
+ # @return [String] the era the host reported
42
+ # @!attribute [r] allowed
43
+ # @return [Array<String>] the eras the list accepts
44
+ Verdict = Struct.new(:status, :era, :allowed) do
45
+ # Tells whether the roll is acceptable.
46
+ #
47
+ # @return [Boolean] true unless the reported era is off the list
48
+ def ok? = status != :mismatch
49
+ end
50
+
51
+ module_function
52
+
53
+ # Reads the eras out of an allow-list file's text.
54
+ #
55
+ # @param text [String] the file's contents
56
+ # @return [Array<String>] the era ids in file order, without comments or blanks
57
+ def parse(text)
58
+ text.each_line.filter_map do |line|
59
+ entry = line.strip.sub(/\s+#.*\z/, "")
60
+ entry unless entry.empty? || entry.start_with?("#")
61
+ end
62
+ end
63
+
64
+ # Builds the `/version` URL from what an operator gave.
65
+ #
66
+ # @param url [String] a host's base URL, or its `/version` URL itself
67
+ # @return [String] the URL to request
68
+ def version_url(url)
69
+ base = url.to_s.chomp("/")
70
+ base.end_with?("/version") ? base : "#{base}/version"
71
+ end
72
+
73
+ # Asks a host which era it reports.
74
+ #
75
+ # @param url [String] the host's base URL, or its `/version` URL
76
+ # @param timeout [Numeric] seconds allowed for connecting and for reading
77
+ # @return [String] the `era` the host's `/version` document carries
78
+ # @raise [Unreachable] if the request fails or the status is not `200`
79
+ # @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
80
+ def fetch_era(url, timeout: 10)
81
+ response = get(version_url(url), timeout)
82
+ raise Unreachable, "#{version_url(url)} answered #{response.code}" unless response.code == "200"
83
+
84
+ era = JSON.parse(response.body)["era"]
85
+ raise BadResponse, "#{version_url(url)} carries no era" unless era.is_a?(String) && !era.empty?
86
+
87
+ era
88
+ rescue JSON::ParserError, TypeError
89
+ raise BadResponse, "#{version_url(url)} did not answer a JSON version document"
90
+ end
91
+
92
+ # Compares a reported era with an allow-list.
93
+ #
94
+ # @param era [String] the era the host reported
95
+ # @param allowed [Array<String>] the eras the list accepts; empty accepts any
96
+ # @return [Verdict] `:unlisted` when the list is empty, else `:match` or `:mismatch`
97
+ def verdict(era, allowed)
98
+ status = if allowed.empty? then :unlisted
99
+ elsif allowed.include?(era) then :match
100
+ else :mismatch
101
+ end
102
+ Verdict.new(status, era, allowed)
103
+ end
104
+
105
+ # Fetches the host's era and compares it with an allow-list file.
106
+ #
107
+ # @param url [String] the host's base URL, or its `/version` URL
108
+ # @param file [String] path of the allow-list file
109
+ # @param timeout [Numeric] seconds allowed for connecting and for reading
110
+ # @return [Verdict] the comparison
111
+ # @raise [Errno::ENOENT] if the allow-list file does not exist
112
+ # @raise [Unreachable] see `fetch_era`
113
+ # @raise [BadResponse] see `fetch_era`
114
+ def check(url, file, timeout: 10)
115
+ allowed = parse(File.read(file))
116
+ verdict(fetch_era(url, timeout: timeout), allowed)
117
+ end
118
+
119
+ def get(url, timeout)
120
+ uri = URI.parse(url)
121
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
122
+ open_timeout: timeout, read_timeout: timeout) do |http|
123
+ http.request(Net::HTTP::Get.new(uri.request_uri, "Accept" => "application/json"))
124
+ end
125
+ rescue SystemCallError, SocketError, Timeout::Error, IOError, URI::InvalidURIError => e
126
+ raise Unreachable, "#{url} could not be reached: #{e.message}"
127
+ end
128
+ private_class_method :get
129
+ end
130
+ end
131
+ end
132
+ end
@@ -159,7 +159,7 @@ module Hecks
159
159
  # aware, same as `apply_drop` above (`apply_backfill`'s own
160
160
  # header) — a bare name was the only shape this ever needed
161
161
  # until a value object gained new required members with zero
162
- # source data of their own (found live: lifeadelics' Attendee
162
+ # source data of their own (found live: a client site's Attendee
163
163
  # redesign, commit 4326dcd).
164
164
  @backfills.each { |backfill| apply_backfill(state, backfill) }
165
165
  Entry.new(operation: entry.operation, id: entry.id, state: state, mirrors: entry.mirrors)
@@ -24,7 +24,7 @@ module Hecks
24
24
  # the domain holds none; keys are `:ordinal` (Integer), `:hash` and `:label` (String,
25
25
  # nil until `mint_name!` names the era), `:held_text` (String) and `:watermark`
26
26
  # (Integer journal ordinal the era was cut at, nil for era 1)
27
- # @raise [Runtime::WiringError] if a held text no longer matches its stored digest
27
+ # @raise [Runtime::WiringError] if a held text does not match its stored digest
28
28
  def eras
29
29
  @db.exec_params(
30
30
  "SELECT ordinal, hash, label, held_text, watermark, held_digest, held_projection::text " \
@@ -153,7 +153,7 @@ module Hecks
153
153
  # Reports the ordinal of the newest era the domain holds.
154
154
  #
155
155
  # @return [Integer] the highest held ordinal, or 1 when no era is held yet
156
- # @raise [Runtime::WiringError] if a held text no longer matches its stored digest
156
+ # @raise [Runtime::WiringError] if a held text does not match its stored digest
157
157
  def current_era
158
158
  held = eras
159
159
  held.empty? ? 1 : held.last[:ordinal]
@@ -553,7 +553,7 @@ module Hecks
553
553
  # `full:` forces a rebuild from the raw journal. The tail-merge
554
554
  # needs it: it moves every watermark to the new tip, so every
555
555
  # ancestor matview's cut goes stale in the same statement, and
556
- # layering on one would carry a cut that no longer exists.
556
+ # layering on one would carry a cut that does not exist.
557
557
  #
558
558
  # The live half reads the snapshot table, not `WHERE era = era AND
559
559
  # aggregate = name` over the raw journal — that would be a
@@ -107,7 +107,7 @@ module Hecks
107
107
  # --approve` and bound to what was actually reviewed: this
108
108
  # edge's parsed content, and the journal as it stood when the
109
109
  # samples were read. A journal that has advanced past the
110
- # review invalidates it — the approved samples no longer cover
110
+ # review invalidates it — the approved samples do not cover
111
111
  # the data.
112
112
  #
113
113
  # @param bluebook [Bluebook::Chapter] the domain, named in the refusal
@@ -64,7 +64,7 @@ module Hecks
64
64
  # real, live gap for any new required value-object member with no
65
65
  # source data at all — `compute` cannot fill it either, since its
66
66
  # own guard requires a real, already-present field to consume
67
- # (found live: lifeadelics' Attendee redesign, commit 4326dcd,
67
+ # (found live: a client site's Attendee redesign, commit 4326dcd,
68
68
  # needed exactly this and had nothing that worked).
69
69
  declared.backfills.each do |backfill|
70
70
  expression = compile_backfill(expression, backfill)
@@ -120,23 +120,29 @@ module Hecks
120
120
 
121
121
  # A compute is the one rule whose SQL is its only implementation
122
122
  # — evaluated exclusively inside the compiled head, never
123
- # in-process. The old field is exposed under its own name (as
124
- # text, exactly as the author's expression expects to cast it).
123
+ # in-process. The source is read as a path through
124
+ # `hecks_tr_extract`, bare or a dotted value-object member alike,
125
+ # the same way the destination goes through `path_literal`. The
126
+ # source's value is exposed to the author's SQL as text under a
127
+ # column named by the source exactly as declared (`price_cents`,
128
+ # or `"price.cents"` for a dotted member), and the whole record
129
+ # stays readable as `__s`.
125
130
  #
126
131
  # @param expression [String] the SQL expression built so far by `compile_rules`, read as
127
132
  # `__s` inside the compute's own SQL
128
133
  # @param compute [Bluebook::TranslationCompute] the declared compute rule
129
134
  # @return [String] `expression` wrapped so the compute's field lands at its declared
130
- # destination when the source field is present, unchanged otherwise
135
+ # destination when the source path is present, unchanged otherwise
131
136
  def compile_compute(expression, compute)
132
137
  from = compute.from.to_s
133
138
  to = compute.to.to_s
134
- "(SELECT CASE WHEN __s ? #{text_literal(from)} THEN " \
135
- "hecks_tr_insert(__s - #{text_literal(from)}, #{path_literal(to)}, to_jsonb((#{compute.sql})), " \
139
+ "(SELECT CASE WHEN (__x).present THEN " \
140
+ "hecks_tr_insert((__x).remaining, #{path_literal(to)}, to_jsonb((#{compute.sql})), " \
136
141
  "#{text_literal("compute #{from} to: #{to}")}) " \
137
142
  "ELSE __s END " \
138
143
  "FROM (SELECT (#{expression}) AS __s) __outer, " \
139
- "LATERAL (SELECT (__s ->> #{text_literal(from)}) AS #{quote(from)}) __fields)"
144
+ "LATERAL (SELECT hecks_tr_extract(__s, #{path_literal(from)}) AS __x) __extract, " \
145
+ "LATERAL (SELECT (__s #>> #{path_literal(from)}) AS #{quote(from)}) __fields)"
140
146
  end
141
147
 
142
148
  # A newly added, required attribute with no source at all — the
@@ -12,10 +12,13 @@ module Hecks
12
12
  module Persistence
13
13
  module Plugins
14
14
  # ADR 0033 — requiring this file is installing the plugin. Nothing in
15
- # Hecks core requires it; an app that binds `PostgresEra`, or wants
16
- # schema-translation support at all, requires it explicitly — the
17
- # same shape every adapter-specific spec fixture already uses to
18
- # load one particular `.adapter` file rather than all of them.
15
+ # Hecks core requires it. A boot loads it on demand for a domain that
16
+ # binds `PostgresEra` (`Runtime::Loader.load_bound_adapters!` resolves the
17
+ # adapter, which autoloads this file, before the gates are collected). An
18
+ # app that wants schema-translation support without binding `PostgresEra`
19
+ # requires it explicitly, the same shape every adapter-specific spec
20
+ # fixture already uses to load one particular `.adapter` file rather than
21
+ # all of them.
19
22
  module Era
20
23
  module_function
21
24
 
@@ -0,0 +1,73 @@
1
+ require "uri"
2
+
3
+ module Hecks
4
+ module Ports
5
+ module Persistence
6
+ class PostgresDump
7
+ # A Postgres connection URL, split so a password reaches the tools run
8
+ # against it through their environment and never on a command line, where
9
+ # any user of the machine can read it from the process list.
10
+ class Connection
11
+ # @param url [String] a `postgres://` or `postgresql://` URL
12
+ # @raise [PostgresDump::Error] if `url` is not a Postgres URL
13
+ def initialize(url)
14
+ @uri = URI.parse(url)
15
+ raise Error, "not a postgres URL" unless %w[postgres postgresql].include?(@uri.scheme)
16
+ end
17
+
18
+ # Names the database the URL points at.
19
+ #
20
+ # @return [String] the database name, empty when the URL names none
21
+ def database
22
+ @uri.path.to_s.delete_prefix("/")
23
+ end
24
+
25
+ # Points the same server and credentials at another database.
26
+ #
27
+ # @param name [String] the database to connect to instead
28
+ # @return [Connection] a new connection; this one is unchanged
29
+ def with_database(name)
30
+ copy = @uri.dup
31
+ copy.path = "/#{name}"
32
+ self.class.new(copy.to_s)
33
+ end
34
+
35
+ # Lists the connection settings the URL carries, in `PG.connect` keyword form.
36
+ #
37
+ # @return [Hash{Symbol => String, Integer}] `host`, `port`, `user`, `password`,
38
+ # `dbname` and `sslmode`, each only when the URL gives one; the user and password
39
+ # are percent-decoded
40
+ def settings
41
+ {
42
+ host: (@uri.host unless @uri.host.to_s.empty?), port: @uri.port,
43
+ user: decode(@uri.user), password: decode(@uri.password),
44
+ dbname: (database unless database.empty?), sslmode: URI.decode_www_form(@uri.query.to_s).to_h["sslmode"]
45
+ }.compact
46
+ end
47
+
48
+ # Builds the libpq environment variables for a child process.
49
+ #
50
+ # @return [Hash{String => String}] `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`,
51
+ # `PGDATABASE` and `PGSSLMODE`, each only when `settings` has the matching key
52
+ def env
53
+ settings.to_h { |key, value| ["PG#{key.to_s.sub('dbname', 'database').upcase}", value.to_s] }
54
+ end
55
+
56
+ # Opens a connection with the `pg` gem.
57
+ #
58
+ # @return [PG::Connection] an open connection; the caller closes it
59
+ def connect
60
+ require "pg"
61
+ PG.connect(**settings)
62
+ end
63
+
64
+ private
65
+
66
+ def decode(value)
67
+ value && URI.decode_www_form_component(value)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,125 @@
1
+ require "open3"
2
+ require "securerandom"
3
+ require_relative "postgres_dump/connection"
4
+
5
+ module Hecks
6
+ module Ports
7
+ module Persistence
8
+ # Dumps one schema of a Postgres database and proves the dump restores.
9
+ #
10
+ # ## What the proof is
11
+ #
12
+ # A dump nobody has restored is a hope. After `pg_dump` writes the file,
13
+ # the dump is restored into a scratch database (on the source server, or on a
14
+ # separate `verify_url` server such as the one the dump will be loaded
15
+ # into) and every table's row count is compared with the source. That shows
16
+ # the dump is complete and loadable. It does not re-derive current state
17
+ # from an event journal, so a caller who needs that proves it separately.
18
+ #
19
+ # ## Credentials
20
+ #
21
+ # URLs are split by `Connection`: the password travels in the child
22
+ # process's environment, never in `argv`.
23
+ #
24
+ # ## Requirements
25
+ #
26
+ # The `pg` gem, and `pg_dump` and `pg_restore` on `PATH` at a version no older
27
+ # than the server's. Neither the gem nor the tools are a dependency of
28
+ # hecks; nothing here loads until a dump is asked for.
29
+ class PostgresDump
30
+ # Raised for a refusal or a failed tool.
31
+ class Error < StandardError; end
32
+
33
+ # What a verified dump holds.
34
+ #
35
+ # @!attribute [r] tables
36
+ # @return [Hash{String => Integer}] row count of every table in the schema, by name
37
+ Result = Struct.new(:tables, keyword_init: true)
38
+
39
+ # @param url [String] the source database's `postgres://` URL
40
+ # @param schema [String] the schema to dump, a plain identifier
41
+ # @param verify_url [String, nil] the server to restore into for the proof; the source
42
+ # server when nil
43
+ # @raise [Error] if `schema` is not a plain identifier or a URL is not a Postgres URL
44
+ def initialize(url:, schema:, verify_url: nil)
45
+ raise Error, "schema #{schema.inspect} is not a plain identifier" unless schema.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)
46
+
47
+ @schema = schema
48
+ @source = Connection.new(url)
49
+ @verify = Connection.new(verify_url || url)
50
+ end
51
+
52
+ # Writes a custom-format dump of the schema and proves it restores.
53
+ #
54
+ # @param dest [String] the file to write
55
+ # @return [Result] the verified row counts
56
+ # @raise [Error] if the schema has no tables, a tool fails, the database refuses, or
57
+ # the restored counts differ from the source's
58
+ def call(dest)
59
+ require "pg"
60
+ raise Error, "schema #{@schema} has no tables in #{@source.database}" if counting(@source) { |db| counts(db) }.empty?
61
+
62
+ run("pg_dump", @source, "--format=custom", "--no-owner", "--no-privileges", "--schema=#{@schema}", "--file=#{dest}")
63
+ expected = counting(@source) { |db| counts(db) }
64
+ verify_restore(dest, expected)
65
+ Result.new(tables: expected)
66
+ rescue PG::Error => e
67
+ raise Error, "database error (#{e.class}): #{e.message.lines.first.to_s.strip}"
68
+ end
69
+
70
+ private
71
+
72
+ def verify_restore(dump, expected)
73
+ scratch = "dump_verify_#{Process.pid}_#{SecureRandom.hex(3)}"
74
+ counting(@verify) do |admin|
75
+ admin.exec("CREATE DATABASE #{admin.quote_ident(scratch)}")
76
+ begin
77
+ target = @verify.with_database(scratch)
78
+ run("pg_restore", target, "--no-owner", "--no-privileges", "--exit-on-error", "--dbname=#{scratch}", dump)
79
+ actual = counting(target) { |db| counts(db) }
80
+ raise Error, mismatch(expected, actual) unless actual == expected
81
+ ensure
82
+ drop(admin, scratch)
83
+ end
84
+ end
85
+ end
86
+
87
+ def counting(connection)
88
+ db = connection.connect
89
+ yield db
90
+ ensure
91
+ db&.close
92
+ end
93
+
94
+ def counts(db)
95
+ names = db.exec_params("SELECT table_name FROM information_schema.tables " \
96
+ "WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY 1", [@schema])
97
+ .map { |row| row["table_name"] }
98
+ names.to_h do |name|
99
+ [name, db.exec("SELECT count(*) FROM #{db.quote_ident(@schema)}.#{db.quote_ident(name)}").getvalue(0, 0).to_i]
100
+ end
101
+ end
102
+
103
+ def mismatch(expected, actual)
104
+ differing = (expected.keys | actual.keys).reject { |name| expected[name] == actual[name] }
105
+ detail = differing.map { |name| "#{name} (source #{expected[name].inspect}, restored #{actual[name].inspect})" }
106
+ "the restored dump disagrees with the source for #{detail.join(', ')}; " \
107
+ "if the database took writes during the dump, dump it again"
108
+ end
109
+
110
+ def drop(admin, scratch)
111
+ admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(scratch)} WITH (FORCE)")
112
+ rescue PG::Error => e
113
+ warn "could not drop scratch database #{scratch} (#{e.class}); drop it by hand"
114
+ end
115
+
116
+ def run(tool, connection, *)
117
+ _out, err, status = Open3.capture3(connection.env, tool, *)
118
+ raise Error, "#{tool} failed: #{err.lines.first(3).join.strip}" unless status.success?
119
+ rescue Errno::ENOENT
120
+ raise Error, "#{tool} is not on PATH"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -30,8 +30,8 @@ module Hecks
30
30
  # lacks `append`, `project` or `entries`
31
31
  def build(registry, domain, aggregate, bind, recover: true, settings_verb: VERB)
32
32
  registry.check_verb(bind)
33
- settings = (registry.world(domain)&.for_binding(settings_verb, bind.adapter) || {})
34
- .reject { |key, _| key.to_sym == :role }
33
+ settings = registry.binding_settings(domain, settings_verb, bind.adapter)
34
+ .reject { |key, _| key.to_sym == :role }
35
35
  registry.check_settings(bind, settings)
36
36
  # The domain, the resolved era, and (for an old checkout) the era
37
37
  # that superseded it ride along after the declared-settings
@@ -44,7 +44,7 @@ module Hecks
44
44
  # drops a key, nil or not: a stored nil stays a stored nil.
45
45
  #
46
46
  # It never touches an undeclared key's value — a retired field, or a
47
- # member a value object no longer declares, is exactly what an Era
47
+ # member a value object does not declare, is exactly what an Era
48
48
  # translation (rename/move/drop) still has to read. Its key keeps its
49
49
  # spelling below the top level; at the top level every key is a
50
50
  # symbol, declared or not, because every adapter has always
@@ -17,6 +17,10 @@ require_relative "persistence/remote_runtime"
17
17
  require_relative "persistence/null_saga_store"
18
18
  require_relative "persistence/state_codec"
19
19
 
20
+ # Loaded on first mention: nothing about a domain's own boot needs the dump
21
+ # tooling, and it wants the optional `pg` gem.
22
+ Hecks::Ports::Persistence.autoload(:PostgresDump, File.join(__dir__, "persistence/postgres_dump"))
23
+
20
24
  module Hecks
21
25
  module Ports
22
26
  # Reopened once `BindingPolicy`/`RepositoryFactory` (required above) are