hecks 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -2
data/lib/hecks/literal.rb CHANGED
@@ -37,19 +37,20 @@ module Hecks
37
37
  def to_s = "state(:#{name})"
38
38
  end
39
39
 
40
+ # The `render`/`read` pair the header above describes: `render` turns a
41
+ # Ruby value into its self-describing wire spelling, `read` is its exact
42
+ # inverse.
40
43
  module Literal
41
44
  module_function
42
45
 
43
46
  def render(value)
44
47
  case value
45
- when nil then "nil"
46
- when StateRef then value.to_s
47
- when true, false then value.to_s
48
- when Symbol then ":#{value}"
49
- when Integer, Float then value.to_s
50
- when String then quote(value)
51
- when Hash then "{#{value.map { |key, held| "#{key}: #{render(held)}" }.join(', ')}}"
52
- when Array then "[#{value.map { |held| render(held) }.join(', ')}]"
48
+ when nil then "nil"
49
+ when Symbol then ":#{value}"
50
+ when String then quote(value)
51
+ when StateRef, true, false, Integer, Float then value.to_s
52
+ when Hash then "{#{value.map { |key, held| "#{key}: #{render(held)}" }.join(', ')}}"
53
+ when Array then "[#{value.map { |held| render(held) }.join(', ')}]"
53
54
  else
54
55
  raise ArgumentError, "#{value.class} has no pinned literal spelling — teach Literal.render one " \
55
56
  "rather than letting #to_s decide it"
@@ -59,6 +60,15 @@ module Hecks
59
60
  # Read one back. Tolerant of a bare word on purpose: the language stores a
60
61
  # closed set's members and a few hand-written fields as plain text that was
61
62
  # never rendered, and those must stay the strings they are.
63
+ #
64
+ # The exact inverse of `render`'s `case`, above — one ordered guard per
65
+ # spelling `render` can produce (plus the bare-word tolerance this
66
+ # comment already explains). Each guard is a single self-contained
67
+ # `return`; splitting them into named predicates would just rename
68
+ # each line without changing what it does, and would separate this
69
+ # method from the `render` it is the deliberate mirror of.
70
+ # rubocop:disable-next Metrics/CyclomaticComplexity
71
+ # rubocop:disable-next Metrics/PerceivedComplexity
62
72
  def read(text)
63
73
  raw = text.to_s.strip
64
74
  return nil if raw.empty? || raw == "nil"
data/lib/hecks/naming.rb CHANGED
@@ -1,4 +1,9 @@
1
1
  module Hecks
2
+ # Every derived-name and identity-joining rule the language relies on —
3
+ # pluralisation/singularisation, snake/Pascal casing, dotted-path and verb
4
+ # splitting, and the command/event reference rewrites — collected here so
5
+ # two readers never invent two different spellings for the same
6
+ # derivation.
2
7
  module Naming
3
8
  # WHAT SEPARATES THE PARTS OF A DERIVED IDENTITY.
4
9
  #
@@ -71,9 +76,7 @@ module Hecks
71
76
  # is left actually ends in one of those five shapes is what
72
77
  # keeps an ordinary "-es" word (e.g. "Invoices" -> "Invoice")
73
78
  # from also losing a letter it never doubled.
74
- if word.length > 3 && word.end_with?("es") && word[0..-3].match?(/(s|x|z|ch|sh)\z/)
75
- return word[0..-3]
76
- end
79
+ return word[0..-3] if word.length > 3 && word.end_with?("es") && word[0..-3].match?(/(s|x|z|ch|sh)\z/)
77
80
 
78
81
  return word[0..-2] if word.length > 1 && word.end_with?("s")
79
82
 
@@ -19,7 +19,7 @@ module Hecks
19
19
  # a domain's adapter here calls that directly, rather than this
20
20
  # port re-deriving Governance-query logic another port already owns.
21
21
  module AccessControl
22
- NAME = "access_control"
22
+ NAME = "access_control".freeze
23
23
 
24
24
  module_function
25
25
 
@@ -39,7 +39,7 @@ module Hecks
39
39
  # differently would mean the SAME malformed answer passing through
40
40
  # one and refusing through the other.
41
41
  module Agent
42
- NAME = "agent"
42
+ NAME = "agent".freeze
43
43
 
44
44
  # The answer came back and could not be used — no JSON in it, a
45
45
  # category the language does not declare, a severity outside the
@@ -48,14 +48,16 @@ module Hecks
48
48
  # `Ports::Authentication::ValidationError` already is, so no
49
49
  # caller ever rescues a raw parser error leaking out of an
50
50
  # adapter.
51
- ValidationError = Class.new(StandardError)
51
+ class ValidationError < StandardError
52
+ end
52
53
 
53
54
  # Nothing answered at all — the binary is missing, the subprocess
54
55
  # died, the call timed out. Separate from ValidationError on
55
56
  # purpose: unavailable means retry or fall back to asking the
56
57
  # human directly; malformed means the PROMPT is wrong, not the
57
58
  # transport, and retrying identically will not fix it.
58
- Unavailable = Class.new(StandardError)
59
+ class Unavailable < StandardError
60
+ end
59
61
 
60
62
  # ── what the four operations hand back ──────────────────────────
61
63
  #
@@ -17,9 +17,10 @@ module Hecks
17
17
  # purely to resolve which adapter answers it, same as every port
18
18
  # here already does).
19
19
  module Authentication
20
- NAME = "authentication"
20
+ NAME = "authentication".freeze
21
21
 
22
- ValidationError = Class.new(StandardError)
22
+ class ValidationError < StandardError
23
+ end
23
24
 
24
25
  module_function
25
26
 
@@ -27,7 +27,7 @@ module Hecks
27
27
  # not in the same boot as the caller; this port is the same
28
28
  # questions asked through one adapter when it is.
29
29
  module Authorization
30
- NAME = "authorization"
30
+ NAME = "authorization".freeze
31
31
 
32
32
  module_function
33
33
 
@@ -33,7 +33,7 @@ module Hecks
33
33
  # runs of exactly that shape. So the filling happens at the door, where a
34
34
  # human or an agent is typing, and never on the dispatch path a replay uses.
35
35
  module Clock
36
- NAME = "clock"
36
+ NAME = "clock".freeze
37
37
 
38
38
  module_function
39
39
 
@@ -2,8 +2,12 @@ require_relative "../runtime/registry"
2
2
 
3
3
  module Hecks
4
4
  module Ports
5
+ # The `extraction` port: resolves whichever adapter can recover a
6
+ # predicate's own source (`canonical`). Reads `Hecks.current_registry`
7
+ # directly rather than taking a registry argument, since extraction only
8
+ # ever happens while a bluebook is loading, never after boot.
5
9
  module Extraction
6
- NAME = "extraction"
10
+ NAME = "extraction".freeze
7
11
 
8
12
  module_function
9
13
 
@@ -18,7 +18,7 @@ module Hecks
18
18
  # it, the same "one adapter registry-wide, refuse if zero or many"
19
19
  # resolution every other port here already uses.
20
20
  module IdentityAssignment
21
- NAME = "identity_assignment"
21
+ NAME = "identity_assignment".freeze
22
22
 
23
23
  module_function
24
24
 
@@ -24,7 +24,7 @@ module Hecks
24
24
  # the identical way: it fires on every dispatch, replay included,
25
25
  # and comparison simply excludes it.
26
26
  module IdentityGeneration
27
- NAME = "identity_generation"
27
+ NAME = "identity_generation".freeze
28
28
 
29
29
  module_function
30
30
 
@@ -13,7 +13,7 @@ module Hecks
13
13
  # way `Authorization#holds_role?` answering `false` decides nothing
14
14
  # on its own.
15
15
  module IdentityResolution
16
- NAME = "identity_resolution"
16
+ NAME = "identity_resolution".freeze
17
17
 
18
18
  module_function
19
19
 
@@ -2,8 +2,11 @@ require_relative "../adapters/driven/folder"
2
2
 
3
3
  module Hecks
4
4
  module Ports
5
+ # The `loading` port: `bootstrap` hands back the Folder adapter that
6
+ # reads bluebook files off disk to build the very first registry, before
7
+ # any other port has a registry to resolve against.
5
8
  module Loading
6
- NAME = "loading"
9
+ NAME = "loading".freeze
7
10
 
8
11
  module_function
9
12
 
@@ -23,12 +23,14 @@ module Hecks
23
23
  @adapter = adapter
24
24
  required = %i[append project entries]
25
25
  missing = required.reject { |method| adapter.respond_to?(method) }
26
- raise Runtime::WiringError,
27
- "#{adapter.class} does not implement append-only persistence: #{missing.join(', ')}" unless missing.empty?
26
+ unless missing.empty?
27
+ raise Runtime::WiringError,
28
+ "#{adapter.class} does not implement append-only persistence: #{missing.join(', ')}"
29
+ end
28
30
  end
29
31
 
30
32
  def find(id) = @adapter.find(id)
31
- def all(**opts) = @adapter.all(**opts)
33
+ def all(**) = @adapter.all(**)
32
34
  def count = @adapter.count
33
35
  def entries = @adapter.entries
34
36
 
@@ -94,7 +96,8 @@ module Hecks
94
96
 
95
97
  def atomic_put(instance, insert_only: false)
96
98
  unless capabilities.include?(:atomic_put) && @adapter.respond_to?(:atomic_put)
97
- raise Runtime::WiringError, "#{@adapter.class} advertises no atomic_put persistence capability"
99
+ raise Runtime::WiringError,
100
+ "#{@adapter.class} advertises no atomic_put persistence capability"
98
101
  end
99
102
 
100
103
  entry = Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
@@ -127,6 +130,39 @@ module Hecks
127
130
  @adapter.record_event(event) if @adapter.respond_to?(:record_event)
128
131
  end
129
132
 
133
+ # ONE COMMIT BOUNDARY FOR SAVE + EMIT + OUTBOX — `Interpreting#
134
+ # run_dispatch_order` runs the `save` and `emit` steps inside
135
+ # this block, so an adapter that owns a real transaction
136
+ # (Sqlite, Postgres) commits the aggregate row, its journal
137
+ # entry, and the outbox rows together or not at all. An adapter
138
+ # without one just yields: Memory has nothing to roll back, and
139
+ # a file adapter's own `with_lock` already serialises its pair
140
+ # of writes. Nested calls are the adapter's problem to make
141
+ # re-entrant (both SQL adapters check for an open transaction
142
+ # first) — a reaction dispatched from inside a drain never nests
143
+ # here anyway, because draining happens after this block returns.
144
+ def transaction(&)
145
+ return @adapter.transaction(&) if @adapter.respond_to?(:transaction)
146
+
147
+ yield
148
+ end
149
+
150
+ # THE OUTBOX CONTRACT — four optional adapter methods, probed
151
+ # together the way `save_saga`/`delete_saga`/`each_saga` are
152
+ # (`Registry::SagaPersistence`): an adapter either has an outbox
153
+ # or it doesn't, never half of one. See `Runtime::Outbox`.
154
+ OUTBOX_METHODS = %i[outbox_enqueue outbox_claim outbox_settle outbox_rows].freeze
155
+
156
+ def outbox?
157
+ @outbox = OUTBOX_METHODS.all? { |method| @adapter.respond_to?(method) } if @outbox.nil?
158
+ @outbox
159
+ end
160
+
161
+ def outbox_enqueue(rows) = @adapter.outbox_enqueue(rows)
162
+ def outbox_claim(id) = @adapter.outbox_claim(id)
163
+ def outbox_settle(id, status:, error: nil) = @adapter.outbox_settle(id, status: status, error: error)
164
+ def outbox_rows(status: nil) = @adapter.outbox_rows(status: status)
165
+
130
166
  def query_read_model(domain, model, args, bluebook = nil)
131
167
  return unless @adapter.respond_to?(:query_read_model)
132
168
 
@@ -46,7 +46,7 @@ module Hecks
46
46
 
47
47
  def unsupported_roles(domain, aggregate, bindings)
48
48
  Runtime::WiringError.new(
49
- "#{domain}::#{aggregate.hecks_name} uses persistence role#{bindings.size == 1 ? '' : 's'} " \
49
+ "#{domain}::#{aggregate.hecks_name} uses persistence role#{'s' unless bindings.size == 1} " \
50
50
  "#{bindings.map(&:role).map(&:inspect).join(', ')}. Only persisted_by is supported."
51
51
  )
52
52
  end
@@ -1,5 +1,8 @@
1
1
  module Hecks
2
2
  module Ports
3
+ # Reopened to hold the persistence-plugin registry (`Plugin`, below) and
4
+ # its call surface (`register_plugin`/`plugin?`/`each_plugin`) — see ADR
5
+ # 0033 and `Plugin`'s own header for the seam this fills.
3
6
  module Persistence
4
7
  # A tiny, real seam — ADR 0033. Core never names a persistence plugin;
5
8
  # a plugin makes itself known as a side effect of being `require`d, the
@@ -105,7 +105,9 @@ module Hecks
105
105
  def source_text_for(bluebook, directory)
106
106
  domain_files = Dir[File.join(directory, "*.bluebook")]
107
107
  own = domain_files.select do |path|
108
- File.foreach(path, encoding: "UTF-8").any? { |line| line.match?(/\A\s*Hecks\.bluebook\s+#{Regexp.escape(bluebook.name.inspect)}/) }
108
+ File.foreach(path, encoding: "UTF-8").any? do |line|
109
+ line.match?(/\A\s*Hecks\.bluebook\s+#{Regexp.escape(bluebook.name.inspect)}/)
110
+ end
109
111
  end
110
112
  own = domain_files if own.empty? && domain_files.size == 1 && !Framework.members.key?(bluebook.name)
111
113
  own = [Framework.members[bluebook.name]].compact if own.empty?
@@ -7,9 +7,9 @@ module Hecks
7
7
  # call it too.
8
8
  module ShapeDiff
9
9
  def shape(aggregate)
10
- aggregate.attributes.map { |attribute|
10
+ aggregate.attributes.map do |attribute|
11
11
  [attribute.name, attribute_signature(aggregate, attribute.type)]
12
- }.sort_by(&:first)
12
+ end.sort_by(&:first)
13
13
  end
14
14
 
15
15
  # A plain type name for a primitive; `[type_name, member_signatures]`
@@ -20,9 +20,9 @@ module Hecks
20
20
  container = nested_type(aggregate, type_name)
21
21
  return type_name if container.nil? || seen.include?(type_name)
22
22
 
23
- members = container.attributes.map { |member|
23
+ members = container.attributes.map do |member|
24
24
  [member.name, attribute_signature(aggregate, member.type, seen + [type_name])]
25
- }.sort_by(&:first)
25
+ end.sort_by(&:first)
26
26
  [type_name, members]
27
27
  end
28
28
 
@@ -64,7 +64,7 @@ module Hecks
64
64
  return false if attribute.list?
65
65
 
66
66
  value_object = aggregate.value_object(attribute.type)
67
- return false if value_object && value_object.attributes.all? { |field| !field.default.nil? }
67
+ return false if value_object&.attributes&.all? { |field| !field.default.nil? }
68
68
 
69
69
  true
70
70
  end
@@ -117,6 +117,15 @@ module Hecks
117
117
  # outright (nothing to be a prefix of on the held side), unlike
118
118
  # every rule above it, which explains a path that existed and
119
119
  # moved, converted, or vanished.
120
+ #
121
+ # One `||` chain over a closed, fixed set of rule kinds (renames,
122
+ # moves, converts, drops, computes, backfills) — the same six this
123
+ # file's other methods enumerate. Splitting each disjunct into its
124
+ # own predicate would scatter one question ("does ANY rule explain
125
+ # this path") across six same-shaped methods with nothing else to
126
+ # do.
127
+ # rubocop:disable-next Metrics/CyclomaticComplexity
128
+ # rubocop:disable-next Metrics/PerceivedComplexity
120
129
  def explains?(path)
121
130
  path = path.to_s
122
131
  top = path.split(".").first
@@ -202,12 +211,12 @@ module Hecks
202
211
  def apply_renames(state, renames)
203
212
  snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
204
213
  snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
205
- # rubocop:disable Style/CombinableLoops -- NOT combinable: a swap
206
- # (:a<->:b) needs every delete done before any write, or the first
207
- # rename's write becomes the second rename's delete target — see
208
- # this method's own comment above.
214
+ # NOT combinable (Style/CombinableLoops is disabled repo-wide, see
215
+ # .rubocop.yml, for exactly this reason): a swap (:a<->:b) needs
216
+ # every delete done before any write, or the first rename's write
217
+ # becomes the second rename's delete target — see this method's
218
+ # own comment above.
209
219
  snapshot.each { |_old_name, new_name, value| state[new_name] = value }
210
- # rubocop:enable Style/CombinableLoops
211
220
  end
212
221
 
213
222
  def apply_drop(state, name)
@@ -6,6 +6,10 @@ module Hecks
6
6
  module Adapters
7
7
  class PostgresEra
8
8
  class Lineage
9
+ # CRUD over `hecks_eras`/`hecks_era_texts`/`hecks_approvals`: reading
10
+ # and tamper-verifying held era text, archiving every frozen text
11
+ # version, recording a Layer-3 approval, and minting an era's name
12
+ # once (never recomputed after).
9
13
  module EraStore
10
14
  # Every held text is verified against its raw-byte digest on the
11
15
  # way out — an edited storage fact refuses rather than silently
@@ -89,7 +89,7 @@ module Hecks
89
89
  source_sql: lambda do |cursor|
90
90
  <<~SQL
91
91
  SELECT id, ordinal, value FROM (#{field_cache_source_sql(storage_name, era, value_expression)}) reduced
92
- #{cursor ? "WHERE id > #{text_literal(cursor)}" : ''}
92
+ #{"WHERE id > #{text_literal(cursor)}" if cursor}
93
93
  ORDER BY id LIMIT #{ResumableBackfill::CHUNK_SIZE}
94
94
  SQL
95
95
  end,
@@ -5,6 +5,12 @@ module Hecks
5
5
  module Adapters
6
6
  class PostgresEra
7
7
  class Lineage
8
+ # The read side of lineage: builds and maintains, per aggregate per
9
+ # era, the head-snapshot table (`ensure_head_snapshot!`/
10
+ # `backfill_head_snapshot!`) and the compiled materialized
11
+ # view/plain view (`compile_head!`) that translate and reduce the
12
+ # append-only journal into the single current-era head a query
13
+ # actually reads.
8
14
  module HeadCompiler
9
15
  # ── head compilation ───────────────────────────────────────────
10
16
 
@@ -124,7 +130,7 @@ module Hecks
124
130
  SELECT DISTINCT ON (aggregate_id) aggregate_id AS id, ordinal, operation, state
125
131
  FROM #{quoted_journal}
126
132
  WHERE era = #{era.to_i} AND aggregate = #{text_literal(storage_name)}
127
- #{cursor ? "AND aggregate_id > #{text_literal(cursor)}" : ''}
133
+ #{"AND aggregate_id > #{text_literal(cursor)}" if cursor}
128
134
  ORDER BY aggregate_id, ordinal DESC
129
135
  ) latest WHERE operation = 'save' ORDER BY id LIMIT #{ResumableBackfill::CHUNK_SIZE}
130
136
  SQL
@@ -160,8 +166,8 @@ module Hecks
160
166
  # re-raised) on failure, and either way the surrounding
161
167
  # transaction is never touched — no early COMMIT, no early
162
168
  # release of whatever advisory lock it holds.
163
- def nested_transaction(name)
164
- return @db.transaction { yield } if @db.transaction_status == PG::PQTRANS_IDLE
169
+ def nested_transaction(name, &)
170
+ return @db.transaction(&) if @db.transaction_status == PG::PQTRANS_IDLE
165
171
 
166
172
  @db.exec("SAVEPOINT #{name}")
167
173
  begin
@@ -252,8 +258,31 @@ module Hecks
252
258
  # written under — so the final edge applies uniformly, with no
253
259
  # per-era CASE. That equality is asserted, not argued: the spec
254
260
  # builds a third era both ways and diffs them.
261
+ # `edges.size != era - 1` — added alongside `head_body_sql`
262
+ # below, not merely a pre-existing guard: `names_by_era(aggregate,
263
+ # edges)` sizes `names[:storage]` to `edges.size + 1`, and the
264
+ # union above indexes it at `names[:storage][era - 2]` — CORRECT
265
+ # only when `edges` is the FULL chain reaching `era` (mint's own
266
+ # call, and the audit's own "after" reading, both are). A caller
267
+ # handed a SHORTER chain against the SAME `era` — exactly what
268
+ # the audit's own "before" reading is, `chain[0..-2]` against the
269
+ # unchanged target era — would index `names[:storage]` out of
270
+ # its real bounds instead of falling back to `chain_sql` the way
271
+ # every other mismatch here already does. Guarded structurally,
272
+ # not by trusting every future caller to know this invariant.
273
+ #
274
+ # The guard clauses above ARE the method: each rules out one way
275
+ # the layered shortcut cannot honestly apply (era too young, too
276
+ # few edges, a mismatched chain length, no prior era, no label,
277
+ # no materialized prior view) before the SQL-building tail runs.
278
+ # Splitting them out would separate each guard from the algebra
279
+ # comment it protects, and thread `held`/`prior`/`prior_view`
280
+ # back out as return values with no simpler shape than they
281
+ # already have.
282
+ # rubocop:disable-next Metrics/CyclomaticComplexity
283
+ # rubocop:disable-next Metrics/PerceivedComplexity
255
284
  def layered_chain_sql(aggregate, era, edges)
256
- return nil if era < 3 || edges.size < 2
285
+ return nil if era < 3 || edges.size < 2 || edges.size != era - 1
257
286
 
258
287
  held = eras
259
288
  prior = held.find { |candidate| candidate[:ordinal] == era - 1 }
@@ -266,7 +295,12 @@ module Hecks
266
295
  cut = held.find { |candidate| candidate[:ordinal] == era }&.dig(:watermark)
267
296
  declared = edges.last[:translation].for_aggregate(names[:current][edges.size])
268
297
  expression = declared ? Translation::RuleCompiler.compile_rules(declared) : "state"
269
- id_column = Translation::RuleCompiler.rekeyed?(declared) ? Translation::RuleCompiler.id_case("operation = 'save'", declared) : "aggregate_id"
298
+ id_column = if Translation::RuleCompiler.rekeyed?(declared)
299
+ Translation::RuleCompiler.id_case("operation = 'save'",
300
+ declared)
301
+ else
302
+ "aggregate_id"
303
+ end
270
304
 
271
305
  <<~SQL
272
306
  WITH layered AS (
@@ -274,7 +308,7 @@ module Hecks
274
308
  SELECT ordinal, aggregate_id, operation, state FROM #{quote(prior_view)}
275
309
  UNION ALL
276
310
  SELECT ordinal, aggregate_id, operation, state FROM #{quoted_journal}
277
- WHERE era = #{era - 1} AND aggregate = #{text_literal(names[:storage][era - 2])}#{cut ? " AND ordinal <= #{cut}" : ''}
311
+ WHERE era = #{era - 1} AND aggregate = #{text_literal(names[:storage][era - 2])}#{" AND ordinal <= #{cut}" if cut}
278
312
  ) layers ORDER BY aggregate_id, ordinal DESC
279
313
  )
280
314
  SELECT ordinal, #{id_column}, operation,
@@ -290,7 +324,12 @@ module Hecks
290
324
  declared = edge[:translation].for_aggregate(names[:current][index + 1])
291
325
  expression = declared ? Translation::RuleCompiler.compile_rules(declared) : "state"
292
326
  guard = "era <= #{index + 1} AND operation = 'save'"
293
- id_column = Translation::RuleCompiler.rekeyed?(declared) ? Translation::RuleCompiler.id_case(guard, declared) : "aggregate_id"
327
+ id_column = if Translation::RuleCompiler.rekeyed?(declared)
328
+ Translation::RuleCompiler.id_case(guard,
329
+ declared)
330
+ else
331
+ "aggregate_id"
332
+ end
294
333
  "edge_#{index + 1} AS (SELECT ordinal, era, #{id_column}, operation, " \
295
334
  "CASE WHEN #{guard} THEN #{expression} ELSE state END AS state " \
296
335
  "FROM #{index.zero? ? 'tail' : "edge_#{index}"})"
@@ -305,9 +344,19 @@ module Hecks
305
344
 
306
345
  # The translated tail as it would stand in era `era`, latest
307
346
  # entry per id, saves only — what the audit holds up against the
308
- # bluebook and the edge.
347
+ # bluebook and the edge. Reads through `head_body_sql`, the SAME
348
+ # layered-or-full choice `compile_head!` makes at real mint time
349
+ # (that method's own comment has the full reasoning) — so this
350
+ # is provably what the real mint will materialize, not a second
351
+ # implementation that could silently drift from it. Safe for
352
+ # BOTH real callers: the "after" reading (`edges` is the full
353
+ # chain reaching `era`) and the "before" reading (`edges` one
354
+ # shorter, same `era` — `audit.rb`'s own `samples_for`/`check`
355
+ # callers) — `layered_chain_sql`'s own `edges.size != era - 1`
356
+ # guard falls back to `chain_sql` exactly when the shorter
357
+ # chain can't honestly answer the layered question.
309
358
  def translated_latest(aggregate, era, edges)
310
- latest_of(chain_sql(aggregate, era, edges))
359
+ latest_of(head_body_sql(aggregate, era, edges))
311
360
  end
312
361
 
313
362
  # The UNtranslated ancestor tail, latest entry per id — the
@@ -363,15 +412,30 @@ module Hecks
363
412
  # write count; the ancestor side was already bounded that way
364
413
  # (the matview only ever holds the reduced tail, never raw
365
414
  # history — see latest_per_id's own comment).
415
+ # THE ONE PLACE THAT PICKS layered VS full — pulled out so the
416
+ # audit's own preview (`translated_latest`, below: both
417
+ # `bin/translation_audit`'s standalone run and the real
418
+ # mint-time gate in coverage_check.rb#audit!) reads through the
419
+ # IDENTICAL branch selection a real mint's own `compile_head!`
420
+ # will use, rather than a second, hand-kept-in-sync copy of this
421
+ # same "layered when eligible, else full" choice. Before this,
422
+ # `translated_latest` called `chain_sql` unconditionally — for
423
+ # any era >= 3, the real mint (this method, `full: false` by
424
+ # default) could materialize via the LAYERED path while the
425
+ # preview that approved it never exercised that branch at all.
426
+ # `full:` mirrors `compile_head!`'s own default; tail_merge's
427
+ # `full: true` rebuild is the one caller that ever needs the
428
+ # non-layered branch unconditionally.
429
+ def head_body_sql(aggregate, era, edges, full: false)
430
+ return chain_sql(aggregate, era, edges) if full
431
+
432
+ layered_chain_sql(aggregate, era, edges) || chain_sql(aggregate, era, edges)
433
+ end
434
+
366
435
  def compile_head!(aggregate, era, label, edges, full: false)
367
436
  storage_name = aggregate.storage_name
368
437
  view = matview(storage_name, era, label)
369
- body =
370
- if !full && (layered = layered_chain_sql(aggregate, era, edges))
371
- layered
372
- else
373
- chain_sql(aggregate, era, edges)
374
- end
438
+ body = head_body_sql(aggregate, era, edges, full: full)
375
439
  @db.exec(<<~SQL)
376
440
  CREATE MATERIALIZED VIEW #{quote(view)} AS
377
441
  #{body}
@@ -445,7 +509,7 @@ module Hecks
445
509
  cut = watermarks[ancestor + 1]
446
510
  "SELECT ordinal, era, aggregate, aggregate_id, operation, state FROM #{quoted_journal} " \
447
511
  "WHERE era = #{ancestor} AND aggregate = #{text_literal(names[:storage][ancestor - 1])}" \
448
- "#{cut ? " AND ordinal <= #{cut}" : ''}"
512
+ "#{" AND ordinal <= #{cut}" if cut}"
449
513
  end
450
514
  selects.join(" UNION ALL ")
451
515
  end
@@ -5,6 +5,11 @@ module Hecks
5
5
  module Adapters
6
6
  class PostgresEra
7
7
  class Lineage
8
+ # `mint_era!` itself: one transaction that writes the new era row,
9
+ # attaches its partition, recompiles every aggregate's head, grants
10
+ # an app role, and — last, right before commit — advances the
11
+ # current-era RLS fence (`advance_era!`) that drops write access to
12
+ # the old schema the instant it commits.
8
13
  module MintTransaction
9
14
  # ── the mint transaction ───────────────────────────────────────
10
15
  #
@@ -30,7 +35,8 @@ module Hecks
30
35
 
31
36
  watermark = last_ordinal
32
37
  @db.exec_params(
33
- "INSERT INTO hecks_eras (domain, ordinal, hash, label, held_text, watermark, held_digest, held_projection, canon_form) " \
38
+ "INSERT INTO hecks_eras (domain, ordinal, hash, label, held_text, watermark, held_digest, " \
39
+ "held_projection, canon_form) " \
34
40
  "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
35
41
  [@domain, ordinal, hash, label, held_text, watermark, Digest::SHA256.hexdigest(held_text),
36
42
  projection && JSON.generate(projection), Runtime::StorageShape::FORM_VERSION]
@@ -70,13 +76,21 @@ module Hecks
70
76
  @db.exec("COMMIT")
71
77
  true
72
78
  rescue PG::LockNotAvailable
73
- @db.exec("ROLLBACK") rescue nil
79
+ begin
80
+ @db.exec("ROLLBACK")
81
+ rescue StandardError
82
+ nil
83
+ end
74
84
  raise Runtime::WiringError,
75
85
  "cannot mint era #{ordinal} of #{@domain}: another mint holds the domain lock — " \
76
86
  "waited 10s; try again shortly"
77
- rescue PG::Error => error
78
- @db.exec("ROLLBACK") rescue nil
79
- raise Runtime::WiringError, "cannot mint era #{ordinal} of #{@domain}: #{error.message.strip}"
87
+ rescue PG::Error => e
88
+ begin
89
+ @db.exec("ROLLBACK")
90
+ rescue StandardError
91
+ nil
92
+ end
93
+ raise Runtime::WiringError, "cannot mint era #{ordinal} of #{@domain}: #{e.message.strip}"
80
94
  end
81
95
 
82
96
  # Base privileges for a deployment's app role — a NON-owner,
@@ -116,7 +130,10 @@ module Hecks
116
130
  aggregates.each do |aggregate|
117
131
  storage_name = aggregate.storage_name
118
132
  @db.exec("GRANT SELECT, INSERT, UPDATE, DELETE ON #{quote(head_snapshot(storage_name, era))} TO #{quoted_role}")
119
- @db.exec("GRANT SELECT ON #{quote(head_view(storage_name))} TO #{quoted_role}") if view_exists?(head_view(storage_name))
133
+ if view_exists?(head_view(storage_name))
134
+ @db.exec("GRANT SELECT ON #{quote(head_view(storage_name))} " \
135
+ "TO #{quoted_role}")
136
+ end
120
137
  end
121
138
  end
122
139