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
@@ -2,6 +2,12 @@ module Hecks
2
2
  module Adapters
3
3
  class PostgresEra
4
4
  class Lineage
5
+ # Owner-only schema bootstrap: creates the lineage tables
6
+ # (`hecks_eras`, `hecks_era_texts`, `hecks_approvals`, the
7
+ # partitioned journal), sets up its RLS fence, bridges a renamed
8
+ # domain's history (`rename_domain!`), and attaches each era's
9
+ # journal partition (`ensure_partition!`) — the build-then-ATTACH
10
+ # dance that keeps a mint from stopping every writer.
5
11
  module Provisioning
6
12
  # Provisioning is the OWNER's job, and a deployment's app role is
7
13
  # deliberately not the owner — it may append and read, and it
@@ -13,6 +19,15 @@ module Hecks
13
19
  # grant_era! exists to constrain could never finish booting
14
20
  # ("must be owner of table hecks_eras"). A fence nothing can
15
21
  # reach is not a fence.
22
+ # One bootstrap sequence of DDL, order-dependent throughout —
23
+ # every guarded ALTER (RLS, REVOKE) is guarded and placed
24
+ # exactly where it is for measured, documented reasons (see the
25
+ # inline comments on each: catalog-lock avoidance, RLS timing,
26
+ # FORCE semantics). Splitting this into smaller methods would
27
+ # only hide that single ordered sequence behind several call
28
+ # sites, with nothing gained — each statement already reads as
29
+ # one step, and the length here is DDL, not branching logic.
30
+ # rubocop:disable-next Metrics/MethodLength
16
31
  def ensure_base!
17
32
  rename_domain! if @formerly_known_as
18
33
  return unless provisioner?
@@ -183,6 +198,16 @@ module Hecks
183
198
  # Anything else (no rows under either name) falls through
184
199
  # harmlessly — `formerly_known_as` pointing at a name with no
185
200
  # held history is not an error, just inert.
201
+ # One transactional migration, whose own comment above spells
202
+ # out a three-way precheck and a FIXED lock-acquisition order
203
+ # (old before new, eras before ordinal) chosen specifically to
204
+ # avoid a deadlock against a concurrent rename/mint/write.
205
+ # Splitting this would separate the precheck from the locking
206
+ # from the renames from the commit/rollback handling — each a
207
+ # piece of ONE transaction that must stay in this exact order
208
+ # or the deadlock-avoidance guarantee stops holding.
209
+ # rubocop:disable-next Metrics/AbcSize
210
+ # rubocop:disable-next Metrics/MethodLength
186
211
  def rename_domain!
187
212
  return unless @db.exec_params("SELECT to_regclass($1)", ["hecks_eras"])[0]["to_regclass"]
188
213
  return if @db.exec_params(
@@ -236,17 +261,30 @@ module Hecks
236
261
  # ensure_base! at all — only lazily, on first reattest! — so a
237
262
  # domain that never needed one must not be forced through an
238
263
  # UPDATE against a table that doesn't exist.
239
- @db.exec_params("UPDATE hecks_attestations SET domain = $1 WHERE domain = $2", [@domain, @formerly_known_as]) if @db.exec_params("SELECT to_regclass($1)", ["hecks_attestations"])[0]["to_regclass"]
264
+ if @db.exec_params(
265
+ "SELECT to_regclass($1)", ["hecks_attestations"]
266
+ )[0]["to_regclass"]
267
+ @db.exec_params("UPDATE hecks_attestations SET domain = $1 WHERE domain = $2",
268
+ [@domain, @formerly_known_as])
269
+ end
240
270
 
241
271
  @db.exec("COMMIT")
242
272
  rescue PG::LockNotAvailable
243
- @db.exec("ROLLBACK") rescue nil
273
+ begin
274
+ @db.exec("ROLLBACK")
275
+ rescue StandardError
276
+ nil
277
+ end
244
278
  raise Runtime::WiringError,
245
279
  "cannot rename #{@formerly_known_as} to #{@domain}: another rename, mint, or write holds " \
246
280
  "one of the domain locks — waited 10s; try again shortly"
247
- rescue PG::Error => error
248
- @db.exec("ROLLBACK") rescue nil
249
- raise Runtime::WiringError, "cannot rename #{@formerly_known_as} to #{@domain}: #{error.message.strip}"
281
+ rescue PG::Error => e
282
+ begin
283
+ @db.exec("ROLLBACK")
284
+ rescue StandardError
285
+ nil
286
+ end
287
+ raise Runtime::WiringError, "cannot rename #{@formerly_known_as} to #{@domain}: #{e.message.strip}"
250
288
  end
251
289
 
252
290
  # Nothing provisioned yet — build it. Provisioned and owned —
@@ -4,6 +4,10 @@ module Hecks
4
4
  module Adapters
5
5
  class PostgresEra
6
6
  class Lineage
7
+ # The one deliberate, human-driven command that closes a fork:
8
+ # `merge_tail!` re-enters an old era's post-cut writes into the
9
+ # current era under named winners, and `diverged_count` observes how
10
+ # far the two worlds have drifted at any time before that.
7
11
  module TailMerge
8
12
  # ── fork observability ─────────────────────────────────────────
9
13
 
@@ -29,6 +33,14 @@ module Hecks
29
33
  # winner; resolution itself is append-only (the winner's state
30
34
  # re-enters as the newest row and wins structurally — originals
31
35
  # stay immutable).
36
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
37
+ # One Postgres transaction (BEGIN…COMMIT) with a manual ROLLBACK
38
+ # at every early refusal, and a snapshot-before-mutation
39
+ # invariant (`new_states` captured before the head rebuild lets
40
+ # the tail interleave). Splitting the steps into separate
41
+ # methods would force each to independently know how to roll
42
+ # back the same shared transaction, and would separate the
43
+ # snapshot from the mutation it must precede.
32
44
  def merge_tail!(aggregates:, edges:, winners: {}, audit: nil)
33
45
  @db.exec("BEGIN")
34
46
  @db.exec("SET LOCAL lock_timeout = '10s'")
@@ -129,14 +141,23 @@ module Hecks
129
141
  @db.exec("COMMIT")
130
142
  true
131
143
  rescue PG::LockNotAvailable
132
- @db.exec("ROLLBACK") rescue nil
144
+ begin
145
+ @db.exec("ROLLBACK")
146
+ rescue StandardError
147
+ nil
148
+ end
133
149
  raise Runtime::WiringError,
134
150
  "cannot merge the tail of #{@domain}: another mint or merge holds the domain lock — " \
135
151
  "waited 10s; try again shortly"
136
- rescue PG::Error => error
137
- @db.exec("ROLLBACK") rescue nil
138
- raise Runtime::WiringError, "cannot merge the tail of #{@domain}: #{error.message.strip}"
152
+ rescue PG::Error => e
153
+ begin
154
+ @db.exec("ROLLBACK")
155
+ rescue StandardError
156
+ nil
157
+ end
158
+ raise Runtime::WiringError, "cannot merge the tail of #{@domain}: #{e.message.strip}"
139
159
  end
160
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
140
161
 
141
162
  # Ids touched by BOTH worlds since the cut — the old world's
142
163
  # post-cut tail INTERSECTed with the new world's own writes.
@@ -2,6 +2,12 @@ module Hecks
2
2
  module Adapters
3
3
  class PostgresEra
4
4
  class Lineage
5
+ # Installs the shared, domain-independent `hecks_tr_*` Postgres
6
+ # functions (extract/insert/rename/move/convert/drop over jsonb)
7
+ # that a compiled translation rule set's SQL calls into — the SQL
8
+ # compilation target kept equal to the Ruby reference transform by
9
+ # the cross-execution equivalence spec, never a second source of
10
+ # truth.
5
11
  module TransformInstaller
6
12
  # The jsonb rule transforms — installed once, idempotently. Kept
7
13
  # equal to the port's reference entry-JSON transform by the
@@ -27,7 +33,26 @@ module Hecks
27
33
  end
28
34
  end
29
35
 
36
+ # Six independent CREATE OR REPLACE FUNCTION statements — each
37
+ # self-contained SQL, no shared Ruby state, and (per this
38
+ # module's own header comment) safe in any install order since
39
+ # plpgsql bodies aren't resolved against each other until
40
+ # called, not at CREATE time. Split one-per-function below
41
+ # purely so each has its own name and (where relevant) its own
42
+ # comment to sit next to, not because the six have any
43
+ # sequencing dependency on one another.
30
44
  def install_transform_functions!
45
+ install_hecks_tr_extract!
46
+ install_hecks_tr_insert!
47
+ install_hecks_tr_rename!
48
+ install_hecks_tr_move!
49
+ install_hecks_tr_convert!
50
+ install_hecks_tr_drop!
51
+ end
52
+
53
+ private
54
+
55
+ def install_hecks_tr_extract!
31
56
  @db.exec(<<~SQL)
32
57
  CREATE OR REPLACE FUNCTION hecks_tr_extract(state jsonb, path text[], OUT remaining jsonb, OUT value jsonb, OUT present boolean)
33
58
  LANGUAGE plpgsql IMMUTABLE AS $fn$
@@ -57,15 +82,18 @@ module Hecks
57
82
  END IF;
58
83
  END $fn$
59
84
  SQL
60
- # ADVERSARIAL FINDING: a destination whose top segment already
61
- # holds a value — most commonly a reference, a bare scalar id
62
- # used to be silently overwritten with an empty object the
63
- # moment a dotted destination needed to nest under it. That is
64
- # a drop that never declared itself, the one thing this
65
- # language exists to make explicit (see hecks_tr_convert's own
66
- # refusal below, the same shape) refused here instead, with
67
- # the Ruby reference transform (ports/persistence/lineage.rb's
68
- # `insert`) raising the identical wording.
85
+ end
86
+
87
+ # ADVERSARIAL FINDING: a destination whose top segment already
88
+ # holds a value most commonly a reference, a bare scalar id
89
+ # used to be silently overwritten with an empty object the
90
+ # moment a dotted destination needed to nest under it. That is
91
+ # a drop that never declared itself, the one thing this
92
+ # language exists to make explicit (see hecks_tr_convert's own
93
+ # refusal below, the same shape) — refused here instead, with
94
+ # the Ruby reference transform (ports/persistence/lineage.rb's
95
+ # `insert`) raising the identical wording.
96
+ def install_hecks_tr_insert!
69
97
  @db.exec(<<~SQL)
70
98
  CREATE OR REPLACE FUNCTION hecks_tr_insert(state jsonb, path text[], value jsonb, rule_label text) RETURNS jsonb
71
99
  LANGUAGE plpgsql IMMUTABLE AS $fn$
@@ -83,6 +111,9 @@ module Hecks
83
111
  RETURN jsonb_set(state, path, value);
84
112
  END $fn$
85
113
  SQL
114
+ end
115
+
116
+ def install_hecks_tr_rename!
86
117
  @db.exec(<<~SQL)
87
118
  CREATE OR REPLACE FUNCTION hecks_tr_rename(state jsonb, old_name text, new_name text) RETURNS jsonb
88
119
  LANGUAGE sql IMMUTABLE AS $fn$
@@ -91,6 +122,9 @@ module Hecks
91
122
  ELSE state END
92
123
  $fn$
93
124
  SQL
125
+ end
126
+
127
+ def install_hecks_tr_move!
94
128
  @db.exec(<<~SQL)
95
129
  CREATE OR REPLACE FUNCTION hecks_tr_move(state jsonb, from_path text[], to_path text[], rule_label text) RETURNS jsonb
96
130
  LANGUAGE plpgsql IMMUTABLE AS $fn$
@@ -101,6 +135,9 @@ module Hecks
101
135
  RETURN hecks_tr_insert(extracted.remaining, to_path, extracted.value, rule_label);
102
136
  END $fn$
103
137
  SQL
138
+ end
139
+
140
+ def install_hecks_tr_convert!
104
141
  @db.exec(<<~SQL)
105
142
  CREATE OR REPLACE FUNCTION hecks_tr_convert(state jsonb, from_path text[], to_path text[], pairs jsonb, from_label text, rule_label text) RETURNS jsonb
106
143
  LANGUAGE plpgsql IMMUTABLE AS $fn$
@@ -117,6 +154,9 @@ module Hecks
117
154
  from_label, extracted.value, extracted.value;
118
155
  END $fn$
119
156
  SQL
157
+ end
158
+
159
+ def install_hecks_tr_drop!
120
160
  @db.exec(<<~SQL)
121
161
  CREATE OR REPLACE FUNCTION hecks_tr_drop(state jsonb, path text[]) RETURNS jsonb
122
162
  LANGUAGE plpgsql IMMUTABLE AS $fn$
@@ -66,8 +66,8 @@ module Hecks
66
66
  declared = edge.for_aggregate(aggregate.name)
67
67
  after = begin
68
68
  lineage.translated_latest(aggregate, ordinal, chain)
69
- rescue PG::Error => error
70
- raise Runtime::WiringError, "cannot mint era #{ordinal} of #{bluebook.name}: #{error.message.strip}"
69
+ rescue PG::Error => e
70
+ raise Runtime::WiringError, "cannot mint era #{ordinal} of #{bluebook.name}: #{e.message.strip}"
71
71
  end
72
72
  before = if chain.size > 1
73
73
  lineage.translated_latest(aggregate, ordinal, chain[0..-2])
@@ -10,6 +10,15 @@ module Hecks
10
10
  # held-but-superseded shape boots read-only-toward-the-fence; an
11
11
  # unheld shape goes to the minter.
12
12
  module EraResolver
13
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
14
+ # A single order-dependent dispatch over a closed set of four
15
+ # boot scenarios (first boot / quiet reboot / held-but-superseded
16
+ # / unheld-shape-goes-to-minter), each already explained by its
17
+ # own comment above, all sharing one `db` connection closed in
18
+ # `ensure`. Splitting the branches into separate methods would
19
+ # turn each `return` (which exits `check!`, closing `db`) into a
20
+ # sentinel value threaded back up, obscuring the mutual
21
+ # exclusivity that IS the method's whole point.
13
22
  def check!(registry:, bluebook:, current_text:, settings:, directory: nil)
14
23
  db = PostgresEra.connect_for(bluebook.name, settings)
15
24
  lineage = Lineage.new(db, bluebook.name, formerly_known_as: bluebook.formerly_known_as)
@@ -78,6 +87,7 @@ module Hecks
78
87
  ensure
79
88
  db&.close
80
89
  end
90
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
81
91
  end
82
92
  end
83
93
  end
@@ -20,10 +20,29 @@ module Hecks
20
20
  label = hash[0, Runtime::StorageShape::LABEL_LENGTH]
21
21
  ordinal = latest[:ordinal] + 1
22
22
 
23
+ edge = resolve_edge!(registry, bluebook, lineage, latest, label, ordinal, directory)
24
+ ensure_compute_rekey_approved!(bluebook, lineage, edge, ordinal)
25
+ check_coverage!(registry, bluebook, shadow(latest[:held_text]), edge)
26
+
27
+ chain = edge_chain(registry, bluebook, lineage.eras, label)
28
+ audit!(bluebook, lineage, chain, ordinal, edge)
29
+ lineage.mint_era!(
30
+ ordinal: ordinal, hash: hash, label: label, held_text: current_text,
31
+ aggregates: bluebook.aggregates, edges: chain, role: role,
32
+ projection: Runtime::StorageShape.project(bluebook)
33
+ )
34
+ ordinal
35
+ end
36
+
37
+ # Find the one translation edge that leaves the held era, and
38
+ # validate it: exactly one edge must leave (eras fork
39
+ # mechanically — a second edge from the same source is a wiring
40
+ # mistake, not a merge to resolve automatically), and it must
41
+ # target the current shape's label (otherwise the edge is stale
42
+ # and boot must not silently mint past it).
43
+ def resolve_edge!(registry, bluebook, lineage, latest, label, ordinal, directory)
23
44
  edges = registry.translations.select { |t| t.domain == bluebook.name && t.from == latest[:label] }
24
- if edges.empty?
25
- refuse_toward_the_scaffold!(registry, bluebook, lineage, latest, ordinal, directory)
26
- end
45
+ refuse_toward_the_scaffold!(registry, bluebook, lineage, latest, ordinal, directory) if edges.empty?
27
46
  if edges.size > 1
28
47
  raise Runtime::WiringError,
29
48
  "cannot boot #{bluebook.name}: #{edges.size} translation edges leave era #{latest[:ordinal]} " \
@@ -36,43 +55,35 @@ module Hecks
36
55
  "cannot boot #{bluebook.name}: the translation edge from #{latest[:label]} targets " \
37
56
  "#{edge.to}, but the current shape is #{label} — the edge is stale; re-run bin/scaffold_translation"
38
57
  end
58
+ edge
59
+ end
39
60
 
40
- # A compute's (and, the same way, a rekey's) only verification
41
- # is the audit's human-approved sample — mint stays
42
- # non-interactive by requiring the approval to already exist,
43
- # recorded IN THIS DATABASE by `bin/translation_audit …
44
- # --approve` and bound to what was actually reviewed: this
45
- # edge's parsed content, and the journal as it stood when the
46
- # samples were read. A journal that has advanced past the
47
- # review invalidates it — the approved samples no longer cover
48
- # the data.
49
- if edge.aggregates.any? { |declared| !declared.computes.empty? || !declared.rekeys.empty? }
50
- approval = lineage.approval_for(from: edge.from, to: edge.to)
51
- unless approval && approval[:edge_digest] == Translation::Audit.edge_digest(edge)
52
- raise Runtime::WiringError,
53
- "cannot mint era #{ordinal} of #{bluebook.name}: this edge carries a compute or rekey " \
54
- "rule, and the audit's human-approved sample is its only verification — run " \
55
- "bin/translation_audit with --approve, then boot again"
56
- end
57
- tip = lineage.last_ordinal
58
- if approval[:reviewed_ordinal] != tip
59
- raise Runtime::WiringError,
60
- "cannot mint era #{ordinal} of #{bluebook.name}: the journal advanced past the approved " \
61
- "review (ordinal #{approval[:reviewed_ordinal]} reviewed, #{tip} now) — the samples a " \
62
- "human approved no longer cover the data; re-run bin/translation_audit with --approve"
63
- end
64
- end
61
+ # A compute's (and, the same way, a rekey's) only verification
62
+ # is the audit's human-approved sample — mint stays
63
+ # non-interactive by requiring the approval to already exist,
64
+ # recorded IN THIS DATABASE by `bin/translation_audit …
65
+ # --approve` and bound to what was actually reviewed: this
66
+ # edge's parsed content, and the journal as it stood when the
67
+ # samples were read. A journal that has advanced past the
68
+ # review invalidates it — the approved samples no longer cover
69
+ # the data.
70
+ def ensure_compute_rekey_approved!(bluebook, lineage, edge, ordinal)
71
+ return unless edge.aggregates.any? { |declared| !declared.computes.empty? || !declared.rekeys.empty? }
65
72
 
66
- check_coverage!(registry, bluebook, shadow(latest[:held_text]), edge)
73
+ approval = lineage.approval_for(from: edge.from, to: edge.to)
74
+ unless approval && approval[:edge_digest] == Translation::Audit.edge_digest(edge)
75
+ raise Runtime::WiringError,
76
+ "cannot mint era #{ordinal} of #{bluebook.name}: this edge carries a compute or rekey " \
77
+ "rule, and the audit's human-approved sample is its only verification — run " \
78
+ "bin/translation_audit with --approve, then boot again"
79
+ end
80
+ tip = lineage.last_ordinal
81
+ return unless approval[:reviewed_ordinal] != tip
67
82
 
68
- chain = edge_chain(registry, bluebook, lineage.eras, label)
69
- audit!(bluebook, lineage, chain, ordinal, edge)
70
- lineage.mint_era!(
71
- ordinal: ordinal, hash: hash, label: label, held_text: current_text,
72
- aggregates: bluebook.aggregates, edges: chain, role: role,
73
- projection: Runtime::StorageShape.project(bluebook)
74
- )
75
- ordinal
83
+ raise Runtime::WiringError,
84
+ "cannot mint era #{ordinal} of #{bluebook.name}: the journal advanced past the approved " \
85
+ "review (ordinal #{approval[:reviewed_ordinal]} reviewed, #{tip} now) — the samples a " \
86
+ "human approved no longer cover the data; re-run bin/translation_audit with --approve"
76
87
  end
77
88
 
78
89
  # No edge yet: the boot refuses toward the authoring loop —
@@ -96,7 +107,7 @@ module Hecks
96
107
  # Diff the held era against the current shape and write the edge
97
108
  # file — confident rules inline, ambiguities as parse-refusing
98
109
  # `unresolved` lines. Returns the file path.
99
- def scaffold!(registry, bluebook, lineage, latest, directory)
110
+ def scaffold!(_registry, bluebook, lineage, latest, directory)
100
111
  ensure_named!(lineage, latest)
101
112
  latest = lineage.eras.last
102
113
 
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  require_relative "../../../../adapters/driven/sql_query_builder"
4
+ require_relative "../../../../adapters/driven/postgres/outbox"
4
5
  require_relative "postgres_era/lineage"
5
6
  require_relative "postgres_era/lineage_manager"
6
7
  require_relative "../../../../ports/persistence/append_only"
@@ -40,6 +41,7 @@ module Hecks
40
41
  # operator compiles fully into SQL, or the query refuses loudly.
41
42
  class PostgresEra
42
43
  include SqlQueryBuilder
44
+ include Adapters::PostgresOutbox
43
45
 
44
46
  attr_reader :aggregate
45
47
 
@@ -139,9 +141,9 @@ module Hecks
139
141
  # problems) still surface.
140
142
  connection.exec("SET client_min_messages = warning")
141
143
  connection
142
- rescue PG::Error => error
144
+ rescue PG::Error => e
143
145
  raise Runtime::WiringError,
144
- "cannot bind PostgresEra at #{declared} for #{name}: #{error.message.strip}"
146
+ "cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
145
147
  end
146
148
 
147
149
  def initialize(aggregate:, settings: {}, root: nil)
@@ -192,6 +194,7 @@ module Hecks
192
194
  @field_caches = ensure_field_caches!
193
195
  create_event_table!
194
196
  create_saga_table!
197
+ create_outbox_table!
195
198
  end
196
199
 
197
200
  def table = @aggregate.storage_name
@@ -216,7 +219,10 @@ module Hecks
216
219
  return @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY id)).map { |row| instance(row) } unless order_by
217
220
 
218
221
  name = order_by.to_s.split(".").first
219
- raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
222
+ unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
223
+ raise Runtime::WiringError,
224
+ "#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
225
+ end
220
226
 
221
227
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
222
228
  @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY #{order_clause(spec, nil)})).map { |row| instance(row) }
@@ -286,7 +292,7 @@ module Hecks
286
292
  # make that replay pay real DB cost for a snapshot that's already
287
293
  # correct.
288
294
  def append(entry)
289
- @db.transaction do
295
+ transaction do
290
296
  lock_writes!
291
297
  append_and_project!(entry)
292
298
  end
@@ -299,7 +305,7 @@ module Hecks
299
305
  # occurs before entering this adapter-native operation.
300
306
  def atomic_put(entry, insert_only: false)
301
307
  status = nil
302
- @db.transaction do
308
+ transaction do
303
309
  lock_writes!
304
310
  exists = !@db.exec_params(
305
311
  "SELECT 1 FROM #{quoted_head} WHERE id = $1 LIMIT 1",
@@ -418,7 +424,8 @@ module Hecks
418
424
  "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
419
425
  "SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
420
426
  "completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
421
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
427
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
428
+ JSON.generate(completed_compensations)]
422
429
  )
423
430
  end
424
431
 
@@ -433,7 +440,8 @@ module Hecks
433
440
  return enum_for(:each_saga) unless block_given?
434
441
 
435
442
  @db.exec_params(
436
- "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = $1",
443
+ "SELECT process_manager, correlation, state, memory, completed_compensations " \
444
+ "FROM hecks_saga_instances WHERE domain = $1",
437
445
  [@domain]
438
446
  ).each do |row|
439
447
  yield row["process_manager"], row["correlation"], row["state"],
@@ -624,11 +632,12 @@ module Hecks
624
632
  end
625
633
 
626
634
  def cached_where_fields
627
- declared_queries.flat_map { |q|
628
- q.wheres.map { |clause|
635
+ fields = declared_queries.flat_map do |q|
636
+ q.wheres.map do |clause|
629
637
  clause.field.to_s
630
- }
631
- }.uniq.select { |field| cacheable_field?(field) }
638
+ end
639
+ end
640
+ fields.uniq.select { |field| cacheable_field?(field) }
632
641
  end
633
642
 
634
643
  def declared_queries
@@ -709,7 +718,7 @@ module Hecks
709
718
  clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
710
719
  end
711
720
 
712
- sql = +"SELECT #{select_list} FROM #{from_relation} WHERE #{clauses.join(' AND ')}"
721
+ sql = "SELECT #{select_list} FROM #{from_relation} WHERE #{clauses.join(' AND ')}"
713
722
  sql << if declared.order_by
714
723
  " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}"
715
724
  else
@@ -751,7 +760,8 @@ module Hecks
751
760
  # this same domain already created before this column existed —
752
761
  # the same idiom `rust/host/src/journal.rs`'s own
753
762
  # `sagas_backfilled` column addition already uses.
754
- @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb")
763
+ @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb " \
764
+ "NOT NULL DEFAULT '[]'::jsonb")
755
765
  end
756
766
  end
757
767
  end
@@ -15,29 +15,27 @@ module Hecks
15
15
  module LayerOne
16
16
  def layer_one!(violations, aggregate, after)
17
17
  after.each do |id, state|
18
- begin
19
- symbolized = JSON.parse(JSON.generate(state), symbolize_names: true)
20
- instance = Runtime::Instance.new(aggregate: aggregate, id: id, state: symbolized)
21
- lifecycle = aggregate.lifecycle
22
- next unless lifecycle
18
+ symbolized = JSON.parse(JSON.generate(state), symbolize_names: true)
19
+ instance = Runtime::Instance.new(aggregate: aggregate, id: id, state: symbolized)
20
+ lifecycle = aggregate.lifecycle
21
+ next unless lifecycle
23
22
 
24
- held = instance[lifecycle.field]
25
- # `Lifecycle#states` answers default+targets only — a state
26
- # legitimately declared just as a `from:` (a terminal
27
- # transition's source, never anyone's target) is real and
28
- # reachable but invisible to it. `ModelCheck.full_states`
29
- # is the full declared set (default, every target, AND
30
- # every from) that `fuzzing/properties.rb`'s own replay
31
- # check already uses for this identical question — see its
32
- # comment on this same hole.
33
- allowed = Bluebook::ModelCheck.full_states(lifecycle)
34
- unless held.nil? || allowed.include?(held.to_s)
35
- violations << "#{aggregate.name}##{id}: #{lifecycle.field} is #{held.inspect}, " \
36
- "a state this era's lifecycle never reaches"
37
- end
38
- rescue Runtime::InvariantViolation, Runtime::TypeMismatch => error
39
- violations << "#{aggregate.name}##{id}: #{error.message}"
23
+ held = instance[lifecycle.field]
24
+ # `Lifecycle#states` answers default+targets only — a state
25
+ # legitimately declared just as a `from:` (a terminal
26
+ # transition's source, never anyone's target) is real and
27
+ # reachable but invisible to it. `ModelCheck.full_states`
28
+ # is the full declared set (default, every target, AND
29
+ # every from) that `fuzzing/properties.rb`'s own replay
30
+ # check already uses for this identical question — see its
31
+ # comment on this same hole.
32
+ allowed = Bluebook::ModelCheck.full_states(lifecycle)
33
+ unless held.nil? || allowed.include?(held.to_s)
34
+ violations << "#{aggregate.name}##{id}: #{lifecycle.field} is #{held.inspect}, " \
35
+ "a state this era's lifecycle never reaches"
40
36
  end
37
+ rescue Runtime::InvariantViolation, Runtime::TypeMismatch => e
38
+ violations << "#{aggregate.name}##{id}: #{e.message}"
41
39
  end
42
40
  end
43
41
  end
@@ -24,6 +24,26 @@ module Hecks
24
24
  def layer_two!(violations, aggregate, declared, before, after)
25
25
  rekeyed = declared && !declared.rekeys.empty?
26
26
 
27
+ check_id_conservation!(violations, aggregate, before, after, rekeyed: rekeyed)
28
+
29
+ return unless declared
30
+ # No correspondence between an old id and its rekeyed row exists
31
+ # in Ruby (the rekey's SQL is its only implementation, same as
32
+ # compute's) — exempt from the per-record equivalence gate for
33
+ # the same reason compute paths already are, extended to the
34
+ # whole record since an old-id lookup into `after` can never
35
+ # succeed once the id itself has changed.
36
+ return if rekeyed
37
+
38
+ check_value_preservation!(violations, aggregate, declared, before, after)
39
+ end
40
+
41
+ # Record-count / id-set conservation — one of the two properties
42
+ # this module's own header names, and independent of the other
43
+ # (per-rule value preservation, below): it needs only `before`,
44
+ # `after`, and whether the edge rekeyed, never the declared rules
45
+ # themselves.
46
+ def check_id_conservation!(violations, aggregate, before, after, rekeyed:)
27
47
  # A rekey legitimately changes the id SET (that's the entire
28
48
  # point) — set-equality would flag every honest rekey as data
29
49
  # loss. What must still hold is RECORD COUNT: a botched rekey
@@ -43,15 +63,13 @@ module Hecks
43
63
  violations << "#{aggregate.name}: the id set changed across the edge " \
44
64
  "(lost #{lost.sort.inspect}, gained #{gained.sort.inspect})"
45
65
  end
46
- return unless declared
47
- # No correspondence between an old id and its rekeyed row exists
48
- # in Ruby (the rekey's SQL is its only implementation, same as
49
- # compute's) — exempt from the per-record equivalence gate for
50
- # the same reason compute paths already are, extended to the
51
- # whole record since an old-id lookup into `after` can never
52
- # succeed once the id itself has changed.
53
- return if rekeyed
66
+ end
54
67
 
68
+ # Per-rule value preservation — the other property this module's
69
+ # header names. Only reached once `layer_two!` has already ruled
70
+ # out "no declared edge" and "rekeyed" (see its own comment on
71
+ # that second guard); this method assumes both are false.
72
+ def check_value_preservation!(violations, aggregate, declared, before, after)
55
73
  rules = Ports::Persistence::Lineage.from_declared(declared, aggregate.name)
56
74
  compute_paths = declared.computes.flat_map { |compute| [compute.from, compute.to] }.map(&:to_s)
57
75
 
@@ -63,7 +81,7 @@ module Hecks
63
81
  actual = strip_compute_paths(normalize(after[id]), compute_paths)
64
82
  next if expected == actual
65
83
 
66
- diverged = (expected.keys | actual.keys).select { |key| expected[key] != actual[key] }
84
+ diverged = (expected.keys | actual.keys).reject { |key| expected[key] == actual[key] }
67
85
  violations << "#{aggregate.name}##{id}: the translated state diverges from the reference " \
68
86
  "transform at #{diverged.sort.join(', ')}"
69
87
  end
@@ -7,6 +7,15 @@ module Hecks
7
7
  # attribute, and the loud refusal for a REQUIRED one comes from
8
8
  # Layer 1 the moment an invariant reads it.
9
9
  module UnfedReport
10
+ # `fed` build-up plus one ordered guard chain per attribute
11
+ # (already fed, has a default, actually present in some record, no
12
+ # records to check at all) answering a single question — "is
13
+ # anything, ever, going to feed this field" — where each `next`
14
+ # rules out one way the answer is "not unfed after all" before the
15
+ # next is even worth asking. Splitting the guards out would just
16
+ # turn each into a same-shaped one-line predicate call.
17
+ # rubocop:disable-next Metrics/CyclomaticComplexity
18
+ # rubocop:disable-next Metrics/PerceivedComplexity
10
19
  def unfed(aggregate, declared, after)
11
20
  fed = if declared
12
21
  (declared.renames.values.map(&:to_s) +