hecks 1.0.2 → 1.2.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 (247) 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 +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -52,7 +52,7 @@ module Hecks
52
52
  out << chapter_header(bluebook, depth) unless only
53
53
  Array(aggregates(bluebook, only)).each { |aggregate| out << aggregate_section(aggregate, only ? depth : depth + 1) }
54
54
  out << closing(bluebook, depth + 1) unless only
55
- out.compact.join("\n").rstrip + "\n"
55
+ "#{out.compact.join("\n").rstrip}\n"
56
56
  end
57
57
 
58
58
  # A NAME THAT NAMES NOTHING IS REFUSED, not answered with an empty
@@ -179,7 +179,7 @@ module Hecks
179
179
  value_object = value_object_for(attribute, holder)
180
180
  inner =
181
181
  if value_object
182
- "{ " + value_object.attributes.map { |f| "#{f.name}: #{f.type}" }.join(", ") + " }"
182
+ "{ #{value_object.attributes.map { |f| "#{f.name}: #{f.type}" }.join(', ')} }"
183
183
  else
184
184
  attribute.type.to_s
185
185
  end
@@ -244,19 +244,13 @@ module Hecks
244
244
  end
245
245
 
246
246
  def command_entry(command, holder, depth)
247
- out = [h(depth, "#{command.hecks_name}#{command.creates? ? ' *(creates)*' : ''}"), ""]
247
+ out = [h(depth, "#{command.hecks_name}#{' *(creates)*' if command.creates?}"), ""]
248
248
  out += [command.goal, ""] if command.goal
249
249
  out << "Issued by: **#{command.role}**." if command.role
250
250
  out << ""
251
251
 
252
252
  arguments = command.attributes
253
- unless arguments.empty?
254
- rows = arguments.map do |attribute|
255
- shape = attribute.reference? ? "id of a `#{attribute.type.target_name}`" : shape_of(attribute, holder)
256
- ["`#{attribute.name}`", shape, attribute.optional? ? "" : "required"]
257
- end
258
- out << table(%w[argument shape needed], rows)
259
- end
253
+ out << table(%w[argument shape needed], command_argument_rows(arguments, holder)) unless arguments.empty?
260
254
 
261
255
  refusals = refusals_of(command, holder)
262
256
  unless refusals.empty?
@@ -272,6 +266,17 @@ module Hecks
272
266
  out.join("\n")
273
267
  end
274
268
 
269
+ # The argument table's own rows — a pure per-attribute mapping with
270
+ # nothing to share with `command_entry`'s other sections, extracted
271
+ # only to keep that method to the one shape every section there
272
+ # follows: build a chunk, append it if non-empty.
273
+ def command_argument_rows(arguments, holder)
274
+ arguments.map do |attribute|
275
+ shape = attribute.reference? ? "id of a `#{attribute.type.target_name}`" : shape_of(attribute, holder)
276
+ ["`#{attribute.name}`", shape, attribute.optional? ? "" : "required"]
277
+ end
278
+ end
279
+
275
280
  # EVERY WAY THIS VERB CAN SAY NO, gathered from the three places a
276
281
  # chapter states them — the lifecycle it is an edge of, its own
277
282
  # `given`s, and the fact that a reference has to resolve. A caller
@@ -284,7 +289,11 @@ module Hecks
284
289
  froms = lifecycle && lifecycle.transitions.filter_map do |name, transition|
285
290
  Array(transition.from) if name.to_s == command.hecks_name
286
291
  end.flatten.uniq
287
- refusals << "`#{lifecycle.field}` is anything other than #{froms.map { |f| "`#{f}`" }.join(' or ')}" if froms && !froms.empty?
292
+ if froms && !froms.empty?
293
+ refusals << "`#{lifecycle.field}` is anything other than #{froms.map do |f|
294
+ "`#{f}`"
295
+ end.join(' or ')}"
296
+ end
288
297
 
289
298
  command.attributes.select(&:reference?).each do |reference|
290
299
  refusals << "no `#{reference.type.target_name}` exists for the id given as `#{reference.name}`"
@@ -302,7 +311,7 @@ module Hecks
302
311
  queries.each do |query|
303
312
  shape = query.to_h
304
313
  takes = Array(shape[:attributes]).map { |a| "`#{a[:name]}`" }.join(", ")
305
- out << "**#{query.hecks_name}**#{takes.empty? ? '' : " (#{takes})"} "
314
+ out << "**#{query.hecks_name}**#{" (#{takes})" unless takes.empty?} "
306
315
  out << (query.description ? "#{query.description} " : "")
307
316
  filters = Array(shape[:wheres]).map { |w| "`#{w[:field]} #{w[:op]} #{w[:value].inspect}`" }
308
317
  out << "Filters: #{filters.join(', ')}." unless filters.empty?
@@ -3,6 +3,13 @@ require_relative "../ports/persistence"
3
3
 
4
4
  module Hecks
5
5
  module Projector
6
+ # Registry-wide serialization to Hash/JSON: bluebook IR
7
+ # (`call`/`json`), era-adapter lineage-capability flags (`lineage`),
8
+ # and translation edges in both their DIGEST-relevant declared shape
9
+ # (`translation_hash`, what ApprovalDigest hashes) and their
10
+ # consumer-ready COMPILED shape with precompiled SQL attached
11
+ # (`translations`/`compiled_translation_aggregate`). Read directly by
12
+ # bin/ir, bin/project_rust, and the translation/audit approval digest.
6
13
  module Exporter
7
14
  module_function
8
15
 
@@ -47,6 +54,30 @@ module Hecks
47
54
  { capable_aggregates: capable.map { |aggregate| { name: aggregate.name, storage_name: aggregate.storage_name } } }
48
55
  end
49
56
 
57
+ # A BINDING fact, same shape/reasoning as `lineage` above: every
58
+ # aggregate's DECLARED persistence adapter name (`persisted_by`),
59
+ # not part of the canonical bluebook shape `call` exports (ADR
60
+ # 0001 — the IR describes what's declared, never which adapter a
61
+ # deployment binds it to). Unlike `lineage`, this needs no era
62
+ # plugin — `BindingPolicy` is core, always loaded — and covers
63
+ # EVERY aggregate, not just lineage-capable ones: `rust/host`
64
+ # (`ir.rs`'s own `refuse_unsupported_persistence_adapters`) reads
65
+ # this to refuse loudly, at boot, against a domain bound to an
66
+ # adapter it has no backend for (Heki, Memory, Sqlite, D1,
67
+ # LocalStorage — `rust/host` understands only Postgres/PostgresEra
68
+ # today), rather than silently building up a second, disjoint
69
+ # history nothing but Rust ever reads while the real state stays
70
+ # wherever its own adapter actually wrote it.
71
+ def persistence(registry, domain_name)
72
+ bluebook = registry.bluebooks.fetch(domain_name)
73
+ aggregates = bluebook.aggregates.map do |aggregate|
74
+ adapter = Ports::Persistence::BindingPolicy.resolve(registry, domain_name, aggregate).adapter
75
+ { name: aggregate.name, storage_name: aggregate.storage_name, adapter: adapter }
76
+ end
77
+
78
+ { aggregates: aggregates }
79
+ end
80
+
50
81
  # Translation IR, always as an array, WITH each aggregate's
51
82
  # precompiled SQL attached (`compiled_translation_aggregate`) —
52
83
  # this is the export a consumer embeds (`ir.json`'s `translations`
@@ -150,7 +181,9 @@ module Hecks
150
181
 
151
182
  translation_aggregate(aggregate).merge(
152
183
  compiled_state_expression: Translation::RuleCompiler.compile_rules(aggregate),
153
- compiled_id_expression: Translation::RuleCompiler.rekeyed?(aggregate) ? Translation::RuleCompiler.compile_id_expression(aggregate) : nil
184
+ compiled_id_expression: (if Translation::RuleCompiler.rekeyed?(aggregate)
185
+ Translation::RuleCompiler.compile_id_expression(aggregate)
186
+ end)
154
187
  )
155
188
  end
156
189
  end
@@ -72,7 +72,11 @@ module Hecks
72
72
  parts << identity_sentence(aggregate)
73
73
 
74
74
  refs = aggregate.attributes.select(&:reference?)
75
- parts << "Each one is linked to #{to_sentence_list(refs.map { |r| "#{a_or_an(r.type.target_name)} #{r.type.target_name}" })}." unless refs.empty?
75
+ unless refs.empty?
76
+ parts << "Each one is linked to #{to_sentence_list(refs.map do |r|
77
+ "#{a_or_an(r.type.target_name)} #{r.type.target_name}"
78
+ end)}."
79
+ end
76
80
 
77
81
  parts << lifecycle_narrative(aggregate)
78
82
  parts << verbs_narrative(aggregate, depth + 1)
@@ -124,35 +128,84 @@ module Hecks
124
128
  "#{header}\n\n#{body}"
125
129
  end
126
130
 
131
+ # ONE PARAGRAPH, BUILT FROM INDEPENDENT SENTENCES — each sentence
132
+ # below states one unrelated fact about `command` (its goal, who
133
+ # issues it, whether it creates the holder, what it takes, what it
134
+ # references, what gates it, what it guarantees, what it emits), in
135
+ # the fixed order a reader expects them; nothing after the first
136
+ # sentence depends on anything before it. Same nil-or-string +
137
+ # `compact.join` shape `aggregate_narrative`/`entity_narrative`
138
+ # already use above for the identical reason.
127
139
  def command_paragraph(command, holder)
128
- # THE GOAL, VERBATIM — same rule `DocsProjector` holds to: quoted
129
- # exactly as declared, not recased to fit mid-sentence, because the
130
- # promise this whole projector makes is that a sentence here is a
131
- # sentence the chapter actually wrote.
132
- sentences = ["**#{command.hecks_name}**#{command.goal ? " — #{command.goal}." : '.'}"]
133
- sentences << "Issued by #{a_or_an(command.role)} #{command.role}." if command.role
134
- # `acts_on.nil?`, NOT `creates?` — `creates?` answers true for every
135
- # verb an ENTITY declares (it never references itself; see
136
- # `Command#acts_on`'s own comment), so reading it directly here would
137
- # tell an SME that `LedgerEntry.Amend` brings a new ledger entry into
138
- # being, which is exactly backwards.
139
- sentences << "This is how a new #{holder.hecks_name} comes into being." if command.acts_on.nil?
140
+ [
141
+ command_headline_sentence(command),
142
+ command_role_sentence(command),
143
+ command_creation_sentence(command, holder),
144
+ command_arguments_sentence(command),
145
+ command_references_sentence(command),
146
+ command_conditions_sentence(command, holder),
147
+ command_guarantees_sentence(command),
148
+ command_emits_sentence(command)
149
+ ].compact.join(" ")
150
+ end
151
+
152
+ # THE GOAL, VERBATIM — same rule `DocsProjector` holds to: quoted
153
+ # exactly as declared, not recased to fit mid-sentence, because the
154
+ # promise this whole projector makes is that a sentence here is a
155
+ # sentence the chapter actually wrote.
156
+ def command_headline_sentence(command)
157
+ "**#{command.hecks_name}**#{command.goal ? " — #{command.goal}." : '.'}"
158
+ end
159
+
160
+ def command_role_sentence(command)
161
+ return nil unless command.role
162
+
163
+ "Issued by #{a_or_an(command.role)} #{command.role}."
164
+ end
165
+
166
+ # `acts_on.nil?`, NOT `creates?` — `creates?` answers true for every
167
+ # verb an ENTITY declares (it never references itself; see
168
+ # `Command#acts_on`'s own comment), so reading it directly here would
169
+ # tell an SME that `LedgerEntry.Amend` brings a new ledger entry into
170
+ # being, which is exactly backwards.
171
+ def command_creation_sentence(command, holder)
172
+ return nil unless command.acts_on.nil?
173
+
174
+ "This is how a new #{holder.hecks_name} comes into being."
175
+ end
140
176
 
177
+ def command_arguments_sentence(command)
141
178
  arguments = command.attributes.reject(&:reference?)
142
- sentences << "It takes #{to_sentence_list(arguments.map { |a| Forms::Humanize.label(a.name.to_s).downcase })}." unless arguments.empty?
179
+ return nil if arguments.empty?
143
180
 
181
+ "It takes #{to_sentence_list(arguments.map { |a| Forms::Humanize.label(a.name.to_s).downcase })}."
182
+ end
183
+
184
+ def command_references_sentence(command)
144
185
  refs = command.attributes.select(&:reference?)
145
- sentences << "It's aimed at one existing #{to_sentence_list(refs.map { |r| r.type.target_name })}, by id." unless refs.empty?
186
+ return nil if refs.empty?
146
187
 
188
+ "It's aimed at one existing #{to_sentence_list(refs.map { |r| r.type.target_name })}, by id."
189
+ end
190
+
191
+ def command_conditions_sentence(command, holder)
147
192
  conditions = conditions_of(command, holder)
148
- sentences << "It only goes through if #{conditions.join('; ')}." unless conditions.empty?
193
+ return nil if conditions.empty?
149
194
 
195
+ "It only goes through if #{conditions.join('; ')}."
196
+ end
197
+
198
+ def command_guarantees_sentence(command)
150
199
  guarantees = command.ensures.map(&:description)
151
- sentences << "When it succeeds: #{guarantees.join('; ')}." unless guarantees.empty?
200
+ return nil if guarantees.empty?
201
+
202
+ "When it succeeds: #{guarantees.join('; ')}."
203
+ end
152
204
 
153
- sentences << "It records `#{command.emits.join('`, `')}` as a fact." unless command.emits.empty?
205
+ def command_emits_sentence(command)
206
+ return nil if command.emits.empty?
154
207
 
155
- sentences.join(" ")
208
+ "It records `#{command.emits.join('`, `')}` as a fact."
156
209
  end
157
210
 
158
211
  # EVERY REQUIRED CONDITION, STATED AS SOMETHING THAT MUST BE TRUE —
@@ -166,10 +219,14 @@ module Hecks
166
219
 
167
220
  lifecycle = holder.lifecycle
168
221
  if lifecycle
169
- froms = lifecycle.transitions.filter_map { |name, transition|
222
+ froms = lifecycle.transitions.filter_map do |name, transition|
170
223
  Array(transition.from) if name.to_s == command.hecks_name
171
- }.flatten.uniq
172
- conditions << "its `#{lifecycle.field}` is currently #{to_sentence_list(froms.map { |f| "`#{f}`" }, conj: 'or')}" unless froms.empty?
224
+ end.flatten.uniq
225
+ unless froms.empty?
226
+ conditions << "its `#{lifecycle.field}` is currently #{to_sentence_list(froms.map do |f|
227
+ "`#{f}`"
228
+ end, conj: 'or')}"
229
+ end
173
230
  end
174
231
 
175
232
  conditions + command.givens.map(&:description)
@@ -200,8 +257,8 @@ module Hecks
200
257
  "#{header}\n\n#{lines.join("\n")}"
201
258
  end
202
259
 
203
- def op_words(op)
204
- { eq: "is", lt: "under", lte: "at most", gt: "over", gte: "at least" }[op.to_s.to_sym] || op.to_s
260
+ def op_words(comparator)
261
+ { eq: "is", lt: "under", lte: "at most", gt: "over", gte: "at least" }[comparator.to_s.to_sym] || comparator.to_s
205
262
  end
206
263
 
207
264
  # ── what happens on its own ───────────────────────────────────────
@@ -212,7 +269,8 @@ module Hecks
212
269
  parts = [DocsProjector.h(depth, "Reactions")]
213
270
  bluebook.policies.each do |policy|
214
271
  elsewhere = policy.target_domain ? " in #{policy.target_domain}" : ""
215
- parts << "Whenever `#{policy.on_event}` happens, `#{policy.trigger_command}` fires on its own#{elsewhere} — nobody has to ask for it."
272
+ parts << "Whenever `#{policy.on_event}` happens, `#{policy.trigger_command}` fires on its own#{elsewhere} — " \
273
+ "nobody has to ask for it."
216
274
  end
217
275
 
218
276
  bluebook.process_managers.each do |saga|
@@ -70,9 +70,9 @@ module Hecks
70
70
  # any construct that emits IR, and `admits!` is what decides whether
71
71
  # THIS target can actually take the one handed over.
72
72
  def call(name, bluebook:, options: {})
73
- projector = registry.fetch(name.to_sym) {
73
+ projector = registry.fetch(name.to_sym) do
74
74
  raise UnknownProjector, "no projector registered for #{name.inspect} — registered: #{registered.sort.inspect}"
75
- }
75
+ end
76
76
  admits!(name, projector, bluebook)
77
77
  projector.call(bluebook: bluebook, options: options)
78
78
  end
@@ -1,4 +1,3 @@
1
- require "set"
2
1
  require_relative "bluebook"
3
2
  require_relative "codemod"
4
3
  require_relative "projections/model"
@@ -49,9 +48,9 @@ module Hecks
49
48
  # makes, reusing its own Deviations data so this can never silently
50
49
  # drift from what that gate actually checks.
51
50
  def construct_diff(name)
52
- klass = CONSTRUCTS.fetch(name) {
51
+ klass = CONSTRUCTS.fetch(name) do
53
52
  raise ArgumentError, "no such construct #{name.inspect} — known: #{CONSTRUCTS.keys.join(', ')}"
54
- }
53
+ end
55
54
  declared = meta_declared(name)
56
55
  emitted = klass.ir_spec.keys
57
56
 
@@ -101,33 +100,9 @@ module Hecks
101
100
  rules = []
102
101
  chapters = chapter_name ? [registry.bluebook(chapter_name)] : registry.bluebooks.values
103
102
 
104
- walk_construct = lambda do |construct, path|
105
- (construct.respond_to?(:preconditions) ? construct.preconditions : []).each do |rule|
106
- rules << Rule.new(kind: "given", description: rule.description, canonical: rule.canonical,
107
- location: "#{path} (declared)")
108
- end
109
- (construct.respond_to?(:invariants) ? construct.invariants : []).each do |rule|
110
- rules << Rule.new(kind: "invariant", description: rule.description, canonical: rule.canonical,
111
- location: "#{path} (declared)")
112
- end
113
- construct.commands.each do |command|
114
- command.givens.each do |rule|
115
- rules << Rule.new(kind: "given", description: rule.description, canonical: rule.canonical,
116
- location: "#{path}.#{command.hecks_name}")
117
- end
118
- command.ensures.each do |rule|
119
- rules << Rule.new(kind: "ensures", description: rule.description, canonical: rule.canonical,
120
- location: "#{path}.#{command.hecks_name}")
121
- end
122
- end
123
- construct.entities.each { |piece|
124
- walk_construct.call(piece, "#{path}.#{piece.hecks_name}")
125
- } if construct.respond_to?(:entities)
126
- end
127
-
128
103
  chapters.each do |chapter|
129
104
  chapter.aggregates.each do |aggregate|
130
- walk_construct.call(aggregate, aggregate.hecks_name)
105
+ walk_construct_rules(aggregate, aggregate.hecks_name, rules)
131
106
  aggregate.value_objects.each do |vo|
132
107
  vo.invariants.each do |rule|
133
108
  rules << Rule.new(kind: "invariant", description: rule.description, canonical: rule.canonical,
@@ -139,6 +114,39 @@ module Hecks
139
114
  rules
140
115
  end
141
116
 
117
+ # THE RECURSIVE WALK `collect_rules` drives — pulled out of that method
118
+ # (pure extraction, identical traversal and Rule shapes) as its own
119
+ # named, self-recursive method rather than a lambda closing over the
120
+ # same locals. `rules` is the one piece of state every call shares —
121
+ # threaded as a parameter and mutated in place, the same accumulator
122
+ # role the lambda's own closure played.
123
+ def walk_construct_rules(construct, path, rules)
124
+ (construct.respond_to?(:preconditions) ? construct.preconditions : []).each do |rule|
125
+ rules << Rule.new(kind: "given", description: rule.description, canonical: rule.canonical,
126
+ location: "#{path} (declared)")
127
+ end
128
+ (construct.respond_to?(:invariants) ? construct.invariants : []).each do |rule|
129
+ rules << Rule.new(kind: "invariant", description: rule.description, canonical: rule.canonical,
130
+ location: "#{path} (declared)")
131
+ end
132
+ construct.commands.each do |command|
133
+ command.givens.each do |rule|
134
+ rules << Rule.new(kind: "given", description: rule.description, canonical: rule.canonical,
135
+ location: "#{path}.#{command.hecks_name}")
136
+ end
137
+ command.ensures.each do |rule|
138
+ rules << Rule.new(kind: "ensures", description: rule.description, canonical: rule.canonical,
139
+ location: "#{path}.#{command.hecks_name}")
140
+ end
141
+ end
142
+ return unless construct.respond_to?(:entities)
143
+
144
+ construct.entities.each do |piece|
145
+ walk_construct_rules(piece, "#{path}.#{piece.hecks_name}", rules)
146
+ end
147
+ end
148
+ private_class_method :walk_construct_rules
149
+
142
150
  # A rule's OWNER — the construct path a "(declared)" location names
143
151
  # directly, or (for a command-level `.givens`/`.ensures` entry) the
144
152
  # path with its trailing `.CommandName` segment stripped. Two rules
@@ -196,10 +204,10 @@ module Hecks
196
204
  all_rules.group_by { |r| [r.kind, r.description, r.canonical] }
197
205
  .map { |key, rules| [key, rules, declaration_count(rules)] }
198
206
  .select { |_, _, count| count > 1 }
199
- .map { |(kind, description, canonical), rules, _|
207
+ .map do |(kind, description, canonical), rules, _|
200
208
  { kind: kind, description: description, canonical: canonical,
201
209
  locations: rules.map(&:location) }
202
- }
210
+ end
203
211
  end
204
212
 
205
213
  # `given` ONLY (not `invariant`/`ensures`) also covers CROSS-ENTITY
@@ -254,9 +262,9 @@ module Hecks
254
262
  # resolved chapter-wide reference.
255
263
  def declaration_count(rules)
256
264
  declared = rules.select { |r| r.location.end_with?(" (declared)") }
257
- declared_owners = declared.map { |r| owner_of(r.location) }.to_set
265
+ declared_owners = declared.to_set { |r| owner_of(r.location) }
258
266
  declared_given_roots = declared.select { |r| r.kind == "given" }
259
- .map { |r| owner_of(r.location).split(".").first }.to_set
267
+ .to_set { |r| owner_of(r.location).split(".").first }
260
268
 
261
269
  standalone = rules.reject do |r|
262
270
  owner = owner_of(r.location)
@@ -282,8 +290,14 @@ module Hecks
282
290
  if diff[:missing_from_ruby].empty? && diff[:unaccounted_in_ruby].empty?
283
291
  lines << " clean — every declared field is emitted (or a named deviation), nothing emitted is undeclared"
284
292
  else
285
- lines << " MISSING FROM RUBY (declared, not emitted, not a named deviation): #{diff[:missing_from_ruby].join(', ')}" unless diff[:missing_from_ruby].empty?
286
- lines << " UNACCOUNTED IN RUBY (emitted, not declared, not a named deviation): #{diff[:unaccounted_in_ruby].join(', ')}" unless diff[:unaccounted_in_ruby].empty?
293
+ unless diff[:missing_from_ruby].empty?
294
+ lines << " MISSING FROM RUBY (declared, not emitted, not a named deviation): " \
295
+ "#{diff[:missing_from_ruby].join(', ')}"
296
+ end
297
+ unless diff[:unaccounted_in_ruby].empty?
298
+ lines << " UNACCOUNTED IN RUBY (emitted, not declared, not a named deviation): " \
299
+ "#{diff[:unaccounted_in_ruby].join(', ')}"
300
+ end
287
301
  end
288
302
  lines.join("\n")
289
303
  end.join("\n\n")
@@ -358,6 +372,10 @@ module Hecks
358
372
  # was never supposed to have this touchpoint at all.
359
373
  def reconstruction_reads?(name, field)
360
374
  method_name = RECONSTRUCTION_METHODS[name]
375
+ # rubocop:disable-next Style/ReturnNilInPredicateMethodDefinition -- nil vs
376
+ # false is a deliberate distinction here: nil means "not applicable" (no
377
+ # hand-typed method to check), false means "applicable, and it fails" —
378
+ # see the spec's own "not-applicable (nil), not false" example.
361
379
  return nil unless method_name
362
380
 
363
381
  file, start_line = Hecks::Bluebook::MetaValidator::Reconstruction.instance_method(method_name).source_location
@@ -365,7 +383,9 @@ module Hecks
365
383
  def_line = lines[start_line - 1]
366
384
  indent = def_line[/\A\s*/]
367
385
 
368
- body = lines[start_line..].take_while { |line| line.strip.empty? || line[/\A\s*/].size > indent.size || !line.lstrip.start_with?("def ") }
386
+ body = lines[start_line..].take_while do |line|
387
+ line.strip.empty? || line[/\A\s*/].size > indent.size || !line.lstrip.start_with?("def ")
388
+ end
369
389
  body.join.include?("#{field}:")
370
390
  end
371
391
  private_class_method :reconstruction_reads?
@@ -385,7 +405,11 @@ module Hecks
385
405
  def format_impact_preview(preview)
386
406
  lines = ["== #{preview[:name]}##{preview[:field]} =="]
387
407
  preview[:touchpoints].each do |t|
388
- mark = t[:present].nil? ? "n/a" : (t[:present] ? "yes" : "NOT YET")
408
+ mark = if t[:present].nil?
409
+ "n/a"
410
+ else
411
+ (t[:present] ? "yes" : "NOT YET")
412
+ end
389
413
  lines << " [#{mark.rjust(7)}] #{t[:touchpoint]}"
390
414
  end
391
415
  done = preview[:touchpoints].count { |t| t[:present] == true }
@@ -2,7 +2,7 @@ module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
4
  AuthorizationSpec = Struct.new(:policy, :tenant, keyword_init: true) do
5
- def to_h = { policy: policy.to_s, tenant: tenant && tenant.to_s }
5
+ def to_h = { policy: policy.to_s, tenant: tenant&.to_s }
6
6
  end
7
7
  end
8
8
  end
@@ -2,6 +2,10 @@ require_relative "../../literal"
2
2
  require_relative "../../vocabulary"
3
3
 
4
4
  module Hecks
5
+ # Reopened (see query_specification.rb for the namespace's own
6
+ # summary) to add Common::COMPARATORS — the vendored comparator
7
+ # vocabulary — and .render_value, the wire-rendering entry point
8
+ # shared by every literal-bearing spec struct.
5
9
  module QuerySpecification
6
10
  module Common
7
11
  # `none_in_state`, vendored addition not (yet) upstream hecks
@@ -44,7 +44,7 @@ module Hecks
44
44
  value = value.to_h if value.is_a?(Runtime::Value)
45
45
  return value unless value.is_a?(Hash)
46
46
 
47
- numerics = value.values.select { |field| field.is_a?(Numeric) }
47
+ numerics = value.values.grep(Numeric)
48
48
  return numerics.first if numerics.size == 1
49
49
  return value.values.first if value.size == 1
50
50
 
@@ -63,6 +63,13 @@ module Hecks
63
63
 
64
64
  NUMERIC_TYPES = %w[Integer Float Numeric].freeze
65
65
 
66
+ # A `case` over a closed, declared set (Vocabulary::QueryComparator,
67
+ # held equal to this list by spec/vocabulary_table_spec — see the
68
+ # `else` branch's own comment) is the whole point of THE ONE
69
+ # COMPARATOR TABLE this file's header describes: one place naming
70
+ # every comparator, not one method per comparator scattered across
71
+ # a module.
72
+ # rubocop:disable-next Metrics/CyclomaticComplexity
66
73
  def holds?(operation, held, want, registry: nil)
67
74
  # A NULL SATISFIES NO COMPARISON — NullPolicy.unmatchable? owns
68
75
  # the rule and the reasoning, including why `none_in_state` is
@@ -11,6 +11,11 @@ require_relative "where_clause"
11
11
  module Hecks
12
12
  module QuerySpecification
13
13
  module Common
14
+ # Shared builder-instance methods for the `where`/`order_by`/
15
+ # `limit`/`offset`/`cursor`/`authorize`/`nulls`/`inspect_query`
16
+ # bluebook DSL words, mixed into both QueryBuilder and
17
+ # ReadModelBuilder so the two specification kinds parse the same
18
+ # clauses identically rather than each carrying its own copy.
14
19
  module DSL
15
20
  # RENAMED FROM `where`/`order_by`/`limit`/`offset`/`authorize`
16
21
  # (all below) — item #13's full metaprogrammed dispatch (slice
@@ -1,8 +1,15 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- LimitSpec = Struct.new(:value, keyword_init: true) do
5
- def to_h = { value: QuerySpecification.render_value(value) }
4
+ # `target` — see `WhereClause`'s own header (ADR 0055): which
5
+ # many-side `include`d aggregate this `limit` applies to, on a
6
+ # `read_model` declaring more than one. `nil` for a plain `Query`,
7
+ # and for a `read_model` with a single many-side head.
8
+ LimitSpec = Struct.new(:value, :target, keyword_init: true) do
9
+ def to_h
10
+ base = { value: QuerySpecification.render_value(value) }
11
+ target ? base.merge(target: target.to_s) : base
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -1,6 +1,12 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
+ # Null-handling shared between the in-memory and SQL query
5
+ # engines: stable null-aware ordering (#order, #sql_order) and
6
+ # which comparators a null value can never satisfy (#unmatchable?,
7
+ # #sql_predicate) — kept in one place because Memory, Postgres and
8
+ # SQLite each default nulls differently and must be made to agree
9
+ # explicitly rather than leaking their own native behavior.
4
10
  module NullPolicy
5
11
  module_function
6
12
 
@@ -82,8 +88,6 @@ module Hecks
82
88
  ["#{expression} IS NULL", []]
83
89
  elsif value.nil? && operation.to_s == "ne"
84
90
  ["#{expression} IS NOT NULL", []]
85
- else
86
- nil
87
91
  end
88
92
  end
89
93
  end
@@ -1,8 +1,15 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- OffsetSpec = Struct.new(:value, keyword_init: true) do
5
- def to_h = { value: QuerySpecification.render_value(value) }
4
+ # `target` — see `WhereClause`'s own header (ADR 0055): which
5
+ # many-side `include`d aggregate this `offset` applies to, on a
6
+ # `read_model` declaring more than one. `nil` for a plain `Query`,
7
+ # and for a `read_model` with a single many-side head.
8
+ OffsetSpec = Struct.new(:value, :target, keyword_init: true) do
9
+ def to_h
10
+ base = { value: QuerySpecification.render_value(value) }
11
+ target ? base.merge(target: target.to_s) : base
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -3,6 +3,12 @@ require_relative "null_semantics"
3
3
  module Hecks
4
4
  module QuerySpecification
5
5
  module Common
6
+ # The attribute set every query-shaped construct shares — wheres,
7
+ # ordering, paging, cursor, authorization, null semantics,
8
+ # inspection mode — plus `options_to_h`/`extra_options_to_h` for
9
+ # serializing it. Both Bluebook::Query and
10
+ # QuerySpecification::ReadModel::Specification subclass this
11
+ # rather than each declaring the same fields twice.
6
12
  class Options
7
13
  attr_reader :wheres, :order_by, :limit, :offset, :cursor,
8
14
  :authorization, :null_semantics, :inspection
@@ -10,8 +16,14 @@ module Hecks
10
16
  def initialize(wheres: [], order_by: nil, limit: nil, offset: nil, cursor: nil,
11
17
  authorization: nil,
12
18
  inspection: nil, null_semantics: NullSemantics.default)
13
- @wheres, @order_by, @limit, @offset, @cursor = wheres, order_by, limit, offset, cursor
14
- @authorization, @null_semantics, @inspection = authorization, null_semantics, inspection
19
+ @wheres = wheres
20
+ @order_by = order_by
21
+ @limit = limit
22
+ @offset = offset
23
+ @cursor = cursor
24
+ @authorization = authorization
25
+ @null_semantics = null_semantics
26
+ @inspection = inspection
15
27
  end
16
28
 
17
29
  def options_to_h
@@ -21,10 +33,10 @@ module Hecks
21
33
  end
22
34
 
23
35
  def extra_options_to_h
24
- options_to_h.reject { |key, value|
36
+ options_to_h.reject do |key, value|
25
37
  value.nil? || value == [] || (key == :null_semantics && value == { mode: "native" })
26
- }
27
- .reject { |key, _| %i[wheres order_by limit].include?(key) }
38
+ end
39
+ .except(:wheres, :order_by, :limit)
28
40
  end
29
41
  end
30
42
  end
@@ -1,8 +1,15 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- OrderBy = Struct.new(:field, :direction, keyword_init: true) do
5
- def to_h = { field: field.to_s, direction: direction.to_s }
4
+ # `target` see `WhereClause`'s own header (ADR 0055): which
5
+ # many-side `include`d aggregate this `order_by` applies to, on a
6
+ # `read_model` declaring more than one. `nil` for a plain `Query`,
7
+ # and for a `read_model` with a single many-side head.
8
+ OrderBy = Struct.new(:field, :direction, :target, keyword_init: true) do
9
+ def to_h
10
+ base = { field: field.to_s, direction: direction.to_s }
11
+ target ? base.merge(target: target.to_s) : base
12
+ end
6
13
  end
7
14
  end
8
15
  end