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.
- checksums.yaml +4 -4
- data/exe/hecks +13 -0
- data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +1 -1
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +2 -2
- data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
- data/lib/hecks/behaviors/expectations.rb +2 -2
- data/lib/hecks/bluebook/assembly/contract.rb +1 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
- data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
- data/lib/hecks/bluebook/assembly.rb +1 -1
- data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
- data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
- data/lib/hecks/bluebook/capabilities.rb +2 -2
- data/lib/hecks/bluebook/chapter.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
- data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
- data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/meta_validator.rb +1 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
- data/lib/hecks/bluebook/model_check.rb +13 -14
- data/lib/hecks/cache_dir.rb +103 -0
- data/lib/hecks/cli/document.rb +104 -0
- data/lib/hecks/cli/ir.rb +47 -0
- data/lib/hecks/cli/mcp.rb +32 -0
- data/lib/hecks/cli/mcp_door.rb +461 -0
- data/lib/hecks/cli/model_check.rb +270 -0
- data/lib/hecks/cli/project_cli.rb +123 -0
- data/lib/hecks/cli/project_diagrams.rb +43 -0
- data/lib/hecks/cli/run.rb +219 -0
- data/lib/hecks/cli/smoke_test.rb +59 -0
- data/lib/hecks/cli/stores.rb +54 -0
- data/lib/hecks/cli.rb +166 -0
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/facade/handle.rb +2 -2
- data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/mcp_door_scope.rb +176 -0
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence/state_codec.rb +1 -1
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +188 -139
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections/reference.rb +7 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/projector/exporter.rb +4 -4
- data/lib/hecks/projector/target.rb +1 -1
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/release/runner/ci_publisher.rb +96 -0
- data/lib/hecks/release/runner/commands.rb +68 -0
- data/lib/hecks/release/runner/console.rb +52 -0
- data/lib/hecks/release/runner/gem_publisher.rb +44 -0
- data/lib/hecks/release/runner/git.rb +48 -0
- data/lib/hecks/release/runner/npm_publisher.rb +89 -0
- data/lib/hecks/release/runner/preflight.rb +119 -0
- data/lib/hecks/release/runner/published.rb +55 -0
- data/lib/hecks/release/runner/tagger.rb +87 -0
- data/lib/hecks/release/runner.rb +244 -0
- data/lib/hecks/router/namespace_installer.rb +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +3 -3
- data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/entity_element.rb +1 -1
- data/lib/hecks/runtime/entity_interpreter.rb +1 -1
- data/lib/hecks/runtime/invocation.rb +1 -1
- data/lib/hecks/runtime/loader.rb +27 -3
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/reaction_invocation.rb +3 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
- data/lib/hecks/runtime/reference_hop.rb +4 -4
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +15 -13
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +22 -13
- data/lib/hecks/runtime/saga_interpreter.rb +1 -1
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/runtime/value/coercion.rb +3 -3
- data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
- data/lib/hecks/storehouse.rb +18 -12
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +5 -0
- data/lib/hecks.rb +8 -2
- metadata +72 -58
- data/lib/hecks/bench/cli.rb +0 -74
- data/lib/hecks/bench/environment.rb +0 -100
- data/lib/hecks/bench/postgres_probe.rb +0 -41
- data/lib/hecks/bench/report.rb +0 -116
- data/lib/hecks/bench/ruby_runner.rb +0 -151
- data/lib/hecks/bench/run.rb +0 -55
- data/lib/hecks/bench/rust_runner.rb +0 -165
- data/lib/hecks/bench/stats.rb +0 -75
- data/lib/hecks/bench/suite.rb +0 -172
- data/lib/hecks/bench/workload.rb +0 -137
- data/lib/hecks/bench.rb +0 -28
- data/lib/hecks/codemod.rb +0 -443
- data/lib/hecks/corpus.rb +0 -479
- data/lib/hecks/doc/reference.rb +0 -585
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
- data/lib/hecks/fuzzing/combination_miner.rb +0 -178
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
- data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
- data/lib/hecks/fuzzing/differential.rb +0 -192
- data/lib/hecks/fuzzing/domain_generator.rb +0 -871
- data/lib/hecks/fuzzing/era_boundary.rb +0 -154
- data/lib/hecks/fuzzing/form_census.rb +0 -285
- data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
- data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
- data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
- data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
- data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
- data/lib/hecks/fuzzing/properties/guards.rb +0 -398
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
- data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
- data/lib/hecks/fuzzing/properties/querying.rb +0 -260
- data/lib/hecks/fuzzing/properties.rb +0 -267
- data/lib/hecks/fuzzing/qa_settings.rb +0 -164
- data/lib/hecks/fuzzing/replay.rb +0 -930
- data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
- data/lib/hecks/fuzzing/self_consistency.rb +0 -825
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
- data/lib/hecks/fuzzing/shrinker.rb +0 -309
- data/lib/hecks/fuzzing/structural_skips.rb +0 -53
- data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
- data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
- data/lib/hecks/fuzzing/value_generator.rb +0 -340
- data/lib/hecks/fuzzing.rb +0 -23
- data/lib/hecks/grammar/evolve.rb +0 -524
- 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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
124
|
-
#
|
|
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
|
|
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
|
|
135
|
-
"hecks_tr_insert(
|
|
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
|
|
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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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 =
|
|
34
|
-
|
|
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
|
|
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
|