hecks 2.5.1 → 2.7.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bench/cli.rb +74 -0
  6. data/lib/hecks/bench/environment.rb +100 -0
  7. data/lib/hecks/bench/postgres_probe.rb +41 -0
  8. data/lib/hecks/bench/report.rb +116 -0
  9. data/lib/hecks/bench/ruby_runner.rb +151 -0
  10. data/lib/hecks/bench/run.rb +55 -0
  11. data/lib/hecks/bench/rust_runner.rb +165 -0
  12. data/lib/hecks/bench/stats.rb +75 -0
  13. data/lib/hecks/bench/suite.rb +172 -0
  14. data/lib/hecks/bench/workload.rb +137 -0
  15. data/lib/hecks/bench.rb +28 -0
  16. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  17. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  18. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  20. data/lib/hecks/bluebook/hexagon.rb +15 -7
  21. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  22. data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
  23. data/lib/hecks/bluebook/model_check.rb +21 -2
  24. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  25. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  26. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  27. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  28. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  29. data/lib/hecks/forms/value_object_shape.rb +1 -1
  30. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  31. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  32. data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
  33. data/lib/hecks/language/world/world.bluebook +32 -2
  34. data/lib/hecks/mcp_stdio_guard.rb +160 -0
  35. data/lib/hecks/ports/key_vault.rb +1 -1
  36. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  37. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  38. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  39. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  40. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  41. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  42. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  43. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  44. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  45. data/lib/hecks/ports/persistence.rb +4 -0
  46. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  47. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  48. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  49. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  50. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  51. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  52. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  53. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  54. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  55. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  56. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  57. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  58. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  59. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  60. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  61. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  62. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  63. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  64. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  65. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  66. data/lib/hecks/projections/deploy/preview.rb +103 -0
  67. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  68. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  69. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  70. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  71. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  72. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  73. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  74. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  75. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  76. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  77. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  78. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  79. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  80. data/lib/hecks/projections.rb +2 -0
  81. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  82. data/lib/hecks/release/gem_pin.rb +112 -0
  83. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  84. data/lib/hecks/runtime/loader.rb +26 -2
  85. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  86. data/lib/hecks/runtime/registry/verification.rb +4 -2
  87. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  88. data/lib/hecks/runtime/registry.rb +14 -9
  89. data/lib/hecks/runtime/tenant_check.rb +1 -1
  90. data/lib/hecks/vendoring/git_environment.rb +35 -0
  91. data/lib/hecks/vendoring/git_source.rb +102 -0
  92. data/lib/hecks/vendoring.rb +102 -0
  93. data/lib/hecks/version.rb +1 -1
  94. data/lib/hecks.rb +3 -1
  95. metadata +59 -2
@@ -0,0 +1,88 @@
1
+ require "optparse"
2
+ require_relative "vendor"
3
+
4
+ module Hecks
5
+ module EmbryonautBluebook
6
+ # The command line behind `bin/vendor_bluebook`.
7
+ #
8
+ # It lives in the gem, not in a script, so a consuming project reaches it
9
+ # through its own bundle without a copy of the script:
10
+ #
11
+ # bundle exec ruby -rhecks \
12
+ # -e 'exit Hecks::EmbryonautBluebook::VendorCli.run(ARGV)' payments@1.2.0
13
+ #
14
+ # ## Usage
15
+ #
16
+ # vendor_bluebook <package>[@<version-or-commit>] [--from <path>] [--root <path>]
17
+ #
18
+ # `--from` defaults to `$EMBRYONAUT_BLUEBOOKS_SRC` and `--root` to the current
19
+ # directory. `ALLOW_DOWNGRADE=1` in the environment permits a release older
20
+ # than the vendored one.
21
+ module VendorCli
22
+ USAGE = "usage: vendor_bluebook <package>[@<version-or-commit>] [--from PATH] [--root PATH]".freeze
23
+
24
+ # Runs the command and reports on the given streams.
25
+ #
26
+ # @param argv [Array<String>] the command-line arguments
27
+ # @param env [Hash{String => String}] the environment
28
+ # @param out [IO] where the report goes
29
+ # @param err [IO] where a refusal goes
30
+ # @param root [String] the default project root
31
+ # @return [Integer] the exit status, 0 when the package was vendored
32
+ def self.run(argv, env: ENV, out: $stdout, err: $stderr, root: Dir.pwd)
33
+ options = { from: env["EMBRYONAUT_BLUEBOOKS_SRC"], root: root }
34
+ spec = parse(argv, options)
35
+ return usage(err) unless spec && options[:from]
36
+
37
+ name, _, ref = spec.partition("@")
38
+ result = Vendor.new(name, from: options[:from], root: options[:root], ref: ref.empty? ? nil : ref,
39
+ allow_downgrade: env["ALLOW_DOWNGRADE"] == "1").call
40
+ report(result, options[:from], out)
41
+ 0
42
+ rescue Vendoring::Error, OptionParser::ParseError => e
43
+ err.puts(e.message)
44
+ 1
45
+ end
46
+
47
+ # Reads the options into a hash and picks out the package argument.
48
+ #
49
+ # @param argv [Array<String>] the command-line arguments
50
+ # @param options [Hash{Symbol => String}] receives `:from` and `:root` when given
51
+ # @return [String, nil] the one positional argument, nil unless exactly one was given
52
+ def self.parse(argv, options)
53
+ parser = OptionParser.new
54
+ parser.on("--from PATH") { |path| options[:from] = path }
55
+ parser.on("--root PATH") { |path| options[:root] = path }
56
+ rest = parser.parse(argv)
57
+ rest.first if rest.length == 1
58
+ end
59
+
60
+ def self.usage(err)
61
+ err.puts(USAGE)
62
+ 2
63
+ end
64
+
65
+ def self.report(result, from, out)
66
+ label = result.release? ? "#{result.version} (#{result.tag}, #{result.commit[0, 7]})" : result.commit[0, 12]
67
+ out.puts("Vendored embryonaut_bluebooks/#{result.package} #{label} from #{from} into #{result.dir}")
68
+ out.puts(shape_line(result))
69
+ end
70
+
71
+ # The shape label tells a re-vendor that only moved the pin apart from one
72
+ # that will mint a new era on the next deploy.
73
+ def self.shape_line(result)
74
+ now = result.shape.join(" ")
75
+ if result.previous_shape.nil?
76
+ "Shape: #{now} (no earlier vendored copy to compare against)"
77
+ elsif result.shape_changed?
78
+ "SHAPE CHANGED: the next deploy mints a new era; add its translation edge first.\n " \
79
+ "before: #{result.previous_shape.join(' ')}\n after: #{now}"
80
+ else
81
+ "Shape unchanged: #{now}. No new era on deploy."
82
+ end
83
+ end
84
+
85
+ private_class_method :parse, :usage, :report, :shape_line
86
+ end
87
+ end
88
+ end
@@ -13,10 +13,10 @@ module Hecks
13
13
  #
14
14
  # This module and its `uses_embryonaut_bluebook`
15
15
  # DSL word (hecksagon_builder.rb) were built on a prior commit of this
16
- # repo (933d1dd), vendored out to a real consumer (lifeadelics/domain,
16
+ # repo (933d1dd), vendored out to a real consumer (a client site's domain,
17
17
  # for embryonaut_bluebooks/payments), and then lost from this repo's own
18
18
  # reachable history — a hard reset or rebase left no branch containing
19
- # that commit. The lifeadelics vendor snapshot (a `git archive` of that
19
+ # that commit. The consumer's vendor snapshot (a `git archive` of that
20
20
  # commit, committed into their repo) was the only surviving copy; this
21
21
  # file is ported forward from it, checked against current `main`'s own
22
22
  # conventions rather than copied wholesale, since the two trees had
@@ -64,7 +64,18 @@ module Hecks
64
64
  # => "Payments"), not a separate ledger. A vendored package's directory
65
65
  # name and its declared `Hecks.bluebook` name are the one convention
66
66
  # this reuses from Framework rather than reinventing.
67
+ #
68
+ # ## Vendoring
69
+ #
70
+ # `vendor!` is the other direction: it pins a package from a local checkout
71
+ # of the registry into a consuming project's own `vendor/` tree, and is the
72
+ # one implementation behind `bin/vendor_bluebook` (see Vendoring and Vendor).
67
73
  module EmbryonautBluebook
74
+ autoload :Lock, File.join(__dir__, "embryonaut_bluebook/lock")
75
+ autoload :Shape, File.join(__dir__, "embryonaut_bluebook/shape")
76
+ autoload :Vendor, File.join(__dir__, "embryonaut_bluebook/vendor")
77
+ autoload :VendorCli, File.join(__dir__, "embryonaut_bluebook/vendor_cli")
78
+
68
79
  # Loads a vendored embryonaut bluebook package's `.bluebook` files, once
69
80
  # per registry.
70
81
  #
@@ -89,10 +100,25 @@ module Hecks
89
100
  if files.empty?
90
101
  raise Runtime::WiringError,
91
102
  "no vendored embryonaut bluebook named #{name.inspect} at #{dir} — " \
92
- "run bin/vendor_embryonaut_bluebooks #{name}"
103
+ "vendor it with bin/vendor_bluebook #{name}"
93
104
  end
94
105
 
95
106
  files.each { |file| Kernel.load(file) }
96
107
  end
108
+
109
+ # Pins one package of the registry into a project's `vendor/` tree.
110
+ #
111
+ # @param name [String, Symbol] the package's directory name, such as `"payments"`
112
+ # @param from [String] path of a local checkout of the registry repository
113
+ # @param ref [String, nil] a release version (`"1.2.0"`), a release tag name, any
114
+ # commit-ish, or nil for the newest release
115
+ # @param root [String] the consuming project's root
116
+ # @param allow_downgrade [Boolean] whether a release older than the vendored one is allowed
117
+ # @return [Vendor::Result] the commit, version and storage shape now vendored
118
+ # @raise [Vendoring::Error] if the source, release or files are unusable, or a
119
+ # version-policy refusal applies (see Vendor)
120
+ def self.vendor!(name, from:, root:, ref: nil, allow_downgrade: false)
121
+ Vendor.new(name, from: from, root: root, ref: ref, allow_downgrade: allow_downgrade).call
122
+ end
97
123
  end
98
124
  end
@@ -2,7 +2,7 @@ module Hecks
2
2
  module Forms
3
3
  # The value-object classification every consumer of a resolved VO
4
4
  # shape needs, spelled once — `field_shape.rb` (a command's own
5
- # form), `ui_schema.rb` (embryonaut_console's table/detail-panel
5
+ # form), `ui_schema.rb` (the console app's table/detail-panel
6
6
  # renderer, a separate app, not this repo), and
7
7
  # `adapters/driven/sql_query_builder.rb` (a query's own ORDER BY/
8
8
  # where compiler) each grew their own copy of "is this VO money-
@@ -1,5 +1,5 @@
1
1
  Hecks.bluebook "ConsoleSettings" do
2
- vision "A generic admin console's own curated UI facts — nav, columns, state tones, which rows a detail panel shows — as real dispatched data instead of a hand-rewritten YAML file. A UI opinion the DOMAIN it's pointed at has no way to answer, never a business record; see embryonaut_console's own presentation_config.rb for the fuller story."
2
+ vision "A generic admin console's own curated UI facts — nav, columns, state tones, which rows a detail panel shows — as real dispatched data instead of a hand-rewritten YAML file. A UI opinion the DOMAIN it's pointed at has no way to answer, never a business record; see the console app's own presentation_config.rb for the fuller story."
3
3
  supporting
4
4
 
5
5
  # A FRAMEWORK MEMBER LIKE IDENTITY/GOVERNANCE — attach with
@@ -7,7 +7,7 @@ Hecks.bluebook "ConsoleSettings" do
7
7
  # `.hecksagon`, plus a `Hecks.hecksagon "ConsoleSettings" do ... end`
8
8
  # persistence binding (Heki is the common case — a small, local,
9
9
  # per-app settings store, no shared database needed). This used to
10
- # boot in a SECOND, independent registry (embryonaut_console's own
10
+ # boot in a SECOND, independent registry (the console app's own
11
11
  # console_settings_domain.rb) specifically so no consuming app needed
12
12
  # any change at all — that property is real and now given up on
13
13
  # purpose, in favor of the one binding mechanism every other
@@ -30,7 +30,7 @@ Hecks.bluebook "ConsoleSettings" do
30
30
  # (Overview, below) and every `extra_json`/`identity_extra_json`
31
31
  # field throughout this chapter were BOTH originally scoped out on an
32
32
  # "confirmed unused in any real presentation.yml" basis that turned
33
- # out to be checked against the wrong file — embryonaut_console's own
33
+ # out to be checked against the wrong file — the console app's own
34
34
  # fixture, never this app's actual, live one. Migrating the real file
35
35
  # found both very much in use.
36
36
 
@@ -82,7 +82,7 @@ module Hecks
82
82
  return not_applicable("#{bluebook.name} is bound to #{adapter_name}, not PostgresEra")
83
83
  end
84
84
 
85
- settings = registry.world(bluebook.name)&.for_binding(Hecks::Ports::Persistence::VERB, adapter_name) || {}
85
+ settings = registry.binding_settings(bluebook.name, Hecks::Ports::Persistence::VERB, adapter_name)
86
86
  db = Hecks::Adapters::PostgresEra.connect_for(bluebook.name, settings)
87
87
  begin
88
88
  lineage = Hecks::Adapters::PostgresEra::Lineage.new(db, bluebook.name)
@@ -93,13 +93,17 @@ module Hecks
93
93
  # `adapter: :postgres_era`; ignored by every other adapter
94
94
  # @param schema [String, nil] the schema name, required only for
95
95
  # `adapter: :postgres_era`; ignored by every other adapter
96
+ # @param scratch [Hash{Symbol => String}] `:database` and/or `:schema` for
97
+ # `adapter: :postgres` to use instead of the shared fuzz scratch schema, so a caller
98
+ # such as `bin/bench` never drops the schema a concurrent `bin/fuzz` is using;
99
+ # ignored by every other adapter
96
100
  # @yield [String] the freshly rebound copy's root directory, ready to boot
97
101
  # @yieldreturn [Object] anything; becomes this method's own return value
98
102
  # @return [Object] whatever the given block returns
99
103
  # @raise [ArgumentError] if `adapter` is not `:memory`, `:sqlite`, `:postgres`, or
100
104
  # `:postgres_era`, or if `adapter: :postgres_era` is given without both
101
105
  # `database:` and `schema:`
102
- def call(domain_path, adapter: :memory, database: nil, schema: nil)
106
+ def call(domain_path, adapter: :memory, database: nil, schema: nil, scratch: {})
103
107
  Dir.mktmpdir("hecks-fuzz") do |tmp|
104
108
  copy = File.join(tmp, File.basename(domain_path))
105
109
  copy_dereferencing(domain_path, copy)
@@ -107,7 +111,7 @@ module Hecks
107
111
  case adapter
108
112
  when :memory then rebind_to_memory!(copy)
109
113
  when :sqlite then rebind_to_sqlite!(copy)
110
- when :postgres then rebind_to_postgres!(copy)
114
+ when :postgres then rebind_to_postgres!(copy, **scratch)
111
115
  when :postgres_era then rebind_to_postgres_era!(copy, database: database, schema: schema)
112
116
  else raise ArgumentError,
113
117
  "unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, :postgres, or :postgres_era"
@@ -196,8 +200,8 @@ module Hecks
196
200
  end
197
201
  end
198
202
 
199
- # Rewrites every `.hecksagon` in the copy to bind through Memory and drops its
200
- # `.world` files, so the boot needs no settings at all.
203
+ # Rewrites every `.hecksagon` in the copy to bind through Memory and replaces its
204
+ # `.world` files with a `default_adapter "Memory"` one, so the boot needs no settings.
201
205
  #
202
206
  # @param copy [String] the isolated copy's root directory
203
207
  # @return [void]
@@ -215,8 +219,11 @@ module Hecks
215
219
  # `data/<table>.db` under `root:` when unset — the same
216
220
  # zero-config default Memory gets from having no `.world` at
217
221
  # all), so the simplest correct fix for either target is
218
- # dropping `.world` from the copy entirely.
219
- Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
222
+ # dropping the shipped `.world` from the copy. What replaces it is
223
+ # only a `default_adapter`, so an aggregate the hecksagon leaves
224
+ # unbound (the real world's `default_adapter` bound it) still lands on
225
+ # this mode's adapter — see `write_default_worlds!`.
226
+ write_default_worlds!(copy, "Memory")
220
227
  end
221
228
 
222
229
  # Same dance as memory, one adapter over — `Adapters::Sqlite#
@@ -233,7 +240,7 @@ module Hecks
233
240
  def rebind_to_sqlite!(copy)
234
241
  rewrite_bindings!(copy, "SqlitePersistence")
235
242
  strip_translations!(copy)
236
- Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
243
+ write_default_worlds!(copy, "SqlitePersistence")
237
244
  end
238
245
 
239
246
  # **The expensive one** — Postgres has no zero-config default the way
@@ -262,15 +269,20 @@ module Hecks
262
269
  FUZZ_POSTGRES_SCHEMA = "hecks_fuzz".freeze
263
270
 
264
271
  # Rewrites every `.hecksagon` in the copy to bind through Postgres, against the
265
- # shared `FUZZ_POSTGRES_DATABASE`/`FUZZ_POSTGRES_SCHEMA` scratch schema.
272
+ # shared `FUZZ_POSTGRES_DATABASE`/`FUZZ_POSTGRES_SCHEMA` scratch schema unless the
273
+ # caller names its own, which lets `bin/bench` run without touching the fuzzer's.
266
274
  #
267
275
  # @param copy [String] the isolated copy's root directory
276
+ # @param database [String, nil] the scratch database, `FUZZ_POSTGRES_DATABASE` when nil
277
+ # @param schema [String, nil] the scratch schema, `FUZZ_POSTGRES_SCHEMA` when nil
268
278
  # @return [void]
269
- def rebind_to_postgres!(copy)
279
+ def rebind_to_postgres!(copy, database: nil, schema: nil)
270
280
  require "pg"
281
+ database ||= FUZZ_POSTGRES_DATABASE
282
+ schema ||= FUZZ_POSTGRES_SCHEMA
271
283
  rewrite_bindings!(copy, "Postgres")
272
284
  strip_translations!(copy)
273
- ensure_fuzz_schema!
285
+ ensure_fuzz_schema!(database, schema)
274
286
 
275
287
  # One `.world` per directory a `.hecksagon` actually lives in, not
276
288
  # one at `copy`'s own root — `Folder#load_domain` resolves a
@@ -278,35 +290,20 @@ module Hecks
278
290
  # recursively (`Folder#load_each`); a domain can hold several
279
291
  # `Hecks.hecksagon "<Name>" do ... end` sibling blocks in that one
280
292
  # file (banking.hecksagon declares "Banking", "Governance", and
281
- # "Identity" together), so every name found in one `.hecksagon`
282
- # file gets bundled into one `.world` written beside it, not
283
- # scattered by name.
284
- Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
285
- names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
286
- next if names.empty?
287
-
288
- world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres.world")
289
- File.write(world_path, names.map do |name|
290
- <<~WORLD
291
- Hecks.world "#{name}" do
292
- persisted_by("Postgres") do
293
- database "#{FUZZ_POSTGRES_DATABASE}"
294
- schema "#{FUZZ_POSTGRES_SCHEMA}"
295
- end
293
+ # "Identity" together), so every name found in one directory
294
+ # is bundled into one `.world` written there, not scattered by name.
295
+ write_worlds!(copy, "hecks_fuzz_postgres.world") do |name|
296
+ <<~WORLD
297
+ Hecks.world "#{name}" do
298
+ default_adapter "Postgres"
299
+ persisted_by("Postgres") do
300
+ database "#{database}"
301
+ schema "#{schema}"
296
302
  end
297
- WORLD
298
- end.join("\n"))
299
- end
300
-
301
- # Any pre-existing `.world` this copy shipped with (a real
302
- # deployment's own connection string) is now redundant with — and
303
- # would conflict with, `Registry#add_world`'s own header on
304
- # loading the same domain name twice — the fresh one just
305
- # written above, so it goes the same way `rebind_to_memory!`
306
- # already sends every `.world` in a Memory-mode boot.
307
- Dir.glob(File.join(copy, "**", "*.world")).each do |path|
308
- File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres.world"
303
+ end
304
+ WORLD
309
305
  end
306
+ write_unbound_chapter_worlds!(copy)
310
307
  end
311
308
 
312
309
  # Admin connection lives outside the tmp copy entirely — same as
@@ -318,8 +315,10 @@ module Hecks
318
315
  # recreated on every call, which is what actually isolates one
319
316
  # ephemeral boot's data from the next.
320
317
  #
318
+ # @param database [String] the scratch database to create if missing
319
+ # @param schema [String] the schema to drop and recreate inside it
321
320
  # @return [void]
322
- def ensure_fuzz_schema!
321
+ def ensure_fuzz_schema!(database = FUZZ_POSTGRES_DATABASE, schema = FUZZ_POSTGRES_SCHEMA)
323
322
  # `Adapters::Postgres#initialize` opens one real `PG::Connection`
324
323
  # per aggregate and never explicitly closes it — fine for a
325
324
  # process that boots once and runs, exactly what every other
@@ -342,17 +341,18 @@ module Hecks
342
341
  # something to route around by connecting less carefully.
343
342
  GC.start
344
343
 
345
- unless @fuzz_database_ready
344
+ @fuzz_databases_ready ||= {}
345
+ unless @fuzz_databases_ready[database]
346
346
  admin = PG.connect(dbname: "postgres")
347
347
  exists = admin.exec_params(
348
- "SELECT 1 FROM pg_database WHERE datname = $1", [FUZZ_POSTGRES_DATABASE]
348
+ "SELECT 1 FROM pg_database WHERE datname = $1", [database]
349
349
  ).ntuples.positive?
350
- admin.exec(%(CREATE DATABASE "#{FUZZ_POSTGRES_DATABASE}")) unless exists
350
+ admin.exec(%(CREATE DATABASE "#{database}")) unless exists
351
351
  admin.close
352
- @fuzz_database_ready = true
352
+ @fuzz_databases_ready[database] = true
353
353
  end
354
354
 
355
- db = PG.connect(dbname: FUZZ_POSTGRES_DATABASE)
355
+ db = PG.connect(dbname: database)
356
356
  # **Quiet on purpose** — same as `Adapters::Postgres.connect_for`'s
357
357
  # own `SET client_min_messages`: a `DROP SCHEMA ... CASCADE` that
358
358
  # actually has something to drop (every boot after the first)
@@ -361,7 +361,7 @@ module Hecks
361
361
  # output under a wall of "drop cascades to table ..." on every
362
362
  # single ephemeral boot.
363
363
  db.exec("SET client_min_messages = warning")
364
- quoted = db.quote_ident(FUZZ_POSTGRES_SCHEMA)
364
+ quoted = db.quote_ident(schema)
365
365
  db.exec("DROP SCHEMA IF EXISTS #{quoted} CASCADE")
366
366
  db.exec("CREATE SCHEMA #{quoted}")
367
367
  db.close
@@ -416,9 +416,9 @@ module Hecks
416
416
  ensure_postgres_era_schema!(database: database, schema: schema)
417
417
 
418
418
  # Same one-`.world`-per-`.hecksagon`-directory shape `rebind_to_
419
- # postgres!` already uses, for the identical reason (`Folder#
420
- # load_domain` globs `*.world` non-recursively) — see that
421
- # method's own comment on `world_path` above. `PostgresEra`
419
+ # postgres!` uses, for the identical reason (`Folder#
420
+ # load_domain` globs `*.world` non-recursively) — see
421
+ # `write_worlds!`. `PostgresEra`
422
422
  # additionally takes `schema:` (postgres_era.rb's own "shared-
423
423
  # instance isolation" comment): the caller-supplied throwaway
424
424
  # schema is what actually isolates this one ephemeral boot from
@@ -444,36 +444,103 @@ module Hecks
444
444
  # database it is about to throw away; the one-line warning
445
445
  # PostgresEra prints per boot under the opt-in is the honest
446
446
  # price. Inert on a machine whose ambient user is ordinary.
447
- # One world file per directory, every hecksagon name in that
448
- # directory — not one write per *.hecksagon file. context_map.hecksagon
449
- # sits beside the domain file; a second File.write to the same
450
- # hecks_fuzz_postgres_era.world would drop the first file's names
451
- # (found live: ConcurrentDispatchUnboundFixture + Governance).
452
- worlds_by_dir = Hash.new { |h, k| h[k] = [] }
447
+ write_worlds!(copy, "hecks_fuzz_postgres_era.world") do |name|
448
+ <<~WORLD
449
+ Hecks.world "#{name}" do
450
+ default_adapter "PostgresEra"
451
+ persisted_by("PostgresEra") do
452
+ database "#{database}"
453
+ schema "#{schema}"
454
+ allow_superuser true
455
+ end
456
+ end
457
+ WORLD
458
+ end
459
+ write_unbound_chapter_worlds!(copy)
460
+ end
461
+
462
+ # Replaces every `.world` in the copy with one that declares only `default_adapter`,
463
+ # so the copy needs no settings and an aggregate its hecksagon leaves unbound
464
+ # (a chapter whose real world bound it through `default_adapter`) persists through
465
+ # `adapter_name` like every bound one.
466
+ #
467
+ # @param copy [String] the isolated copy's root directory
468
+ # @param adapter_name [String] the persistence adapter every unbound aggregate falls
469
+ # back to, such as `"Memory"` or `"SqlitePersistence"`
470
+ # @return [void]
471
+ def write_default_worlds!(copy, adapter_name)
472
+ write_worlds!(copy, "hecks_fuzz_default.world") do |name|
473
+ <<~WORLD
474
+ Hecks.world "#{name}" do
475
+ default_adapter "#{adapter_name}"
476
+ end
477
+ WORLD
478
+ end
479
+ end
480
+
481
+ # Gives every chapter no hecksagon names a world of its own that falls back to Memory.
482
+ #
483
+ # A chapter with a bluebook and no hecksagon block (the framework's Privacy) has no
484
+ # bind, and the worlds `write_worlds!` wrote declare a `default_adapter` that needs a
485
+ # `database`: left alone, the chapter resolves that adapter through the project's
486
+ # world with no connection and the boot refuses (`WiringError ... needs a database
487
+ # connection`). It persisted through Memory before those worlds carried a
488
+ # `default_adapter`, and a chapter's own world's default wins over the project's, so
489
+ # this keeps it there.
490
+ #
491
+ # @param copy [String] the isolated copy's root directory
492
+ # @return [void]
493
+ def write_unbound_chapter_worlds!(copy)
494
+ named = Dir.glob(File.join(copy, "**", "*.hecksagon")).flat_map do |path|
495
+ File.read(path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
496
+ end
497
+ bluebooks = Dir.glob(File.join(copy, "**", "*.bluebook")).group_by { |path| File.dirname(path) }
498
+ bluebooks.each do |dir, files|
499
+ unbound = files.flat_map { |path| File.read(path).scan(/Hecks\.bluebook[\s(]+"([^"]+)"/).flatten }
500
+ .uniq - named
501
+ named.concat(unbound)
502
+ next if unbound.empty?
503
+
504
+ worlds = unbound.map { |name| %(Hecks.world "#{name}" do\n default_adapter "Memory"\nend\n) }
505
+ File.write(File.join(dir, "hecks_fuzz_unbound.world"), worlds.join("\n"))
506
+ end
507
+ end
508
+
509
+ # Writes one `.world` file per directory that holds a `.hecksagon`, naming every
510
+ # `Hecks.hecksagon` block found in that directory, and deletes every other `.world`
511
+ # in the copy.
512
+ #
513
+ # One world file per directory, every hecksagon name in that directory — never one
514
+ # write per `*.hecksagon` file. A domain's `context_map.hecksagon` sits beside its
515
+ # main hecksagon (the `qa` domain declares Governance in one and QualityControl in
516
+ # the other), and a second `File.write` to the same world path would drop the first
517
+ # file's names, leaving that domain bound to Postgres with no `database` to open.
518
+ #
519
+ # Any `.world` the copy shipped with (a real deployment's own connection string) is
520
+ # redundant with, and would conflict with (`Registry#add_world`'s own header, on
521
+ # loading the same domain name twice), the fresh one written here, so it goes the same
522
+ # way `rebind_to_memory!` sends every `.world` in a Memory-mode boot.
523
+ #
524
+ # @param copy [String] the isolated copy's root directory
525
+ # @param world_file [String] the basename of the world file written in each directory
526
+ # @yield [name] builds the world text for one hecksagon name
527
+ # @yieldparam name [String] a domain name a `Hecks.hecksagon` block in the directory declares
528
+ # @yieldreturn [String] the `Hecks.world` block for that name
529
+ # @return [void]
530
+ def write_worlds!(copy, world_file, &world_for)
531
+ names_by_dir = Hash.new { |hash, dir| hash[dir] = [] }
453
532
  Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
454
533
  names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
455
- worlds_by_dir[File.dirname(hecksagon_path)].concat(names)
534
+ names_by_dir[File.dirname(hecksagon_path)].concat(names)
456
535
  end
457
- worlds_by_dir.each do |dir, names|
458
- names = names.uniq
536
+ names_by_dir.each do |dir, names|
459
537
  next if names.empty?
460
538
 
461
- world_path = File.join(dir, "hecks_fuzz_postgres_era.world")
462
- File.write(world_path, names.map do |name|
463
- <<~WORLD
464
- Hecks.world "#{name}" do
465
- persisted_by("PostgresEra") do
466
- database "#{database}"
467
- schema "#{schema}"
468
- allow_superuser true
469
- end
470
- end
471
- WORLD
472
- end.join("\n"))
539
+ File.write(File.join(dir, world_file), names.uniq.map(&world_for).join("\n"))
473
540
  end
474
541
 
475
542
  Dir.glob(File.join(copy, "**", "*.world")).each do |path|
476
- File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres_era.world"
543
+ File.delete(path) unless File.basename(path) == world_file
477
544
  end
478
545
  end
479
546
 
@@ -4,8 +4,10 @@ Hecks.bluebook "World" do
4
4
 
5
5
  identified_by WorldName, as: :domain
6
6
 
7
- attribute :realm, Realm
8
- attribute :latest, Latest
7
+ attribute :realm, Realm
8
+ attribute :latest, Latest
9
+ attribute :default_database, DefaultDatabase
10
+ attribute :default_adapter, DefaultAdapter
9
11
 
10
12
  value_object "WorldName" do
11
13
  attribute :value, String
@@ -22,6 +24,22 @@ Hecks.bluebook "World" do
22
24
  invariant("a latest version says something") { !value.to_s.empty? }
23
25
  end
24
26
 
27
+ # The connection every chapter's database-taking persistence adapter
28
+ # uses unless that chapter's own world says otherwise — a world's
29
+ # per-chapter `persisted_by ... database` block, written once.
30
+ value_object "DefaultDatabase" do
31
+ attribute :value, String
32
+ invariant("a default database says something") { !value.to_s.empty? }
33
+ end
34
+
35
+ # The persistence adapter every aggregate binds to unless its own
36
+ # chapter's hecksagon binds it — between an explicit bind and the
37
+ # framework's in-memory fallback.
38
+ value_object "DefaultAdapter" do
39
+ attribute :value, String
40
+ invariant("a default adapter names an adapter") { !value.to_s.empty? }
41
+ end
42
+
25
43
  command "Declare" do
26
44
  role "Deployment"
27
45
  goal "Open a world for a domain"
@@ -29,10 +47,14 @@ Hecks.bluebook "World" do
29
47
  attribute :domain, WorldName
30
48
  attribute :realm, Realm, optional: true
31
49
  attribute :latest, Latest, optional: true
50
+ attribute :default_database, DefaultDatabase, optional: true
51
+ attribute :default_adapter, DefaultAdapter, optional: true
32
52
 
33
53
  sets :domain
34
54
  sets :realm
35
55
  sets :latest
56
+ sets :default_database
57
+ sets :default_adapter
36
58
 
37
59
  emits "WorldDeclared"
38
60
  end
@@ -55,6 +77,12 @@ Hecks.bluebook "World" do
55
77
  member word: "world", context: "File", body: "keywords", inner: "World", opens: "World", fills: ""
56
78
  member word: "realm", context: "World", body: "none", inner: "", opens: "", fills: "realm", calls: "realm_impl"
57
79
  member word: "latest", context: "World", body: "none", inner: "", opens: "", fills: "latest", calls: "latest_impl"
80
+ # THE TWO CROSS-CHAPTER DEFAULTS — the same connection string and the
81
+ # same persistence adapter every chapter of a project repeats, written
82
+ # once. A chapter's own world (`persisted_by ... database`) and its own
83
+ # hecksagon bind still win; see docs/implemented/reference/world.md.
84
+ member word: "default_database", context: "World", body: "none", inner: "", opens: "", fills: "default_database", calls: "default_database_impl"
85
+ member word: "default_adapter", context: "World", body: "none", inner: "", opens: "", fills: "default_adapter", calls: "default_adapter_impl"
58
86
 
59
87
  end
60
88
 
@@ -78,6 +106,8 @@ Hecks.bluebook "World" do
78
106
  member keyword: "world", context: "File", at: "1", named: "", kind: "text", required: "true", fills: "domain"
79
107
  member keyword: "realm", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "realm"
80
108
  member keyword: "latest", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "latest"
109
+ member keyword: "default_database", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "default_database"
110
+ member keyword: "default_adapter", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "default_adapter"
81
111
 
82
112
  end
83
113
  end