hecks 0.3.0 → 1.0.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -1,3 +1,14 @@
1
+ # ADR 0033 — the `PostgresEra` Ruby CLASS moved into the loadable era
2
+ # persistence plugin (`lib/hecks/ports/persistence/plugins/era/`); this
3
+ # declaration stays here, unmoved. `Adapters::Folder#load_library` globs
4
+ # `.adapter` files by DIRECTORY, not by require path, on every boot,
5
+ # regardless of which adapters a given domain actually binds — the same
6
+ # reason every other adapter's own `.adapter` file lives beside this one.
7
+ # Pure metadata (which port, which fields) — no era-specific code, and
8
+ # harmless for a domain that never binds `persisted_by "PostgresEra"`.
9
+ # `registry.adapter_class("PostgresEra")` is only ever resolved for a
10
+ # domain that actually binds to it, at which point the plugin must
11
+ # already be `require`d for the class to exist.
1
12
  Hecks.adapter "PostgresEra" do
2
13
  port "persistence"
3
14
  field :database
@@ -14,6 +14,23 @@ module Hecks
14
14
  end
15
15
  lifecycle = @aggregate.lifecycle
16
16
  fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
17
+ # `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO —
18
+ # `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
19
+ # both write one straight into `Instance#state` the same as
20
+ # any other field, so a column has to exist to hold it or
21
+ # `save`'s own `columns =`/`values =` build (this file, not
22
+ # here) silently drops it on every write. No `attribute` of
23
+ # their own to carry (`ProjectedField` is a bare name/
24
+ # reference/remote_field triple, not a typed attribute) —
25
+ # treated as a raw scalar, same as the lifecycle field just
26
+ # above: every real corpus use projects a status/lifecycle
27
+ # string, and `encode_field`/`decode` both already pass a
28
+ # `attribute: nil` field through untouched, not JSON-encoded.
29
+ @aggregate.projected_fields.each do |field|
30
+ next if fields.any? { |f| f[:name] == field.name }
31
+
32
+ fields << { name: field.name, attribute: nil, sql_type: "TEXT" }
33
+ end
17
34
  fields
18
35
  end
19
36
 
@@ -1,5 +1,8 @@
1
1
  require_relative "../../../runtime/instance"
2
2
  require_relative "../../../runtime/value"
3
+ require_relative "../../../runtime/errors"
4
+ require_relative "../../../runtime/refusal_wording"
5
+ require_relative "../../../rendering"
3
6
  require_relative "../../../ports/query/in_memory"
4
7
 
5
8
  # The subclass needs its parent — and sqlite.rb requires this file at
@@ -29,6 +32,34 @@ module Hecks
29
32
  # Execute a declared read model against the projected aggregate-head
30
33
  # tables. The report shape is assembled from SQL-selected rows, rather
31
34
  # than scanning repositories and matching references in Ruby.
35
+ #
36
+ # M19 (docs/audits/2026-08-10-main-bug-audit.md,
37
+ # docs/audits/2026-08-11-bug-triage.md) — this used to diverge from
38
+ # `Runtime::ReadModelInterpreter#project` (the in-process path) on
39
+ # two counts, both fixed here to agree with it:
40
+ #
41
+ # MISSING ROOT: the in-process path's own `fetch` refuses with
42
+ # `NotFound` when the reference argument names no record —
43
+ # `query_read_model` used to answer a silent `{root: nil, ...}`
44
+ # instead, the one path a caller could dispatch a read model
45
+ # against a record that never existed and get back something that
46
+ # LOOKS like an empty report rather than the refusal every other
47
+ # path gives.
48
+ #
49
+ # CHAINED-INCLUDE JOIN SCOPE: a non-root head was always matched
50
+ # against the ROOT's own id, regardless of what it actually
51
+ # references — correct for a head that references the root
52
+ # directly, silently EMPTY for one that references another
53
+ # included head instead (`Leaf` -> `Mid` -> `Root`, `Leaf` itself
54
+ # has no attribute referencing `Root` at all, so `references`
55
+ # was always `[]`). The in-process path's own root-first fix
56
+ # (`ReadModelInterpreter#project`'s "ROOT FIRST, ALWAYS" comment)
57
+ # already matches a head against ANY already-projected source, not
58
+ # only the root — `select_related` now does the same: each head is
59
+ # matched against every source resolved so far (root first, then
60
+ # declared order — the same one-level-of-declaration-order
61
+ # dependency the in-process path itself still has, documented
62
+ # there as L2, not a gap introduced here).
32
63
  def query_read_model(_domain, model, args, bluebook = nil)
33
64
  raise ArgumentError, "projection query needs its domain bluebook" unless bluebook
34
65
 
@@ -37,15 +68,27 @@ module Hecks
37
68
  # declared as a path and followed.
38
69
  reference_id = args.fetch(model.reference_name).to_s
39
70
  eligible = model.filtered_head_name
71
+
72
+ # ROOT FIRST, ALWAYS — see this method's own header. Mirrors
73
+ # `ReadModelInterpreter#project`'s identical partition, for the
74
+ # identical reason: a later head's own join has to be able to
75
+ # match against a root (or another head) already resolved.
76
+ root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
77
+ projected = []
40
78
  reports = {}
41
- model.aggregate_heads.each do |head|
79
+ (root_heads + other_heads).each do |head|
42
80
  aggregate = bluebook.aggregate(head[:aggregate])
43
81
  rows = if head[:aggregate] == model.reference_target
44
- [select_projected(aggregate, reference_id)].compact
82
+ [select_projected(aggregate, reference_id) ||
83
+ raise(Runtime::NotFound,
84
+ Runtime::RefusalWording.render("NotFound", "read_model_reference_missing",
85
+ aggregate: head[:aggregate],
86
+ offered: Hecks::Rendering.describe(reference_id)))]
45
87
  else
46
- select_related(aggregate, model.reference_target, reference_id)
88
+ select_related(aggregate, projected)
47
89
  end
48
90
  rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
91
+ projected << { aggregate: head[:aggregate], rows: rows }
49
92
  reports[head[:as]] = if head[:many]
50
93
  rows.map { |row| Runtime::Value.materialize(row.to_h) }
51
94
  else
@@ -62,18 +105,32 @@ module Hecks
62
105
  row && projected_instance(aggregate, row)
63
106
  end
64
107
 
65
- def select_related(aggregate, target, id)
66
- references = aggregate.attributes.select do |attribute|
67
- attribute.reference? && attribute.type.target_name == target.to_s
108
+ # Matched against EVERY source already projected (root first, then
109
+ # declared order see this class's own `query_read_model` header),
110
+ # not only the root — a head whose own reference points at another
111
+ # included head rather than the root directly used to match nothing
112
+ # at all, since its reference attribute was compared against a
113
+ # target (the root) it never names.
114
+ def select_related(aggregate, projected)
115
+ matches = projected.flat_map do |source|
116
+ references = aggregate.attributes.select do |attribute|
117
+ attribute.reference? && attribute.type.target_name == source[:aggregate].to_s
118
+ end
119
+ next [] if references.empty?
120
+
121
+ ids = source[:rows].map { |row| row.id.to_s }
122
+ next [] if ids.empty?
123
+
124
+ references.product(ids)
68
125
  end
69
- return [] if references.empty?
126
+ return [] if matches.empty?
70
127
 
71
128
  # A REFERENCE COLUMN HOLDS THE ID, so it compares as itself. The
72
129
  # `json_extract(col,'$.value') = ? OR col = ?` this replaced was
73
130
  # reading both shapes because both existed — one written by the
74
131
  # command path, one by older journals. There is one shape now.
75
- clauses = references.map { |attribute| "#{quote_ident(attribute.name)} = ?" }
76
- bind = references.map { id.to_s }
132
+ clauses = matches.map { |attribute, _id| "#{quote_ident(attribute.name)} = ?" }
133
+ bind = matches.map { |_attribute, id| id }
77
134
  @db.execute("SELECT * FROM #{quote_ident(aggregate.storage_name)} WHERE #{clauses.join(' OR ')} ORDER BY id", bind)
78
135
  .map { |row| projected_instance(aggregate, row) }
79
136
  end
@@ -87,6 +144,16 @@ module Hecks
87
144
  if (lifecycle = aggregate.lifecycle) && !fields.any? { |name, _| name == lifecycle.field }
88
145
  fields << [lifecycle.field, nil]
89
146
  end
147
+ # `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
148
+ # (above) already writes one into its own column via `persisted_fields`
149
+ # (`Codec#persisted_fields`, this class's own superclass module), but
150
+ # this method built its own independent field list that never
151
+ # consulted it — a column `project` populated correctly, silently
152
+ # dropped on every read back out. Same raw-passthrough treatment as
153
+ # the lifecycle field just above: `attribute: nil` down in the loop.
154
+ aggregate.projected_fields.each do |field|
155
+ fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
156
+ end
90
157
  fields.each_with_object({}) do |(name, attribute), state|
91
158
  raw = row[name.to_s]
92
159
  state[name] =
@@ -74,14 +74,26 @@ module Hecks
74
74
  def create_saga_table!
75
75
  @db.execute(<<~SQL)
76
76
  CREATE TABLE IF NOT EXISTS hecks_saga_instances (
77
- domain TEXT NOT NULL,
78
- process_manager TEXT NOT NULL,
79
- correlation TEXT NOT NULL,
80
- state TEXT NOT NULL,
81
- memory TEXT NOT NULL,
77
+ domain TEXT NOT NULL,
78
+ process_manager TEXT NOT NULL,
79
+ correlation TEXT NOT NULL,
80
+ state TEXT NOT NULL,
81
+ memory TEXT NOT NULL,
82
+ completed_compensations TEXT NOT NULL DEFAULT '[]',
82
83
  PRIMARY KEY (domain, process_manager, correlation)
83
84
  )
84
85
  SQL
86
+ # `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
87
+ # this same domain already created before this column existed
88
+ # — the same reason Postgres's own `create_saga_table!` needs
89
+ # its own `ADD COLUMN IF NOT EXISTS`. SQLite/D1's own `ALTER
90
+ # TABLE ... ADD COLUMN` has no `IF NOT EXISTS` guard on every
91
+ # version this adapter supports, so a duplicate-column error
92
+ # is caught and treated as "already there" rather than relied
93
+ # on to never happen.
94
+ @db.execute("ALTER TABLE hecks_saga_instances ADD COLUMN completed_compensations TEXT NOT NULL DEFAULT '[]'")
95
+ rescue StandardError => e
96
+ raise unless e.message.include?("duplicate column name")
85
97
  end
86
98
 
87
99
  def sql_type(attr)
@@ -40,7 +40,15 @@ module Hecks
40
40
  # (§2/§4) — falls back to the aggregate's own name for a
41
41
  # directly-instantiated adapter (specs), same fallback shape
42
42
  # Postgres's own @domain already uses.
43
- @domain = (settings[:domain] || settings["domain"] || aggregate.name).to_s
43
+ @domain = (
44
+ if settings.key?(:domain)
45
+ settings[:domain]
46
+ elsif settings.key?("domain")
47
+ settings["domain"]
48
+ else
49
+ aggregate.name
50
+ end
51
+ ).to_s
44
52
 
45
53
  FileUtils.mkdir_p(File.dirname(@path))
46
54
  @db = SQLite3::Database.new(@path)
@@ -89,7 +97,12 @@ module Hecks
89
97
  def append(entry)
90
98
  @db.execute(
91
99
  "INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES (?, ?, ?, ?)",
92
- [entry.id, entry.operation, JSON.generate(entry.state), JSON.generate(entry.mirrors)]
100
+ # `mirrors` (unlike `state`) is a NULLABLE column — an absent
101
+ # mirrors hash must bind a real SQL NULL, not the four-character
102
+ # JSON text `"null"` (`JSON.generate(nil)`), or a future `IS NULL`
103
+ # check against it would never match. Same guard `postgres_era.rb`
104
+ # already uses for its own journal's `mirrors` column.
105
+ [entry.id, entry.operation, JSON.generate(entry.state), entry.mirrors && JSON.generate(entry.mirrors)]
93
106
  )
94
107
  entry
95
108
  end
@@ -189,11 +202,11 @@ module Hecks
189
202
  # either way; a domain that wants an obviously-named saga store
190
203
  # already gets one by sharing `database` across its aggregates,
191
204
  # the recommended, common case.
192
- def save_saga(process_manager:, correlation:, state:, memory:)
205
+ def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
193
206
  @db.execute(
194
- "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory) " \
195
- "VALUES (?, ?, ?, ?, ?)",
196
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory)]
207
+ "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
208
+ "VALUES (?, ?, ?, ?, ?, ?)",
209
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
197
210
  )
198
211
  end
199
212
 
@@ -208,11 +221,12 @@ module Hecks
208
221
  return enum_for(:each_saga) unless block_given?
209
222
 
210
223
  @db.execute(
211
- "SELECT process_manager, correlation, state, memory FROM hecks_saga_instances WHERE domain = ?",
224
+ "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = ?",
212
225
  [@domain]
213
226
  ).each do |row|
214
227
  yield row["process_manager"], row["correlation"], row["state"],
215
- JSON.parse(row["memory"], symbolize_names: true)
228
+ JSON.parse(row["memory"], symbolize_names: true),
229
+ JSON.parse(row["completed_compensations"] || "[]", symbolize_names: true)
216
230
  end
217
231
  end
218
232
 
@@ -269,7 +283,14 @@ module Hecks
269
283
  def quoted_entry_table = quote_ident(entry_table)
270
284
 
271
285
  def resolve_path(settings, root)
272
- declared = settings[:database] || settings["database"] || "data/#{table}.db"
286
+ declared =
287
+ if settings.key?(:database)
288
+ settings[:database]
289
+ elsif settings.key?("database")
290
+ settings["database"]
291
+ else
292
+ "data/#{table}.db"
293
+ end
273
294
  return declared if declared.start_with?("/")
274
295
 
275
296
  File.join(root || Dir.pwd, declared)
@@ -11,7 +11,25 @@ end
11
11
  require_relative "driven/memory"
12
12
  require_relative "driven/sqlite"
13
13
  require_relative "driven/postgres"
14
- require_relative "driven/postgres_era"
14
+ # `PostgresEra` is NOT `require_relative`d here — ADR 0033 moved it, and
15
+ # the rest of the era/lineage/translation subsystem, behind a loadable
16
+ # persistence plugin (`hecks/ports/persistence/plugins/era`) rather than
17
+ # requiring every app to carry it whether or not anything ever binds
18
+ # `persisted_by "PostgresEra"`. `Module#autoload` is the one-line seam
19
+ # that makes that genuinely load-nothing-until-asked instead of pushing
20
+ # an explicit `require` onto every one of the ~15 generic `bin/*` tools
21
+ # that boot an arbitrary checked-in domain (several of which — pizzas,
22
+ # compliance, chess, roster — bind PostgresEra): the FIRST real
23
+ # `Adapters.const_get("PostgresEra")` (`registry/verification.rb`'s own
24
+ # adapter resolution, unchanged) transparently `require`s the plugin
25
+ # entry point, which defines the constant AND calls `register_plugin`
26
+ # as its own side effect (`plugins/era.rb`'s last line) — the exact
27
+ # same effect an explicit `require "hecks/ports/persistence/plugins/
28
+ # era"` has, just deferred to the moment something is actually asked
29
+ # for `PostgresEra` rather than sprinkled across every caller that
30
+ # might. A domain that never binds it never triggers the autoload, so
31
+ # never loads a line of era code — unchanged from ADR 0033's own goal.
32
+ Hecks::Adapters.autoload(:PostgresEra, "hecks/ports/persistence/plugins/era")
15
33
  require_relative "driven/lambda"
16
34
  require_relative "driven/heki"
17
35
  require_relative "driven/prism"
@@ -41,10 +41,39 @@ module Hecks
41
41
  "say which command or query this example exercises"
42
42
  end
43
43
 
44
+ validate_expect!
45
+
44
46
  TestCase.new(description: @description, tests_command: @tests_command,
45
47
  on_aggregate: @on_aggregate, kind: @kind,
46
48
  setups: @setups, input: @input, expect: @expect)
47
49
  end
50
+
51
+ private
52
+
53
+ # Closes the silent-pass paths a free-form `expect(**kwargs)` merge
54
+ # otherwise leaves open: a test with no `expect` at all asserts
55
+ # nothing and passes whenever dispatch doesn't raise; `count:` on a
56
+ # command and `emits:` on a query are each read by neither runner
57
+ # (Expectations#run_command/#run_query), so they're accepted here
58
+ # and then silently ignored at run time. Checking both at BUILD
59
+ # time, not in the runners, makes them errors on the file that
60
+ # wrote them rather than green checks nobody questions.
61
+ def validate_expect!
62
+ if @expect.empty?
63
+ raise Malformed, "test #{@description.inspect} has no `expect` — say what this " \
64
+ "example proves (ok:, refused:, emits:, count:, or a field name)"
65
+ end
66
+
67
+ if @kind == :command && @expect.key?(:count)
68
+ raise Malformed, "test #{@description.inspect}: `expect count:` only applies to " \
69
+ "queries — a command has no row count to check"
70
+ end
71
+
72
+ if @kind == :query && @expect.key?(:emits)
73
+ raise Malformed, "test #{@description.inspect}: `expect emits:` only applies to " \
74
+ "commands — a query never dispatches, so it never emits"
75
+ end
76
+ end
48
77
  end
49
78
 
50
79
  class BehaviorsBuilder
@@ -3,6 +3,7 @@ require_relative "../runtime/loader"
3
3
  require_relative "../runtime/errors"
4
4
  require_relative "../runtime/value"
5
5
  require_relative "../runtime/reaction_invocation"
6
+ require_relative "../ports/persistence/binding_policy"
6
7
 
7
8
  # Hecks::Behaviors::Expectations
8
9
  #
@@ -83,7 +84,40 @@ module Hecks
83
84
  key = files.map { |file| [file, File.exist?(file) ? File.mtime(file).to_f : nil] }
84
85
 
85
86
  RUNTIMES_LOCK.synchronize do
86
- RUNTIMES[key] ||= Hecks::Runtime::Loader.boot_files(files, install_facade: false)
87
+ RUNTIMES[key] ||= boot_and_guard(files)
88
+ end
89
+ end
90
+
91
+ # `reset_runtime_state!` only drops repository OBJECTS between
92
+ # tests (registry.rb) — sufficient isolation for `Memory`, whose
93
+ # `@records` is a plain per-instance ivar, so a fresh object really
94
+ # is a fresh store. Against anything else (Sqlite, Postgres) the
95
+ # rows themselves stay put: tests leak into each other, and a
96
+ # suite booted against a domain's REAL hecksagon writes to a real
97
+ # database. Refusing that wiring here, at boot, is the same shape
98
+ # of guard `BindingPolicy` already applies to a missing bind — the
99
+ # project's identity is refusing bad wiring up front, not
100
+ # discovering it mid-suite.
101
+ def boot_and_guard(files)
102
+ runtime = Hecks::Runtime::Loader.boot_files(files, install_facade: false)
103
+ guard_memory_only!(runtime)
104
+ runtime
105
+ end
106
+
107
+ def guard_memory_only!(runtime)
108
+ runtime.registry.bluebooks.each_value do |bluebook|
109
+ bluebook.aggregates.each do |aggregate|
110
+ bind = Ports::Persistence::BindingPolicy.resolve(runtime.registry, bluebook.name, aggregate)
111
+ next if bind.adapter == Ports::Persistence::DEFAULT_ADAPTER
112
+
113
+ raise Malformed,
114
+ "#{bluebook.name}::#{aggregate.hecks_name} is persisted_by " \
115
+ "#{bind.adapter.inspect}, not #{Ports::Persistence::DEFAULT_ADAPTER.inspect} — " \
116
+ "a behaviors suite's `loads` must resolve every aggregate to an in-memory " \
117
+ "binding, or tests leak state into each other and write to a real database. " \
118
+ "Load a Memory-bound sibling hecksagon instead of the domain's real one — see " \
119
+ "examples/pizzas/bluebook/pizzas.behaviors's own `loads` comment for the pattern."
120
+ end
87
121
  end
88
122
  end
89
123
 
@@ -182,7 +216,33 @@ module Hecks
182
216
  return fail_result(test, "expected count: #{expected}, got #{count}") if count != expected
183
217
  end
184
218
 
185
- check_ok(test) || pass_result(test)
219
+ return check_ok(test) || pass_result(test) unless field_expectations?(test)
220
+
221
+ row = query_row(test, rows)
222
+ return row if row.is_a?(Result)
223
+
224
+ check_ok(test) || check_fields(test, row) || pass_result(test)
225
+ end
226
+
227
+ def field_expectations?(test)
228
+ test.expect.keys.any? { |key| !SPECIAL_KEYS.include?(key) }
229
+ end
230
+
231
+ # A field expectation on a query names one row's shape, so it only
232
+ # makes sense once the query has settled on exactly one — the same
233
+ # reason `expect status: "sold"` on a multi-row answer would be
234
+ # ambiguous about which row it's describing. `check_fields` itself
235
+ # stays row-shaped (it already is, for `run_command`'s settled
236
+ # state); this picks which row it reads.
237
+ def query_row(test, rows)
238
+ return rows unless rows.is_a?(Array)
239
+
240
+ case rows.size
241
+ when 1 then rows.first
242
+ when 0 then fail_result(test, "expect names a field, but the query returned no rows")
243
+ else fail_result(test, "expect names a field, but the query returned #{rows.size} rows — " \
244
+ "narrow it with `input`/`expect count: 1` first")
245
+ end
186
246
  end
187
247
 
188
248
  def check_ok(test)
@@ -255,20 +255,49 @@ module Hecks
255
255
  ),
256
256
 
257
257
  # S17, ADR 0026 — Dispatch is a genuine entity now, nested under
258
- # Handler (`entity "Dispatch"`, reaction.bluebook) — two levels
259
- # deep, "no life outside its Handler" (the ADR's own words).
260
- # `command_name` is Dispatch's own real, non-positional identity
261
- # neither `position` nor `handler` is a stored field any more,
262
- # the same reason Handler's own contract, above, dropped them.
258
+ # Handler (`entity "Dispatch"`, process_manager.bluebook) — two
259
+ # levels deep, "no life outside its Handler" (the ADR's own
260
+ # words). `command_name` ALONE used to be Dispatch's own
261
+ # identity, until items #151/#152 (`process_manager.bluebook`'s
262
+ # own `DispatchPosition` comment) found it collided the instant a
263
+ # real handler fanned the same command out more than once —
264
+ # `position` now joins it, walk-minted the same way every other
265
+ # category's own `position` is (`derived: { position: :walk }`,
266
+ # the same entry ProcessManager's own contract carries above) —
267
+ # never a stored field on `DispatchSpec` itself, exactly like
268
+ # `handler` before it.
269
+ # `compensates_command_name`/`compensates_with_spec` — FOLDED,
270
+ # the SAME kind `Lifecycle`'s own `state_field`/`default` claim
271
+ # (one IR OBJECT, `DispatchSpec#compensates`, feeding two
272
+ # separate language fields): `Readings#field_value`'s own
273
+ # `contract.folded` branch reads
274
+ # `node.compensates.to_h[:command_name]` / `[:with_spec]` for
275
+ # the JUDGE'S offering side, nil-safe when there is no
276
+ # compensation at all (`through`'s own `return nil unless
277
+ # held`). `compensates_with_spec` ALSO needs its own row shaper
278
+ # (`compensates_with_spec_rows`, readings.rb) for the JUDGE'S
279
+ # list-offering side — the with_spec pairs still need one
280
+ # "BindCompensation" append per pair, the same reason
281
+ # `with_spec` itself needs `with_spec_rows`, one level deeper.
282
+ # `Reconstruction#dispatch` reads the two flat fields back off
283
+ # the row and assembles the nested `DispatchSpec` by hand — a
284
+ # nested OBJECT is not one of the shapes `declaration()`'s own
285
+ # generic per-field hash-build can produce, the identical reason
286
+ # `handler`/`process_manager` pass a `:children` list through
287
+ # `extra:` instead.
263
288
  "Dispatch" => Contract.new(
264
289
  holder: DispatchSpec, make: :new,
265
290
  fields: {
266
291
  command_name: [:command_name, :plain],
267
292
  with_spec: [:with_spec, :bindings]
268
293
  },
269
- rows: { with_spec: :with_spec_rows },
294
+ rows: { with_spec: :with_spec_rows, compensates_with_spec: :compensates_with_spec_rows },
270
295
  reads: { with_spec: [:from, :with_spec] },
271
- derived: {}
296
+ derived: {
297
+ position: :walk,
298
+ compensates_command_name: [:folded, :compensates, :command_name],
299
+ compensates_with_spec: [:folded, :compensates, :with_spec]
300
+ }
272
301
  ),
273
302
 
274
303
  # S14, ADR 0026 — Syntax/Keyword/Argument are never built via
@@ -126,9 +126,10 @@ module Hecks
126
126
  target = change[:target].to_sym
127
127
  op = change[:op].to_sym
128
128
 
129
- # `:delegate` (CommandBuilder#delegates_to's own comment) rides
130
- # the SAME multi-binding shape `:append` does.
131
- return Mutation.new(target: target, op: op, source: appended(change[:fields])) if [:append, :delegate].include?(op)
129
+ # `:delegate`/`:corrects` (CommandBuilder#delegates_to's and
130
+ # #corrects_impl's own comments) ride the SAME multi-binding
131
+ # shape `:append` does.
132
+ return Mutation.new(target: target, op: op, source: appended(change[:fields])) if [:append, :delegate, :corrects].include?(op)
132
133
 
133
134
  Mutation.new(target: target, op: op, source: classified(change[:source]))
134
135
  end
@@ -71,7 +71,20 @@ module Hecks
71
71
 
72
72
  def handler(row)
73
73
  Build.call("Handler", row,
74
- dispatches: Array(row[:dispatches]).map { |leg| Build.call("Dispatch", leg) })
74
+ dispatches: Array(row[:dispatches]).map { |leg| dispatch(leg) })
75
+ end
76
+
77
+ # `compensates` — a PLAIN HASH on the declaration (`Reconstruction#
78
+ # dispatch`'s own comment for why), built into the real
79
+ # `DispatchSpec` its own field actually is by recursing through
80
+ # THIS SAME method, one level in — the identical move `handler`
81
+ # itself takes into `dispatches`, one level up. `nil` when there
82
+ # is nothing to compensate; `Build.call` never sees a
83
+ # `compensates:` key it does not know how to read either way, the
84
+ # same reason `compensates` never joined `Contract#fields` at all.
85
+ def dispatch(row)
86
+ compensates = row[:compensates] && dispatch(row[:compensates])
87
+ Build.call("Dispatch", row, compensates: compensates)
75
88
  end
76
89
  end
77
90
  end
@@ -37,7 +37,24 @@ module Hecks
37
37
  return nil if matches.empty?
38
38
  return matches.first unless current_state
39
39
 
40
- matches.find { |t| applies_from?(t, current_state) } || matches.first
40
+ # NOT `|| matches.first` that used to silently hand back an
41
+ # ARBITRARY declared transition for `command` whenever none of
42
+ # them actually admitted `current_state`, picking a `target`
43
+ # that command dispatch would in fact have refused (that
44
+ # refusal is `CommandRules::Admissibility#admissible_transition`'s
45
+ # own job, which raises `LifecycleRefused` for exactly this
46
+ # case rather than guessing — this module has no state subject
47
+ # to build that refusal message from, only the two bare
48
+ # values callers passed in). Zero production callers reach
49
+ # this branch today (both real dispatch paths that check a
50
+ # transition go through `admissible_transition`, not
51
+ # `target_for`/`match_transition`), so this raise is a
52
+ # backstop, not a live behaviour change — same shape as
53
+ # `CommandRules::Arithmetic#sign_of`'s own fix.
54
+ matches.find { |t| applies_from?(t, current_state) } ||
55
+ raise(Runtime::WiringError,
56
+ "no transition for #{command.inspect} admits state #{current_state.inspect} " \
57
+ "— add a from: covering it, or check admissibility before calling #target_for")
41
58
  end
42
59
 
43
60
  def applies_from?(transition, current)
@@ -27,8 +27,21 @@ module Hecks
27
27
  leg = handler_for(Bluebook::ProcessManager::REFUSED)
28
28
  return nil unless leg
29
29
 
30
+ # `compensations` — a STATIC PREVIEW, declaration order, not
31
+ # one instance's own runtime history (which legs a given
32
+ # instance actually completed is per-instance state,
33
+ # `SagaInterpreter`'s own `completed_compensations`, not a
34
+ # fact `Saga` — a pure declaration reading — could ever hold).
35
+ # Every `compensates` ANY handler's own dispatch declares,
36
+ # forward declaration order, THEN whatever this leg's own
37
+ # hand-written body still lists — coexistence, not replacement
38
+ # (`ProcessManagerBuilder::HandlerBuilder#dispatch_impl`'s own
39
+ # comment): a saga can derive some of its compensation and
40
+ # still hand-write the rest for what isn't expressible as
41
+ # "undo command X".
42
+ derived = handlers.flat_map { |handler| handler.dispatches.filter_map(&:compensates) }
30
43
  Bluebook::Saga.new(trigger: Bluebook::ProcessManager::REFUSED, from_state: leg.from_state,
31
- to_state: leg.to_state, reversals: leg.dispatches)
44
+ to_state: leg.to_state, compensations: derived + leg.dispatches)
32
45
  end
33
46
 
34
47
  def saga? = !saga.nil?
@@ -30,17 +30,27 @@ module Hecks
30
30
  IR_VERSION = 1
31
31
 
32
32
  emits_ir(
33
- ir_version: -> { IR_VERSION },
34
- name: :name,
35
- version: :version,
36
- vision: :vision,
37
- classification: :classification,
38
- aggregates: many(:aggregates),
39
- read_models: many(:read_models),
40
- policies: many(:policies),
41
- process_managers: many(:process_managers),
42
- attaches_to: :attaches_to,
43
- canonical_form: -> { Expression::CanonicalForm.table }
33
+ ir_version: -> { IR_VERSION },
34
+ name: :name,
35
+ version: :version,
36
+ vision: :vision,
37
+ classification: :classification,
38
+ # M10 — a domain rename (`formerly_known_as "OldName"`) drives a
39
+ # real Postgres schema rename at boot (EraResolver reads
40
+ # `bluebook.formerly_known_as` off the live Ruby object) and the
41
+ # meta-validator's cache key is `SHA256(JSON(bluebook.to_h))` — so
42
+ # a fact this method didn't spell was a fact two chapters
43
+ # differing ONLY by their old name could hash identically on,
44
+ # same as read-model filters before them. Spelled here for the
45
+ # same reason `version`/`vision` are: a plain field, present
46
+ # (possibly nil) rather than silently absent.
47
+ formerly_known_as: :formerly_known_as,
48
+ aggregates: many(:aggregates),
49
+ read_models: many(:read_models),
50
+ policies: many(:policies),
51
+ process_managers: many(:process_managers),
52
+ attaches_to: :attaches_to,
53
+ canonical_form: -> { Expression::CanonicalForm.table }
44
54
  )
45
55
 
46
56
  attr_reader :name, :version, :vision, :aggregates, :policies, :process_managers,
@@ -39,7 +39,7 @@ module Hecks
39
39
  # the tail, which is why a construct with a variable shape needs no
40
40
  # new mixin API.
41
41
  def to_h
42
- return super.merge(fields: appended_fields) if [:append, :delegate].include?(op)
42
+ return super.merge(fields: appended_fields) if [:append, :delegate, :corrects].include?(op)
43
43
 
44
44
  super.merge(source: classified_source)
45
45
  end