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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/claude_code.rb +3 -3
- data/lib/hecks/adapters/driven/d1.rb +48 -28
- data/lib/hecks/adapters/driven/folder.rb +10 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +3 -5
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
- data/lib/hecks/adapters/driven/lambda.rb +2 -2
- data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
- data/lib/hecks/adapters/driven/local_storage.rb +120 -0
- data/lib/hecks/adapters/driven/memory.rb +48 -3
- data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
- data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
- data/lib/hecks/adapters/driven/postgres.rb +45 -31
- data/lib/hecks/adapters/driven/prism.rb +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
- data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
- data/lib/hecks/adapters/driven/sqlite.rb +87 -8
- data/lib/hecks/adapters/driven.rb +4 -0
- data/lib/hecks/adapters.rb +4 -0
- data/lib/hecks/behaviors/dsl.rb +7 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/behaviors/rspec.rb +2 -0
- data/lib/hecks/behaviors/runner.rb +4 -0
- data/lib/hecks/bluebook/aggregate.rb +6 -5
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
- data/lib/hecks/bluebook/assembly/build.rb +6 -3
- data/lib/hecks/bluebook/assembly/contract.rb +5 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
- data/lib/hecks/bluebook/assembly/marks.rb +15 -4
- data/lib/hecks/bluebook/assembly.rb +1 -3
- data/lib/hecks/bluebook/attribute.rb +6 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
- data/lib/hecks/bluebook/command.rb +6 -5
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
- data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
- data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
- data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
- data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
- data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
- data/lib/hecks/bluebook/entity.rb +5 -3
- data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
- data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
- data/lib/hecks/bluebook/expression/projection.json +12 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
- data/lib/hecks/bluebook/expression/resolver.rb +83 -11
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +10 -0
- data/lib/hecks/bluebook/lifecycle.rb +9 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
- data/lib/hecks/bluebook/meta_validator.rb +5 -5
- data/lib/hecks/bluebook/model_check.rb +119 -82
- data/lib/hecks/bluebook/pattern_subset.rb +9 -0
- data/lib/hecks/bluebook/policy.rb +2 -1
- data/lib/hecks/bluebook/process_manager.rb +6 -0
- data/lib/hecks/bluebook/project_register.rb +45 -25
- data/lib/hecks/bluebook/query.rb +4 -0
- data/lib/hecks/bluebook/read_model.rb +2 -2
- data/lib/hecks/bluebook/smoke_test.rb +5 -0
- data/lib/hecks/bluebook/synthesizer.rb +2 -1
- data/lib/hecks/bluebook/translation.rb +3 -0
- data/lib/hecks/bluebook/value_object.rb +2 -2
- data/lib/hecks/codemod.rb +13 -0
- data/lib/hecks/doc/reference.rb +17 -11
- data/lib/hecks/facade/cli_runner.rb +2 -2
- data/lib/hecks/facade/command_request.rb +11 -11
- data/lib/hecks/facade/handle.rb +3 -3
- data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
- data/lib/hecks/facade.rb +4 -0
- data/lib/hecks/forms/app.rb +10 -6
- data/lib/hecks/forms/command_form_renderer.rb +3 -3
- data/lib/hecks/forms/field_renderer.rb +14 -9
- data/lib/hecks/forms/field_shape.rb +7 -2
- data/lib/hecks/forms/html.rb +5 -2
- data/lib/hecks/forms/index_renderer.rb +1 -1
- data/lib/hecks/forms/page.rb +7 -3
- data/lib/hecks/forms/query_form_renderer.rb +3 -2
- data/lib/hecks/forms/record_renderer.rb +22 -16
- data/lib/hecks/forms.rb +4 -0
- data/lib/hecks/fqn.rb +21 -9
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
- data/lib/hecks/fuzzing/properties/guards.rb +251 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
- data/lib/hecks/fuzzing/properties/querying.rb +206 -0
- data/lib/hecks/fuzzing/properties.rb +41 -1065
- data/lib/hecks/fuzzing/replay.rb +60 -16
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
- data/lib/hecks/fuzzing/value_generator.rb +2 -2
- data/lib/hecks/grammar/evolve.rb +34 -10
- data/lib/hecks/grammar/expression_operators.json +124 -0
- data/lib/hecks/grammar.rb +15 -5
- data/lib/hecks/ir.rb +9 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
- data/lib/hecks/language/bluebook/command.bluebook +2 -1
- data/lib/hecks/language/bluebook/entity.bluebook +2 -1
- data/lib/hecks/language/bluebook/projection.bluebook +2 -1
- data/lib/hecks/language/bluebook/query.bluebook +4 -2
- data/lib/hecks/language/bluebook/shape.bluebook +2 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
- data/lib/hecks/literal.rb +18 -8
- data/lib/hecks/naming.rb +6 -3
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.rb +5 -3
- data/lib/hecks/ports/authentication.rb +3 -2
- data/lib/hecks/ports/authorization.rb +1 -1
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.rb +5 -1
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/loading.rb +4 -1
- data/lib/hecks/ports/persistence/append_only.rb +51 -4
- data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
- data/lib/hecks/ports/persistence/plugin.rb +3 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
- data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
- data/lib/hecks/ports/persistence.rb +7 -3
- data/lib/hecks/ports/projection.rb +13 -5
- data/lib/hecks/ports/query/in_memory.rb +8 -2
- data/lib/hecks/ports/query.rb +1 -3
- data/lib/hecks/ports.rb +2 -0
- data/lib/hecks/projections/diagrams.rb +11 -3
- data/lib/hecks/projections/glossary.rb +374 -0
- data/lib/hecks/projections/ir.rb +2 -0
- data/lib/hecks/projections/model/deviations.rb +2 -1
- data/lib/hecks/projections/model.rb +15 -10
- data/lib/hecks/projections/vocabulary.rb +1 -3
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +78 -42
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +34 -1
- data/lib/hecks/projector/narrate_projector.rb +83 -25
- data/lib/hecks/projector.rb +2 -2
- data/lib/hecks/query_ir.rb +60 -36
- data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
- data/lib/hecks/query_specification/common/comparators.rb +4 -0
- data/lib/hecks/query_specification/common/comparison.rb +8 -1
- data/lib/hecks/query_specification/common/dsl.rb +5 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/query_specification/field_path.rb +4 -4
- data/lib/hecks/query_specification/read_model/specification.rb +5 -2
- data/lib/hecks/router/namespace_installer.rb +22 -10
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
- data/lib/hecks/runtime/command_interpreter.rb +104 -65
- data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
- data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
- data/lib/hecks/runtime/dependency_planning.rb +33 -21
- data/lib/hecks/runtime/dispatcher.rb +41 -7
- data/lib/hecks/runtime/entity_element.rb +25 -6
- data/lib/hecks/runtime/entity_interpreter.rb +28 -15
- data/lib/hecks/runtime/errors.rb +28 -2
- data/lib/hecks/runtime/instance.rb +9 -3
- data/lib/hecks/runtime/interpreting.rb +45 -9
- data/lib/hecks/runtime/loader.rb +21 -0
- data/lib/hecks/runtime/outbox.rb +365 -0
- data/lib/hecks/runtime/policy_interpreter.rb +32 -17
- data/lib/hecks/runtime/query_interpreter.rb +36 -16
- data/lib/hecks/runtime/reaction_invocation.rb +35 -22
- data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
- data/lib/hecks/runtime/reference_hop.rb +1 -1
- data/lib/hecks/runtime/refusal_wording.rb +55 -49
- data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
- data/lib/hecks/runtime/registry/verification.rb +39 -2
- data/lib/hecks/runtime/registry.rb +21 -11
- data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
- data/lib/hecks/runtime/routing.rb +52 -39
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
- data/lib/hecks/runtime/saga_interpreter.rb +119 -90
- data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
- data/lib/hecks/runtime/tenant_scope.rb +7 -0
- data/lib/hecks/runtime/value/coercion.rb +271 -49
- data/lib/hecks/runtime/value.rb +1 -1
- data/lib/hecks/runtime.rb +7 -2
- data/lib/hecks/storehouse.rb +15 -8
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -0
- data/lib/hecks.rb +17 -13
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
- metadata +16 -2
|
@@ -44,35 +44,48 @@ module Hecks
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
with_spec.to_h do |key, source|
|
|
47
|
-
value =
|
|
48
|
-
source
|
|
49
|
-
elsif normalized_bindings.key?(source)
|
|
50
|
-
normalized_bindings.fetch(source)
|
|
51
|
-
else
|
|
52
|
-
visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
|
|
53
|
-
unless visible
|
|
54
|
-
names = normalized_scopes.map(&:name).join(" then ")
|
|
55
|
-
# WHAT IS VISIBLE, NAMED. A refusal that only says which name is
|
|
56
|
-
# missing sent a modeler guessing field after field
|
|
57
|
-
# (`number`, `reference`…) at a fan-out row that is addressed
|
|
58
|
-
# by ONE key — `account`, the lowercase aggregate — which
|
|
59
|
-
# nothing else in the domain spells out. The names each scope
|
|
60
|
-
# actually offers are the whole diagnosis; the refusal now
|
|
61
|
-
# lists them, scope by scope.
|
|
62
|
-
offered = normalized_scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
|
|
63
|
-
raise UnknownArgument,
|
|
64
|
-
"#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
|
|
65
|
-
end
|
|
66
|
-
visible.facts.fetch(source)
|
|
67
|
-
end
|
|
68
|
-
|
|
47
|
+
value = resolved_mapping_value(source, normalized_bindings, normalized_scopes, label)
|
|
69
48
|
[key.to_sym, Value.materialize(value)]
|
|
70
49
|
end
|
|
71
50
|
end
|
|
72
51
|
|
|
52
|
+
# ONE `with:` SOURCE, RESOLVED — pulled out of resolve_mapping
|
|
53
|
+
# because it is a pure function of its own arguments (a literal, a
|
|
54
|
+
# binding, or a name visible in some scope), with no dependency on
|
|
55
|
+
# anything else resolve_mapping's own to_h block is doing.
|
|
56
|
+
def resolved_mapping_value(source, bindings, scopes, label)
|
|
57
|
+
return source unless source.is_a?(Symbol)
|
|
58
|
+
return bindings.fetch(source) if bindings.key?(source)
|
|
59
|
+
|
|
60
|
+
visible = scopes.find { |scope| scope.facts.key?(source) }
|
|
61
|
+
unless visible
|
|
62
|
+
names = scopes.map(&:name).join(" then ")
|
|
63
|
+
# WHAT IS VISIBLE, NAMED. A refusal that only says which name is
|
|
64
|
+
# missing sent a modeler guessing field after field
|
|
65
|
+
# (`number`, `reference`…) at a fan-out row that is addressed
|
|
66
|
+
# by ONE key — `account`, the lowercase aggregate — which
|
|
67
|
+
# nothing else in the domain spells out. The names each scope
|
|
68
|
+
# actually offers are the whole diagnosis; the refusal now
|
|
69
|
+
# lists them, scope by scope.
|
|
70
|
+
offered = scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
|
|
71
|
+
raise UnknownArgument,
|
|
72
|
+
"#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
|
|
73
|
+
end
|
|
74
|
+
visible.facts.fetch(source)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :resolved_mapping_value
|
|
77
|
+
|
|
73
78
|
# A reaction without an explicit projection retains wholesale legacy
|
|
74
79
|
# forwarding. Choosing `with:` opts into the strict envelope: only target
|
|
75
80
|
# command attributes enter `with:`, while identities become `to:`.
|
|
81
|
+
# `consumed` accumulates across the aggregate-identity and entity-
|
|
82
|
+
# identity steps below, and refuse_unconsumed! at the end reads the
|
|
83
|
+
# FINAL list — an ordering dependency threaded through one shared
|
|
84
|
+
# local. Already leans on private helpers (identity_for,
|
|
85
|
+
# source_receiver_for, command_facts, refuse_unconsumed!) for every
|
|
86
|
+
# piece that IS self-contained; what remains is the sequencing
|
|
87
|
+
# itself, which further splitting would only relocate, not remove.
|
|
88
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
76
89
|
def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
|
|
77
90
|
args = projected.transform_keys(&:to_sym)
|
|
78
91
|
unless explicit
|
|
@@ -8,6 +8,13 @@ require_relative "../query_specification/field_path"
|
|
|
8
8
|
|
|
9
9
|
module Hecks
|
|
10
10
|
module Runtime
|
|
11
|
+
# Interprets one declared `read_model`: resolves each of its
|
|
12
|
+
# `include`d heads (root first, then the rest in dependency order),
|
|
13
|
+
# matches non-root heads to already-projected rows by reference
|
|
14
|
+
# field, and applies group_by/count/median reduction where declared.
|
|
15
|
+
# Prefers a native SQLite escape hatch (#query_read_model) when the
|
|
16
|
+
# model is simple enough for one; otherwise runs the whole join
|
|
17
|
+
# in-process against loaded records.
|
|
11
18
|
class ReadModelInterpreter
|
|
12
19
|
def initialize(registry) = @registry = registry
|
|
13
20
|
|
|
@@ -17,6 +24,19 @@ module Hecks
|
|
|
17
24
|
|
|
18
25
|
private
|
|
19
26
|
|
|
27
|
+
# ROOT-FIRST, THEN THE SQLITE ESCAPE HATCH, THEN THE JOIN LOOP —
|
|
28
|
+
# each step's own comment names a real, previously-shipped bug the
|
|
29
|
+
# current ORDER fixes (the reference/TenantScope refusal ordering
|
|
30
|
+
# above, the root-first head processing below). Splitting this
|
|
31
|
+
# into smaller methods would scatter that ordering across method
|
|
32
|
+
# boundaries where a future editor could silently break it, and
|
|
33
|
+
# would force threading `rootless`/`reference_id`/`eligible`/
|
|
34
|
+
# `projected`/`rows_by_as` between the pieces as parameters and
|
|
35
|
+
# return values.
|
|
36
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
37
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
38
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
39
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
20
40
|
def project(domain, model, args)
|
|
21
41
|
bluebook = @registry.bluebook(domain)
|
|
22
42
|
rootless = model.reference_target.nil?
|
|
@@ -30,10 +50,11 @@ module Hecks
|
|
|
30
50
|
refuse_object_reference(model, args) unless rootless
|
|
31
51
|
reference_id = reference(args.fetch(model.reference_name)) unless rootless
|
|
32
52
|
# Computed off the ORIGINAL model, before TenantScope wraps it — the
|
|
33
|
-
# "which head do options apply to" question is about what the
|
|
53
|
+
# "which head(s) do options apply to" question is about what the
|
|
34
54
|
# bluebook author declared, not about the synthetic tenant clause
|
|
35
|
-
# the wrapper adds underneath.
|
|
36
|
-
eligible
|
|
55
|
+
# the wrapper adds underneath. Plural (ADR 0055) — `on:` lets more
|
|
56
|
+
# than one many-side head be eligible at once.
|
|
57
|
+
eligible = model.filtered_head_names
|
|
37
58
|
model = TenantScope.apply(model, args)
|
|
38
59
|
# A ROOTLESS, `group_by`-declared, or `count`/`median`-declared
|
|
39
60
|
# model skips the SQLite native escape hatch entirely (there is
|
|
@@ -46,7 +67,10 @@ module Hecks
|
|
|
46
67
|
# than narrowed.
|
|
47
68
|
unless rootless || model.group_by.any? || model.count? || model.median_field
|
|
48
69
|
repository = @registry.read_repository(domain, bluebook.aggregate(model.reference_target))
|
|
49
|
-
|
|
70
|
+
if repository.respond_to?(:query_read_model) && repository.adapter.respond_to?(:query_read_model)
|
|
71
|
+
return repository.query_read_model(domain, model, args,
|
|
72
|
+
bluebook)
|
|
73
|
+
end
|
|
50
74
|
end
|
|
51
75
|
|
|
52
76
|
# ROOT FIRST, ALWAYS — regardless of `include` order in the
|
|
@@ -96,14 +120,14 @@ module Hecks
|
|
|
96
120
|
end
|
|
97
121
|
end
|
|
98
122
|
end
|
|
99
|
-
rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as]
|
|
123
|
+
rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
|
|
100
124
|
projected << { aggregate: head[:aggregate], rows: rows }
|
|
101
125
|
rows_by_as[head[:as]] = head[:many] ? rows : rows.first
|
|
102
126
|
end
|
|
103
127
|
# Declared order preserved in the OUTPUT — only the
|
|
104
128
|
# computation above needed reordering, not what a caller sees
|
|
105
129
|
# back.
|
|
106
|
-
heads = model.aggregate_heads.
|
|
130
|
+
heads = model.aggregate_heads.to_h { |head| [head[:as], rows_by_as[head[:as]]] }
|
|
107
131
|
grouped_head = group_by_target(model, bluebook)
|
|
108
132
|
reduced_head = aggregation_target(model, bluebook)
|
|
109
133
|
[heads.each_with_object({}) do |(as, value), out|
|
|
@@ -343,7 +367,7 @@ module Hecks
|
|
|
343
367
|
.map(&:name)
|
|
344
368
|
end
|
|
345
369
|
|
|
346
|
-
def matching(records) = records.select
|
|
370
|
+
def matching(records, &) = records.select(&).sort_by(&:id)
|
|
347
371
|
def row(record) = record.to_h
|
|
348
372
|
end
|
|
349
373
|
end
|
|
@@ -13,99 +13,105 @@ module Hecks
|
|
|
13
13
|
# every dispatch, the same reason Expression::Evaluator::
|
|
14
14
|
# SIGN_TEST_OPERATORS is hand-typed beside Vocabulary::SignTest.
|
|
15
15
|
module RefusalWording
|
|
16
|
+
# `Layout/HashAlignment`'s repo-wide `table` style (.rubocop.yml)
|
|
17
|
+
# would force every template string below onto the SAME column —
|
|
18
|
+
# matching the widest key — leaving the longer wordings almost no
|
|
19
|
+
# room to wrap under Layout/LineLength's own 130-column limit.
|
|
20
|
+
# Disabled for exactly this one hash literal (a single statement, so
|
|
21
|
+
# disable-next rather than a disable/enable pair).
|
|
22
|
+
# rubocop:disable-next Layout/HashAlignment
|
|
16
23
|
TEMPLATES = {
|
|
17
24
|
%w[NotFound creating_no_identity] =>
|
|
18
|
-
|
|
25
|
+
"{command} creates a {aggregate} — pass {identity}:",
|
|
19
26
|
%w[AlreadyExists creating_duplicate] =>
|
|
20
|
-
|
|
27
|
+
"{command} creates a {aggregate} that already exists — {identity} {offered}",
|
|
21
28
|
%w[AlreadyExists entity_duplicate] =>
|
|
22
|
-
|
|
29
|
+
"a {entity} already exists on {aggregate} — {identity} {offered}",
|
|
23
30
|
%w[NotFound acting_no_identity] =>
|
|
24
|
-
|
|
31
|
+
"{command} acts on an existing {aggregate} — pass {identity}:",
|
|
25
32
|
%w[NotFound record_missing] =>
|
|
26
|
-
|
|
33
|
+
"no {aggregate} with {identity} {offered}",
|
|
27
34
|
%w[NotFound entity_parent_no_identity] =>
|
|
28
|
-
|
|
35
|
+
"{command} acts on a {aggregate}'s {entity} — pass {identity}:",
|
|
29
36
|
%w[UnknownVerb entity_unknown] =>
|
|
30
|
-
|
|
37
|
+
"{aggregate} has no entity {entity}",
|
|
31
38
|
%w[NotFound entity_element_no_identity] =>
|
|
32
|
-
|
|
39
|
+
"{command} acts on one {entity} — pass {identity}:",
|
|
33
40
|
%w[NotFound entity_element_missing] =>
|
|
34
|
-
|
|
41
|
+
"no {entity} with {identity} {wants} on {aggregate} {parent_id}",
|
|
35
42
|
%w[NotFound reference_target_missing] =>
|
|
36
|
-
|
|
43
|
+
"no {target} with {heads} {key}",
|
|
37
44
|
%w[NotFound read_model_reference_missing] =>
|
|
38
|
-
|
|
45
|
+
"no {aggregate} with reference {offered}",
|
|
39
46
|
%w[TypeMismatch read_model_object_reference] =>
|
|
40
|
-
|
|
47
|
+
"{query} refused — a reference is an id, and {field} arrived as an object",
|
|
41
48
|
%w[UnknownVerb no_query] =>
|
|
42
|
-
|
|
49
|
+
"{aggregate} has no query {query}",
|
|
43
50
|
%w[UnknownVerb entity_query_missing] =>
|
|
44
|
-
|
|
51
|
+
"{entity} has no query {query}",
|
|
45
52
|
%w[UnknownVerb entity_holds_no_list] =>
|
|
46
|
-
|
|
53
|
+
"{aggregate} holds no list of {entity}",
|
|
47
54
|
%w[UnknownVerb entity_no_command] =>
|
|
48
|
-
|
|
55
|
+
"{entity} has no command {command}",
|
|
49
56
|
%w[UnknownVerb aggregate_no_command] =>
|
|
50
|
-
|
|
57
|
+
"{aggregate} has no command {command}",
|
|
51
58
|
%w[UnknownVerb port_no_operation] =>
|
|
52
|
-
|
|
59
|
+
"{port} has no operation {operation}",
|
|
53
60
|
%w[UnknownVerb no_domain] =>
|
|
54
|
-
|
|
61
|
+
"no domain {domain} loaded (verb {verb})",
|
|
55
62
|
%w[UnknownVerb no_read_model] =>
|
|
56
|
-
|
|
63
|
+
"{domain} has no read model {query}",
|
|
57
64
|
%w[UnknownVerb not_fully_qualified] =>
|
|
58
|
-
|
|
65
|
+
"{verb} is not a fully-qualified verb (Domain::Aggregate.Command)",
|
|
59
66
|
%w[UnknownVerb no_aggregate] =>
|
|
60
|
-
|
|
67
|
+
"{domain} has no aggregate {aggregate}",
|
|
61
68
|
%w[LifecycleRefused transition_blocked] =>
|
|
62
|
-
|
|
69
|
+
"{command} refused — {field} is {current}, and {command} moves it only from {allowed}",
|
|
63
70
|
%w[TypeMismatch value_object_shape] =>
|
|
64
|
-
|
|
71
|
+
"{name} is a {type} — pass its fields as an object, not {offered}",
|
|
65
72
|
%w[TypeMismatch reference_as_object] =>
|
|
66
|
-
|
|
73
|
+
"{command} refused — a reference is an id, and {attribute} arrived as an object{known_by}",
|
|
67
74
|
%w[TypeMismatch multi_field_scalar] =>
|
|
68
|
-
|
|
75
|
+
"{type} has multiple fields and cannot stand in for a scalar",
|
|
69
76
|
%w[TypeMismatch composite_identity] =>
|
|
70
|
-
|
|
77
|
+
"{type} is a composite identity — an identity must have exactly one field",
|
|
71
78
|
%w[TypeMismatch numeric_field] =>
|
|
72
|
-
|
|
79
|
+
"{type}.{field} expects {expected}, got {offered}",
|
|
73
80
|
%w[TypeMismatch non_finite_field] =>
|
|
74
|
-
|
|
81
|
+
"{type}.{field} must be a finite number, got {offered}",
|
|
82
|
+
%w[TypeMismatch integer_range] =>
|
|
83
|
+
"{type}.{field} must fit in a 64-bit integer, got {offered}",
|
|
75
84
|
%w[TypeMismatch pattern_mismatch] =>
|
|
76
|
-
|
|
85
|
+
"{type}.{field} must match {pattern}, got {offered}",
|
|
77
86
|
%w[TypeMismatch arithmetic_amount] =>
|
|
78
|
-
|
|
87
|
+
"{op} of {target} needs an Integer, got {offered}",
|
|
79
88
|
%w[TypeMismatch arithmetic_current] =>
|
|
80
|
-
|
|
89
|
+
"{op} of {target} needs an Integer {target}, got {offered}",
|
|
81
90
|
%w[TypeMismatch arithmetic_shared_field] =>
|
|
82
|
-
|
|
91
|
+
"{op} of {target} needs a value object with one shared Integer field",
|
|
83
92
|
%w[UnknownArgument unknown_args] =>
|
|
84
|
-
|
|
93
|
+
"{command} does not declare {unknown} — it takes {declared}",
|
|
85
94
|
%w[AbsentArgument absent_args] =>
|
|
86
|
-
|
|
95
|
+
"{command} was not given {absent} — it takes {declared}",
|
|
87
96
|
%w[InvariantViolation closed_set_member] =>
|
|
88
|
-
|
|
97
|
+
"{type} admits {admitted} — got {offered}",
|
|
89
98
|
%w[InvariantViolation value_object_invariant] =>
|
|
90
|
-
|
|
99
|
+
"{name} invariant violated — {description} (given {offered})",
|
|
91
100
|
%w[InvariantViolation admits_declared_set] =>
|
|
92
|
-
|
|
101
|
+
"{name} admits {admits} — {admitted} — got {offered}",
|
|
93
102
|
%w[InvariantViolation undeclared_set] =>
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
"{name} admits {admits}, which this chapter does not declare — a closed set is named Aggregate::SetName, and it must " \
|
|
104
|
+
"be one the bluebook actually holds",
|
|
96
105
|
%w[Unauthorized tenant_required] =>
|
|
97
|
-
|
|
98
|
-
"{field} this ask is scoped to",
|
|
106
|
+
"{query} declares authorize with tenant: {field} — pass {field}: to name which {field} this ask is scoped to",
|
|
99
107
|
%w[Unauthorized role_mismatch] =>
|
|
100
|
-
|
|
108
|
+
"{command} refused — role: {role}, and the caller stated {caller_role}",
|
|
101
109
|
%w[AttributeAbsent absent_read] =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"default: ...), or declare it optional: true",
|
|
110
|
+
"{aggregate} {field} is absent on this record — declared, not optional, and added since it was written. Backfill it " \
|
|
111
|
+
"in a translation (backfill :{field}, default: ...), or declare it optional: true",
|
|
105
112
|
%w[ProjectionAbsent absent_read] =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"or read {reference}.{remote_field} directly if this rule cannot wait"
|
|
113
|
+
"{aggregate} {field} is not yet projected on this record — declared via projects :{field}, but no rebuild sweep has " \
|
|
114
|
+
"populated it. Run the sweep, or read {reference}.{remote_field} directly if this rule cannot wait"
|
|
109
115
|
}.freeze
|
|
110
116
|
|
|
111
117
|
module_function
|
|
@@ -42,7 +42,9 @@ module Hecks
|
|
|
42
42
|
# why reusing that one would deadlock.
|
|
43
43
|
def saga_persistence(domain)
|
|
44
44
|
key = domain.to_s
|
|
45
|
-
@saga_persistence[key] || @saga_persistence_mutex.synchronize
|
|
45
|
+
@saga_persistence[key] || @saga_persistence_mutex.synchronize do
|
|
46
|
+
@saga_persistence[key] ||= resolve_saga_persistence(key)
|
|
47
|
+
end
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# WALKS EVERY LOADED DOMAIN, repopulating `saga_instances` from
|
|
@@ -45,6 +45,7 @@ module Hecks
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
warn_undurable_sagas!(hexagon)
|
|
48
|
+
warn_undurable_outbox!(hexagon)
|
|
48
49
|
end
|
|
49
50
|
self
|
|
50
51
|
end
|
|
@@ -61,10 +62,10 @@ module Hecks
|
|
|
61
62
|
)
|
|
62
63
|
adapter_class(name)
|
|
63
64
|
self
|
|
64
|
-
rescue WiringError =>
|
|
65
|
+
rescue WiringError => e
|
|
65
66
|
raise WiringError,
|
|
66
67
|
"the default persistence adapter (#{name}) is not usable, so an " \
|
|
67
|
-
"aggregate with no bind could not be given one: #{
|
|
68
|
+
"aggregate with no bind could not be given one: #{e.message}"
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def check_verb(bind)
|
|
@@ -227,6 +228,42 @@ module Hecks
|
|
|
227
228
|
# refusing the boot outright would break a choice an author made
|
|
228
229
|
# deliberately. What a deploy needs is for the gap to be loud and
|
|
229
230
|
# undeniable, not for local dev/test to become impossible.
|
|
231
|
+
# THE OUTBOX'S TWIN OF `warn_undurable_sagas!` — a domain that
|
|
232
|
+
# declares anything a commit could owe a reaction to (a policy
|
|
233
|
+
# listening to one of its events, or a process manager) but is
|
|
234
|
+
# bound to an adapter with no outbox (`AppendOnly#outbox?`) gets
|
|
235
|
+
# reactions the pre-outbox way: run inline, lost on a crash
|
|
236
|
+
# between commit and reaction. A warning, not a refusal, for the
|
|
237
|
+
# reason `warn_undurable_sagas!` gives — and Memory HAS an outbox
|
|
238
|
+
# (in-process, like everything else it holds), so a dev/test
|
|
239
|
+
# boot stays quiet; this speaks up for the file/remote adapters
|
|
240
|
+
# that persist state durably but hand reactions to nothing.
|
|
241
|
+
def warn_undurable_outbox!(hexagon)
|
|
242
|
+
bluebook_ir = bluebook(hexagon.domain)
|
|
243
|
+
return unless bluebook_ir
|
|
244
|
+
return if bluebook_ir.process_managers.empty? && !any_policy_listens_to?(bluebook_ir)
|
|
245
|
+
|
|
246
|
+
anchor = bluebook_ir.aggregates.first or return
|
|
247
|
+
bind = Ports::Persistence::BindingPolicy.resolve(self, hexagon.domain, anchor)
|
|
248
|
+
return if adapter_class(bind.adapter) <= Ports::Persistence::RemoteRuntime
|
|
249
|
+
return if repository(hexagon.domain, anchor).outbox?
|
|
250
|
+
|
|
251
|
+
warn "[hecks] #{hexagon.domain} declares policies/process_managers but its persistence adapter " \
|
|
252
|
+
"(#{bind.adapter}) has no outbox — reactions run inline and a crash between a command's commit " \
|
|
253
|
+
"and its reactions loses them silently. Bind SqlitePersistence or Postgres for a durable outbox " \
|
|
254
|
+
"(see Runtime::Outbox), or accept in-process-only reactions on purpose."
|
|
255
|
+
rescue WiringError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def any_policy_listens_to?(bluebook_ir)
|
|
260
|
+
emitted = bluebook_ir.aggregates.flat_map do |aggregate|
|
|
261
|
+
aggregate.commands.flat_map(&:emits) +
|
|
262
|
+
aggregate.ports.flat_map { |port| port.operations.flat_map { |op| [*op.emits, op.answers, op.refuses] } }
|
|
263
|
+
end.compact.map(&:to_s)
|
|
264
|
+
@bluebooks.each_value.any? { |candidate| candidate.policies.any? { |policy| emitted.include?(policy.event_name.to_s) } }
|
|
265
|
+
end
|
|
266
|
+
|
|
230
267
|
def warn_undurable_sagas!(hexagon)
|
|
231
268
|
bluebook_ir = bluebook(hexagon.domain)
|
|
232
269
|
return unless bluebook_ir
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "registry/verification"
|
|
2
2
|
require_relative "registry/saga_persistence"
|
|
3
|
+
require_relative "outbox"
|
|
3
4
|
|
|
4
5
|
module Hecks
|
|
5
6
|
module Runtime
|
|
@@ -87,8 +88,16 @@ module Hecks
|
|
|
87
88
|
# reason).
|
|
88
89
|
@saga_persistence = {}
|
|
89
90
|
@saga_persistence_mutex = Mutex.new
|
|
91
|
+
@outbox = Outbox::Relay.new(self)
|
|
90
92
|
end
|
|
91
93
|
|
|
94
|
+
# THE OUTBOX RELAY — one per registry, for its whole life (built
|
|
95
|
+
# here, never swapped, so no thread ever sees a different one).
|
|
96
|
+
# It can enqueue from the moment the registry exists; a Dispatcher
|
|
97
|
+
# attaches the interpreters that let it deliver. See
|
|
98
|
+
# `Runtime::Outbox`.
|
|
99
|
+
attr_reader :outbox
|
|
100
|
+
|
|
92
101
|
# THE BUILDER STAYS OPEN FOR THE LIFE OF THIS REGISTRY, keyed by chapter
|
|
93
102
|
# name — see the comment on `BluebookBuilder.build`. A chapter split across
|
|
94
103
|
# several files (`language/bluebook/*.bluebook`, all `Hecks.bluebook "Bluebook"`)
|
|
@@ -201,6 +210,7 @@ module Hecks
|
|
|
201
210
|
@saga_instances.clear
|
|
202
211
|
@repositories = {}
|
|
203
212
|
@projection_repositories = {}
|
|
213
|
+
@outbox.log.clear
|
|
204
214
|
rehydrate_sagas!
|
|
205
215
|
self
|
|
206
216
|
end
|
|
@@ -249,13 +259,13 @@ module Hecks
|
|
|
249
259
|
# authoritative bind" check is what actually catches a genuine
|
|
250
260
|
# double-bind; this merge only concatenates, it does not itself
|
|
251
261
|
# decide which of two binds for the same aggregate wins.
|
|
252
|
-
def merge_hecksagons(
|
|
262
|
+
def merge_hecksagons(base, overlay)
|
|
253
263
|
Bluebook::Hecksagon.new(
|
|
254
|
-
domain:
|
|
255
|
-
binds:
|
|
256
|
-
subscriptions:
|
|
257
|
-
framework_members:
|
|
258
|
-
vendored_bluebooks:
|
|
264
|
+
domain: base.domain,
|
|
265
|
+
binds: base.binds + overlay.binds,
|
|
266
|
+
subscriptions: base.subscriptions + overlay.subscriptions,
|
|
267
|
+
framework_members: base.framework_members + overlay.framework_members,
|
|
268
|
+
vendored_bluebooks: base.vendored_bluebooks + overlay.vendored_bluebooks
|
|
259
269
|
)
|
|
260
270
|
end
|
|
261
271
|
|
|
@@ -266,12 +276,12 @@ module Hecks
|
|
|
266
276
|
# also declares REPLACES that key's whole resolved hash (the same
|
|
267
277
|
# all-or-nothing shape `WorldBuilder#method_missing` already builds
|
|
268
278
|
# each entry as), it does not deep-merge field by field within it.
|
|
269
|
-
def merge_worlds(
|
|
279
|
+
def merge_worlds(base, overlay)
|
|
270
280
|
Bluebook::World.new(
|
|
271
|
-
domain:
|
|
272
|
-
realm:
|
|
273
|
-
latest:
|
|
274
|
-
settings:
|
|
281
|
+
domain: base.domain,
|
|
282
|
+
realm: overlay.realm || base.realm,
|
|
283
|
+
latest: overlay.latest || base.latest,
|
|
284
|
+
settings: base.settings.merge(overlay.settings)
|
|
275
285
|
)
|
|
276
286
|
end
|
|
277
287
|
end
|
|
@@ -81,7 +81,9 @@ module Hecks
|
|
|
81
81
|
# Lambda that has no way to represent its lineage history at
|
|
82
82
|
# all.
|
|
83
83
|
adapter_name = Ports::Persistence::BindingPolicy.resolve(@registry, domain, aggregate).adapter
|
|
84
|
-
|
|
84
|
+
unless @registry.adapter_class(adapter_name) <= Ports::Persistence::RemoteRuntime
|
|
85
|
+
return @local.dispatch(verb, saga_correlation: saga_correlation, **args)
|
|
86
|
+
end
|
|
85
87
|
|
|
86
88
|
response = @client.dispatch(verb, args)
|
|
87
89
|
|
|
@@ -19,74 +19,87 @@ module Hecks
|
|
|
19
19
|
def envelope(to, entity_depth: 0)
|
|
20
20
|
return nil if to.nil?
|
|
21
21
|
|
|
22
|
-
aggregate, entities =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
unless unknown.empty?
|
|
26
|
-
raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
[hash[:aggregate], entity_identities(hash)]
|
|
30
|
-
else
|
|
31
|
-
[to, []]
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
if aggregate.nil? || aggregate.to_s.empty?
|
|
35
|
-
raise TypeMismatch, "to: must name the receiving aggregate identity"
|
|
36
|
-
end
|
|
22
|
+
aggregate, entities = to.is_a?(Hash) ? parse_envelope_hash(to) : [to, []]
|
|
23
|
+
|
|
24
|
+
raise TypeMismatch, "to: must name the receiving aggregate identity" if aggregate.nil? || aggregate.to_s.empty?
|
|
37
25
|
if entities.size != entity_depth
|
|
38
26
|
raise TypeMismatch,
|
|
39
27
|
"to: for an entity command needs #{entity_depth} entity " \
|
|
40
28
|
"#{entity_depth == 1 ? 'identity' : 'identities'} after the aggregate — got #{entities.size}"
|
|
41
29
|
end
|
|
42
|
-
if entities.any?
|
|
43
|
-
|
|
30
|
+
raise TypeMismatch, "to: contains a blank entity identity" if entities.any? do |identity|
|
|
31
|
+
identity.nil? || identity.to_s.empty?
|
|
44
32
|
end
|
|
45
33
|
|
|
46
34
|
Envelope.new(aggregate: aggregate, entities: entities)
|
|
47
35
|
end
|
|
48
36
|
|
|
37
|
+
# The Hash-shaped half of `to:` — pulled out of `envelope` because
|
|
38
|
+
# it is a self-contained parse (raises on an unrecognized key, then
|
|
39
|
+
# returns the pair) with no dependency on anything `envelope` does
|
|
40
|
+
# afterward; the validations that follow apply the same way
|
|
41
|
+
# whichever branch produced `aggregate`/`entities`.
|
|
42
|
+
def parse_envelope_hash(to)
|
|
43
|
+
hash = to.transform_keys(&:to_sym)
|
|
44
|
+
unknown = hash.keys - %i[aggregate entity entities]
|
|
45
|
+
raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}" unless unknown.empty?
|
|
46
|
+
|
|
47
|
+
[hash[:aggregate], entity_identities(hash)]
|
|
48
|
+
end
|
|
49
|
+
private_class_method :parse_envelope_hash
|
|
50
|
+
|
|
49
51
|
# `with:` is deliberately strict. Compatibility-only calls still pass
|
|
50
52
|
# loose keyword arguments through the old addressing gate, but a caller
|
|
51
53
|
# choosing the explicit envelope cannot smuggle receiver identity back
|
|
52
54
|
# into the payload.
|
|
53
55
|
def payload(command, with:, legacy:)
|
|
54
56
|
if with && !legacy.empty?
|
|
55
|
-
raise TypeMismatch,
|
|
57
|
+
raise TypeMismatch,
|
|
58
|
+
"dispatch takes command facts in with:, not both with: and loose keyword arguments"
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
return legacy unless with
|
|
59
|
-
unless with.is_a?(Hash)
|
|
60
|
-
raise TypeMismatch, "with: must be a hash of command facts"
|
|
61
|
-
end
|
|
62
|
+
raise TypeMismatch, "with: must be a hash of command facts" unless with.is_a?(Hash)
|
|
62
63
|
|
|
63
64
|
offered = with.transform_keys(&:to_sym)
|
|
64
65
|
declared = command.attributes.map { |attribute| attribute.name.to_sym }
|
|
66
|
+
refuse_unknown_facts!(command, offered, declared)
|
|
67
|
+
refuse_absent_facts!(command, offered, declared)
|
|
68
|
+
offered
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The two `with:` shape checks `payload` runs in sequence — pulled
|
|
72
|
+
# out because each is a self-contained "compute a difference, raise
|
|
73
|
+
# if non-empty" rule with no dependency on the other. Order stays
|
|
74
|
+
# unknown-before-absent, exactly as inline: `payload` calls them in
|
|
75
|
+
# that order, so a fact that is both unknown AND leaves something
|
|
76
|
+
# else absent still raises UnknownArgument first, same as before.
|
|
77
|
+
def refuse_unknown_facts!(command, offered, declared)
|
|
65
78
|
unknown = (offered.keys - declared).sort
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
return if unknown.empty?
|
|
80
|
+
|
|
81
|
+
reading = declared.empty? ? "none" : declared.join(", ")
|
|
82
|
+
raise UnknownArgument,
|
|
83
|
+
RefusalWording.render("UnknownArgument", "unknown_args",
|
|
84
|
+
command: command.hecks_name, unknown: unknown.join(", "),
|
|
85
|
+
declared: reading)
|
|
86
|
+
end
|
|
87
|
+
private_class_method :refuse_unknown_facts!
|
|
73
88
|
|
|
89
|
+
def refuse_absent_facts!(command, offered, declared)
|
|
74
90
|
absent = command.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } - offered.keys
|
|
75
|
-
|
|
76
|
-
reading = declared.empty? ? "none" : declared.join(", ")
|
|
77
|
-
raise AbsentArgument,
|
|
78
|
-
RefusalWording.render("AbsentArgument", "absent_args",
|
|
79
|
-
command: command.hecks_name, absent: absent.sort.join(", "),
|
|
80
|
-
declared: reading)
|
|
81
|
-
end
|
|
91
|
+
return if absent.empty?
|
|
82
92
|
|
|
83
|
-
|
|
93
|
+
reading = declared.empty? ? "none" : declared.join(", ")
|
|
94
|
+
raise AbsentArgument,
|
|
95
|
+
RefusalWording.render("AbsentArgument", "absent_args",
|
|
96
|
+
command: command.hecks_name, absent: absent.sort.join(", "),
|
|
97
|
+
declared: reading)
|
|
84
98
|
end
|
|
99
|
+
private_class_method :refuse_absent_facts!
|
|
85
100
|
|
|
86
101
|
def entity_identities(hash)
|
|
87
|
-
if hash.key?(:entities) && hash.key?(:entity)
|
|
88
|
-
raise TypeMismatch, "to: takes entity: or entities:, not both"
|
|
89
|
-
end
|
|
102
|
+
raise TypeMismatch, "to: takes entity: or entities:, not both" if hash.key?(:entities) && hash.key?(:entity)
|
|
90
103
|
|
|
91
104
|
hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
|
|
92
105
|
end
|