hecks 3.1.3 → 3.2.1
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/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +15 -0
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +43 -4
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
- data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/settings.rb +23 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box.rb +13 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/rust_build/kernel_input.rb +18 -6
- data/lib/hecks/rust_build/project_rust.rb +6 -132
- data/lib/hecks/site/bluebook/site.bluebook +27 -13
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/deploy_recipe.rb +1 -0
- data/lib/hecks/tools/rust_comment_style.rb +1 -1
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/build/src/lineage_pass.rs +1 -1
- data/rust/build/src/pipeline.rs +1 -1
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/main.rs +3 -4
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +1 -1
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +25 -1
- data/rust/host/src/needs.rs +94 -27
- data/rust/host/src/web.rs +9 -5
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +2 -2
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +5 -23
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
data/rust/project/commands.rb
DELETED
|
@@ -1,877 +0,0 @@
|
|
|
1
|
-
require_relative "skip_reason"
|
|
2
|
-
|
|
3
|
-
module RustProjection
|
|
4
|
-
module Projector
|
|
5
|
-
module_function
|
|
6
|
-
|
|
7
|
-
# The aggregate's own invariant set, passed to every dispatch of its commands.
|
|
8
|
-
# Entity lists are descended into only when the entity declares invariants (C6.2).
|
|
9
|
-
def invariants_fn_name(aggregate) = "#{rust_ident_field(aggregate[:name]).downcase}_invariants"
|
|
10
|
-
|
|
11
|
-
def emit_invariants_fn(aggregate)
|
|
12
|
-
aggregate_vec = invariant_specs_vec(aggregate[:invariants], 8)
|
|
13
|
-
entities_vec = entity_invariants_vec(aggregate, 8)
|
|
14
|
-
# Skip the `Expr` import when neither vec built an `Expr::` literal; it would be unused.
|
|
15
|
-
needs_expr = [aggregate_vec, entities_vec].any? { |vec| vec.include?("Expr::") }
|
|
16
|
-
[
|
|
17
|
-
"fn #{invariants_fn_name(aggregate)}() -> crate::kernel::InvariantSet {",
|
|
18
|
-
(needs_expr ? " use crate::kernel::Expr;" : nil),
|
|
19
|
-
" crate::kernel::InvariantSet {",
|
|
20
|
-
" aggregate: #{aggregate_vec},",
|
|
21
|
-
" entities: #{entities_vec},",
|
|
22
|
-
" }",
|
|
23
|
-
"}"
|
|
24
|
-
].compact.join("\n")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def invariant_specs_vec(rules, indent)
|
|
28
|
-
return "vec![]" if rules.empty?
|
|
29
|
-
|
|
30
|
-
pad = " " * (indent + 4)
|
|
31
|
-
rows = rules.map do |rule|
|
|
32
|
-
"#{pad}crate::kernel::InvariantSpec { description: #{rust_string_literal(rule[:description])}, " \
|
|
33
|
-
"expr: #{ExprEmitter.emit_ast(rule[:ast])} },"
|
|
34
|
-
end
|
|
35
|
-
"vec![\n#{rows.join("\n")}\n#{' ' * indent}]"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def entity_invariants_vec(owner, indent)
|
|
39
|
-
pieces = owner[:entities].filter_map do |entity|
|
|
40
|
-
next if entity[:invariants].empty?
|
|
41
|
-
|
|
42
|
-
list = owner[:attributes].find { |a| a[:list] && a[:type].to_s == entity[:name].to_s }
|
|
43
|
-
next unless list
|
|
44
|
-
|
|
45
|
-
[entity, list[:name].to_s]
|
|
46
|
-
end
|
|
47
|
-
return "vec![]" if pieces.empty?
|
|
48
|
-
|
|
49
|
-
pad = " " * (indent + 4)
|
|
50
|
-
rows = pieces.map do |entity, list_field|
|
|
51
|
-
"#{pad}crate::kernel::EntityInvariants { name: #{rust_string_literal(entity[:name])}, " \
|
|
52
|
-
"list_field: #{rust_string_literal(list_field)}, " \
|
|
53
|
-
"specs: #{invariant_specs_vec(entity[:invariants], indent + 4)}, " \
|
|
54
|
-
"nested: #{entity_invariants_vec(entity, indent + 4)} },"
|
|
55
|
-
end
|
|
56
|
-
"vec![\n#{rows.join("\n")}\n#{' ' * indent}]"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# `None` covers both "no transition" and an unconstrained one (`from: nil`); an empty
|
|
60
|
-
# `from_states` slice would refuse every state instead of admitting every state.
|
|
61
|
-
def transition_check_arg(transition)
|
|
62
|
-
return "None" if transition.nil? || transition[:unconstrained]
|
|
63
|
-
|
|
64
|
-
"Some(crate::kernel::TransitionCheck { field: #{transition[:field].inspect}, " \
|
|
65
|
-
"from_states: &[#{transition[:from_states].map(&:inspect).join(', ')}] })"
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Cross-aggregate dereferences, already resolved by the router into owned data,
|
|
69
|
-
# that every generated dispatch function takes.
|
|
70
|
-
DEREF_PARAMS = ["owner_deref: Vec<(&'static str, crate::kernel::DerefNode)>",
|
|
71
|
-
"command_deref: Vec<(&'static str, crate::kernel::DerefNode)>"].freeze
|
|
72
|
-
|
|
73
|
-
# Deferred tenant-boundary result, aggregate commands only; `dispatch()` checks it where
|
|
74
|
-
# Ruby's `step_save` does.
|
|
75
|
-
TENANT_BOUNDARY_PARAM = "tenant_boundary_check: Result<(), crate::kernel::Refusal>".freeze
|
|
76
|
-
|
|
77
|
-
# Binds `WithReferences`. Precedence, highest first: `command_deref`, typed `args`,
|
|
78
|
-
# `owner_deref`, matching the merge chain in `Admissibility#enforce_givens`.
|
|
79
|
-
def with_references_binding
|
|
80
|
-
"let with_references = crate::kernel::WithReferences { command_deref: &command_deref, args: &args, owner_deref: &owner_deref };"
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Seeds `projects` fields from the same `with_references` (ADR 0025); the field table is
|
|
84
|
-
# always emitted, so an empty spec list is a no-op.
|
|
85
|
-
def seed_projections_binding(aggregate)
|
|
86
|
-
table = screaming_snake(aggregate[:name])
|
|
87
|
-
"let seed_projections = crate::kernel::seeded_projections(&with_references, #{table}_PROJECTED_FIELDS);"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def command_skip_reason(command, aggregate, value_objects_by_name, creating_possible: true)
|
|
91
|
-
# `remove` is generated only for entity-typed lists matched by identity; value-object
|
|
92
|
-
# lists stay unsupported.
|
|
93
|
-
unsupported_ops = command[:mutations].reject { |m| %w[append set increment decrement multiply clamp remove delegate corrects].include?(m[:op].to_s) }.map { |m| m[:op] }.uniq
|
|
94
|
-
return skip("mutation_op", "sets op(s) #{unsupported_ops.join(', ')} not generated yet (only append/set/increment/decrement/multiply/clamp/remove/delegate/corrects are)") if unsupported_ops.any?
|
|
95
|
-
|
|
96
|
-
corrects = corrects_of(command)
|
|
97
|
-
# Skip only when `derive_reverses_mutations!` left this command untouched: a non-invertible
|
|
98
|
-
# sibling op has no reversal design yet (ADR 0041).
|
|
99
|
-
return skip("corrects_reverses", "corrects #{corrects[:target]}, reverses: true — the derived append/remove-reversal shape is a real, separate gap Ruby's own authors haven't finished designing (AggregateBuilder#seal_correction_targets's own comment) — not generated yet") \
|
|
100
|
-
if corrects && corrects_reverses?(corrects) && command[:mutations].none? { |m| m[:op].to_s != "corrects" }
|
|
101
|
-
|
|
102
|
-
delegate_problem = delegate_skip_reason(command, aggregate, value_objects_by_name)
|
|
103
|
-
return skip("delegate", delegate_problem) if delegate_problem
|
|
104
|
-
|
|
105
|
-
append_problems = append_field_problems(command, aggregate, value_objects_by_name)
|
|
106
|
-
return skip("append_field", "sets append field(s): #{append_problems.join('; ')}") if append_problems.any?
|
|
107
|
-
state_problems = state_source_problems(command, aggregate, value_objects_by_name)
|
|
108
|
-
return skip("state_source", "sets state source(s): #{state_problems.join('; ')}") if state_problems.any?
|
|
109
|
-
remove_problems = remove_field_problems(command, aggregate, value_objects_by_name)
|
|
110
|
-
return skip("remove_field", "sets remove field(s): #{remove_problems.join('; ')}") if remove_problems.any?
|
|
111
|
-
|
|
112
|
-
lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field].to_s
|
|
113
|
-
target_type_for = ->(target) { target.to_s == lifecycle_field ? "String" : aggregate[:attributes].find { |a| a[:name].to_s == target.to_s }&.dig(:type) }
|
|
114
|
-
# The lifecycle field is never list-typed.
|
|
115
|
-
target_list_for = ->(target) { target.to_s != lifecycle_field && !!aggregate[:attributes].find { |a| a[:name].to_s == target.to_s }&.dig(:list) }
|
|
116
|
-
|
|
117
|
-
# A literal `:set` source is a scalar or a raw Hash; a Hash must supply every field of the
|
|
118
|
-
# target value object.
|
|
119
|
-
literal_set_targets = command[:mutations].select do |m|
|
|
120
|
-
next false unless m[:op].to_s == "set" && m[:source][:kind] == "literal"
|
|
121
|
-
|
|
122
|
-
!literal_set_bridgeable?(m[:source][:value], target_type_for.call(m[:target]), value_objects_by_name)
|
|
123
|
-
end.map { |m| m[:target] }
|
|
124
|
-
return skip("set_literal", "sets to: a literal that doesn't bridge to the target's type (#{literal_set_targets.join(', ')}) — not generated yet") if literal_set_targets.any?
|
|
125
|
-
|
|
126
|
-
# Ruby coerces a `:set` value into the target's declared type; the generated clone only
|
|
127
|
-
# compiles when source and target agree, and cardinality (list vs scalar) must match.
|
|
128
|
-
mismatched_sets = command[:mutations].select do |m|
|
|
129
|
-
next false unless m[:op].to_s == "set" && m[:source][:kind] == "argument"
|
|
130
|
-
|
|
131
|
-
target_type = target_type_for.call(m[:target])
|
|
132
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == m[:source][:name] }
|
|
133
|
-
source_type = source_attr&.dig(:type)
|
|
134
|
-
next false unless target_type && source_type
|
|
135
|
-
|
|
136
|
-
target_list_for.call(m[:target]) != !!source_attr[:list] || !bridgeable_value_types?(source_type, target_type, value_objects_by_name)
|
|
137
|
-
end.map { |m| m[:target] }
|
|
138
|
-
return skip("set_argument_bridge", "sets :#{mismatched_sets.join(', ')} sources an argument no single-field rewrap can bridge to the target's type — not generated yet") if mismatched_sets.any?
|
|
139
|
-
|
|
140
|
-
# increment/decrement/multiply share one check: the target names an Integer field and the
|
|
141
|
-
# amount resolves to an integer expression.
|
|
142
|
-
arithmetic_targets = command[:mutations].select { |m| %w[increment decrement multiply].include?(m[:op].to_s) }
|
|
143
|
-
unsupported_arithmetic = arithmetic_targets.reject do |m|
|
|
144
|
-
target = arithmetic_target_field(m, aggregate, value_objects_by_name)
|
|
145
|
-
target && arithmetic_amount_expr(m[:source], command, value_objects_by_name, target[1])
|
|
146
|
-
end.map { |m| m[:target] }
|
|
147
|
-
return skip("arithmetic", "sets :#{unsupported_arithmetic.join(', ')} increment/decrement/multiply amount or target field isn't bridgeable — not generated yet") if unsupported_arithmetic.any?
|
|
148
|
-
|
|
149
|
-
# `clamp` bounds the current value with a literal [min, max] Integer pair; the target
|
|
150
|
-
# field rule is the same as arithmetic.
|
|
151
|
-
clamp_targets = command[:mutations].select { |m| m[:op].to_s == "clamp" }
|
|
152
|
-
unsupported_clamp = clamp_targets.reject do |m|
|
|
153
|
-
arithmetic_target_field(m, aggregate, value_objects_by_name) && clamp_bounds_ints(m[:source])
|
|
154
|
-
end.map { |m| m[:target] }
|
|
155
|
-
return skip("clamp", "sets :#{unsupported_clamp.join(', ')} clamp target field or bounds isn't bridgeable — not generated yet") if unsupported_clamp.any?
|
|
156
|
-
|
|
157
|
-
optional_problems = optional_source_mismatches(command, aggregate, value_objects_by_name, creating_possible: creating_possible)
|
|
158
|
-
return skip("optional_source", "optional argument feeds a non-optional target: #{optional_problems.join('; ')} — not generated yet") if optional_problems.any?
|
|
159
|
-
|
|
160
|
-
nil
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# An optional argument feeding a field that is not `Option<T>` cannot be generated: the Rust
|
|
164
|
-
# struct shape is fixed at compile time, while Ruby stores `nil`. Skipped, not guessed.
|
|
165
|
-
def optional_source_mismatches(command, aggregate, value_objects_by_name, creating_possible: true)
|
|
166
|
-
lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field].to_s
|
|
167
|
-
problems = []
|
|
168
|
-
|
|
169
|
-
# An entity command never creates, so its optional identity argument is not an identity
|
|
170
|
-
# source (`creating_possible: false`).
|
|
171
|
-
|
|
172
|
-
# `identified_by` is never Option-wrapped, so an optional argument feeding it is caught
|
|
173
|
-
# here; the `sets` checks below never see it.
|
|
174
|
-
if creating_possible && creates_owner?(aggregate, command, value_objects_by_name)
|
|
175
|
-
aggregate[:identified_by].each do |path|
|
|
176
|
-
head, = path.split(".")
|
|
177
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == head }
|
|
178
|
-
next unless source_attr && source_attr[:optional]
|
|
179
|
-
|
|
180
|
-
problems << "identified_by :#{path} sources optional argument #{source_attr[:name]}"
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
command[:mutations].each do |m|
|
|
185
|
-
case m[:op].to_s
|
|
186
|
-
when "set"
|
|
187
|
-
next unless m[:source][:kind] == "argument"
|
|
188
|
-
|
|
189
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == m[:source][:name].to_s }
|
|
190
|
-
next unless source_attr && source_attr[:optional]
|
|
191
|
-
|
|
192
|
-
if m[:target].to_s == lifecycle_field
|
|
193
|
-
problems << "sets :#{m[:target]} sources optional argument #{source_attr[:name]} into the lifecycle field"
|
|
194
|
-
next
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
|
|
198
|
-
# A list target is exempt: both `Vec<T>` and `Option<Vec<T>>` record fields are handled
|
|
199
|
-
# by the `:set` branch.
|
|
200
|
-
next if target_attr && target_attr[:list]
|
|
201
|
-
|
|
202
|
-
# Aggregate-command records hold `Option<T>` fields, so an optional argument assigns
|
|
203
|
-
# straight across; only entity element fields can mismatch.
|
|
204
|
-
next if creating_possible
|
|
205
|
-
|
|
206
|
-
problems << "sets :#{m[:target]} sources optional argument #{source_attr[:name]}" unless target_attr && target_attr[:optional]
|
|
207
|
-
when "append"
|
|
208
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
|
|
209
|
-
element = target_attr && append_element(aggregate, target_attr[:type], value_objects_by_name)
|
|
210
|
-
next unless element
|
|
211
|
-
|
|
212
|
-
m[:fields].each do |field_name, source|
|
|
213
|
-
parsed = append_field_source(source)
|
|
214
|
-
next unless parsed.is_a?(Symbol) # a literal, not a caller-omittable argument
|
|
215
|
-
|
|
216
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == parsed.to_s }
|
|
217
|
-
next unless source_attr && source_attr[:optional]
|
|
218
|
-
|
|
219
|
-
field_attr = element[:attributes].find { |a| a[:name].to_s == field_name.to_s }
|
|
220
|
-
problems << "sets append #{m[:target]}.#{field_name} sources optional argument #{source_attr[:name]}" unless field_attr && field_attr[:optional]
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
problems
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# Per-attribute admits-constraint and nested-value-object invariant checks, against an
|
|
229
|
-
# already-bound value expression.
|
|
230
|
-
# Usage-level `pattern:` is deliberately not checked: Ruby never enforces it on a command
|
|
231
|
-
# argument (ADR 0010). List attributes skip `admits:` too, since `admit_declared_set` is
|
|
232
|
-
# only reached on the scalar branch.
|
|
233
|
-
def argument_check_lines(attr, value_expr, aggregates_by_name, value_objects_by_name)
|
|
234
|
-
lines = []
|
|
235
|
-
|
|
236
|
-
unless attr[:list]
|
|
237
|
-
# Raw field expression: `emit_admits_check` does its own optional handling.
|
|
238
|
-
constraint = emit_admits_check(value_expr, attr, aggregates_by_name, value_objects_by_name)
|
|
239
|
-
lines << " #{constraint}" if constraint
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
if value_objects_by_name.key?(attr[:type]) && !value_objects_by_name[attr[:type]][:closed_set]
|
|
243
|
-
lines << if attr[:optional]
|
|
244
|
-
attr[:list] ? " if let Some(items) = &#{value_expr} { for item in items { item.check_invariants()?; } }" : " if let Some(v) = &#{value_expr} { v.check_invariants()?; }"
|
|
245
|
-
else
|
|
246
|
-
attr[:list] ? " for item in &#{value_expr} { item.check_invariants()?; }" : " #{value_expr}.check_invariants()?;"
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
lines
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# Post-construction copy against the built Args struct, run at the router and again in the
|
|
254
|
-
# dispatch fn; redundant with the interleaved checks in `from_json`, but kept.
|
|
255
|
-
def invariant_checks_for(command, aggregates_by_name, value_objects_by_name)
|
|
256
|
-
command[:attributes].flat_map do |attr|
|
|
257
|
-
field = rust_ident_field(attr[:name])
|
|
258
|
-
argument_check_lines(attr, "args.#{field}", aggregates_by_name, value_objects_by_name)
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# One emitter for every command shape `kernel::dispatch` can run, creating or acting.
|
|
263
|
-
def emit_command(command, aggregate, domain_name, value_objects_by_name, aggregates_by_name)
|
|
264
|
-
record = rust_ident(aggregate[:name])
|
|
265
|
-
cmd = rust_ident(command[:name])
|
|
266
|
-
creates = creates_owner?(aggregate, command, value_objects_by_name)
|
|
267
|
-
identity = identity_components(aggregate, command)
|
|
268
|
-
identity_extra_params = identity.filter_map { |c| c[:param] }
|
|
269
|
-
|
|
270
|
-
# `aggregate_name`/`identity_reading` feed the refusal wording of both `creating_duplicate`
|
|
271
|
-
# and `record_missing`, whatever the command shape.
|
|
272
|
-
aggregate_name = aggregate[:name].to_s
|
|
273
|
-
identity_reading = aggregate[:identified_by].join(", ")
|
|
274
|
-
|
|
275
|
-
# Reuses `struct_field` only; the Args derive lacks `PartialEq`, unlike `plain_struct`.
|
|
276
|
-
args_struct = ["pub struct #{cmd}Args {"]
|
|
277
|
-
command[:attributes].each do |attr|
|
|
278
|
-
type = rust_type(attr[:type], list: attr[:list])
|
|
279
|
-
# An optional argument is `Option<T>`; `command_skip_reason` guards the one shape that
|
|
280
|
-
# cannot represent it.
|
|
281
|
-
type = "Option<#{type}>" if attr[:optional]
|
|
282
|
-
args_struct << " #{Exemplar.render('struct_field', 'TmplFieldType' => type, 'tmpl_field' => rust_ident_field(attr[:name]))}"
|
|
283
|
-
end
|
|
284
|
-
args_struct << "}"
|
|
285
|
-
|
|
286
|
-
invariant_checks = invariant_checks_for(command, aggregates_by_name, value_objects_by_name)
|
|
287
|
-
|
|
288
|
-
given_specs = corrects_given_specs(command) + command[:givens].map do |given|
|
|
289
|
-
" crate::kernel::GivenSpec { description: #{rust_string_literal(given[:description])}, expr: #{ExprEmitter.emit_ast(given[:ast])}, corrects_event: None },"
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
ensures_specs = command[:ensures].map do |rule|
|
|
293
|
-
" crate::kernel::EnsuresSpec { description: #{rust_string_literal(rule[:description])}, expr: #{ExprEmitter.emit_ast(rule[:ast])} },"
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
transition = lifecycle_transition_for(command, aggregate)
|
|
297
|
-
transition_arg =
|
|
298
|
-
transition_check_arg(transition)
|
|
299
|
-
|
|
300
|
-
mutation_lines = command[:mutations].reject { |m| m[:op].to_s == "corrects" }
|
|
301
|
-
.map { |m| emit_mutation_line(m, aggregate, command, value_objects_by_name) }
|
|
302
|
-
mutation_lines.unshift(pre_state_line) if reads_pre_state?(command[:mutations])
|
|
303
|
-
mutation_lines.concat(corrects_flag_mutation_lines(command, aggregate))
|
|
304
|
-
# Advance the lifecycle unconditionally once a transition applies, even when no explicit
|
|
305
|
-
# set targets the lifecycle field.
|
|
306
|
-
mutation_lines << " record.#{rust_ident_field(transition[:field])} = #{transition[:to_state].inspect}.to_string();" if transition && transition[:to_state]
|
|
307
|
-
mutation_lines = [" let _ = record;"] if mutation_lines.empty? # nothing to apply — silence the unused-param warning
|
|
308
|
-
delegation = delegation_of(command, aggregate, value_objects_by_name, domain_name)
|
|
309
|
-
if delegation
|
|
310
|
-
raise "#{command[:name]}: a creating command cannot delegate — nothing exists to delegate to" if creates
|
|
311
|
-
|
|
312
|
-
# The element extraction runs first inside the closure, after a successful hydrate.
|
|
313
|
-
mutation_lines = [delegation[:element], delegation[:apply]]
|
|
314
|
-
end
|
|
315
|
-
prelude = delegation ? delegation[:prelude] : ""
|
|
316
|
-
payload = delegation ? "delegate_facts.clone()," : "args.to_json(),"
|
|
317
|
-
emits_out = delegation ? delegation[:emits] : command[:emits]
|
|
318
|
-
|
|
319
|
-
if creates
|
|
320
|
-
# A `projects` field is never a command argument, so it falls to `None`;
|
|
321
|
-
# `seed_projections` fills it before save.
|
|
322
|
-
record_fields = (aggregate[:attributes] + Projector.projected_field_pseudo_attributes(aggregate)).map do |attr|
|
|
323
|
-
matched = command[:attributes].find { |a| a[:name] == attr[:name] }
|
|
324
|
-
field = rust_ident_field(attr[:name])
|
|
325
|
-
if matched && matched[:optional]
|
|
326
|
-
# An optional argument is already `Option<T>`; a scalar record field takes it as is.
|
|
327
|
-
# A list field does too only when `list_attr_creation_optional?` wrapped it, else it is
|
|
328
|
-
# unwrapped with the `[]` fallback `default_for` gives an unmatched list attribute.
|
|
329
|
-
if attr[:list] && !list_attr_creation_optional?(aggregate, attr[:name], value_objects_by_name)
|
|
330
|
-
" #{field}: args.#{field}.clone().unwrap_or_default(),"
|
|
331
|
-
elsif attr[:list] || matched[:type] == attr[:type]
|
|
332
|
-
" #{field}: args.#{field}.clone(),"
|
|
333
|
-
else
|
|
334
|
-
# Same-named cross-aggregate argument of a different type (see `bridging.rb`):
|
|
335
|
-
# bridge it rather than clone.
|
|
336
|
-
" #{field}: #{optional_value_rhs("args.#{field}", matched[:type], attr[:type], value_objects_by_name)},"
|
|
337
|
-
end
|
|
338
|
-
elsif matched
|
|
339
|
-
if attr[:list] || matched[:type] == attr[:type]
|
|
340
|
-
attr[:list] ? " #{field}: args.#{field}.clone()," : " #{field}: Some(args.#{field}.clone()),"
|
|
341
|
-
else
|
|
342
|
-
" #{field}: Some(#{value_rhs("args.#{field}", matched[:type], attr[:type], value_objects_by_name)}),"
|
|
343
|
-
end
|
|
344
|
-
elsif attr[:list]
|
|
345
|
-
" #{field}: vec![],"
|
|
346
|
-
else
|
|
347
|
-
default_rhs = creation_default_rhs(attr, value_objects_by_name)
|
|
348
|
-
default_rhs ? " #{field}: Some(#{default_rhs})," : " #{field}: None,"
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
record_fields << " #{rust_ident_field(aggregate[:lifecycle][:field])}: #{aggregate[:lifecycle][:default].inspect}.to_string()," if aggregate[:lifecycle]
|
|
352
|
-
correctable_event_names(aggregate).each { |name| record_fields << " #{corrects_flag_field(name)}: false," }
|
|
353
|
-
|
|
354
|
-
# Mirrors `CommandInterpreter#step_hydrate`'s complete-state and state-independent branch.
|
|
355
|
-
state_independent = state_independent_creation?(aggregate, command, value_objects_by_name)
|
|
356
|
-
create_block = <<~RUST.rstrip
|
|
357
|
-
crate::kernel::Hydrate::Create {
|
|
358
|
-
id: __hydrate_id,
|
|
359
|
-
build: Box::new(|| #{record} {
|
|
360
|
-
#{record_fields.join("\n")}
|
|
361
|
-
}),
|
|
362
|
-
state_independent: #{state_independent},
|
|
363
|
-
}
|
|
364
|
-
RUST
|
|
365
|
-
|
|
366
|
-
# Ruby checks the route before choosing create-vs-find. With `complete_state?` a route is
|
|
367
|
-
# validated against the derived identity (`TypeMismatch` on mismatch), then creates;
|
|
368
|
-
# otherwise a route forces find-or-`NotFound` (`Hydrate::Act`).
|
|
369
|
-
complete_state = complete_state_creation?(aggregate, command, value_objects_by_name)
|
|
370
|
-
route_mismatch_message = "#{command[:name]} routes to {:?}, but its identity facts name {:?}"
|
|
371
|
-
route_arm =
|
|
372
|
-
if complete_state
|
|
373
|
-
<<~RUST.rstrip
|
|
374
|
-
Some(__route) => {
|
|
375
|
-
__route.require_depth(0)?;
|
|
376
|
-
let __hydrate_id: String = #{build_identity_expr(identity)};
|
|
377
|
-
if __route.aggregate() != __hydrate_id.as_str() {
|
|
378
|
-
return Err(crate::kernel::Refusal::TypeMismatch(format!(#{route_mismatch_message.inspect}, __route.aggregate(), __hydrate_id)));
|
|
379
|
-
}
|
|
380
|
-
#{create_block}
|
|
381
|
-
}
|
|
382
|
-
RUST
|
|
383
|
-
else
|
|
384
|
-
<<~RUST.rstrip
|
|
385
|
-
Some(__route) => {
|
|
386
|
-
__route.require_depth(0)?;
|
|
387
|
-
crate::kernel::Hydrate::Act { id: __route.aggregate().to_string() }
|
|
388
|
-
}
|
|
389
|
-
RUST
|
|
390
|
-
end
|
|
391
|
-
hydrate = <<~RUST.rstrip
|
|
392
|
-
match route {
|
|
393
|
-
#{route_arm}
|
|
394
|
-
None => { let __hydrate_id: String = #{build_identity_expr(identity)}; #{create_block} }
|
|
395
|
-
}
|
|
396
|
-
RUST
|
|
397
|
-
fn_signature = (["repo: &mut impl crate::kernel::Repository<#{record}>", "route: Option<&crate::kernel::RoutingEnvelope>"] + identity_extra_params +
|
|
398
|
-
["args: #{cmd}Args", "mutations: &mut Vec<crate::kernel::MutationRecord>", *DEREF_PARAMS, TENANT_BOUNDARY_PARAM]).join(", ")
|
|
399
|
-
else
|
|
400
|
-
hydrate = %(crate::kernel::Hydrate::Act { id: id.to_string() })
|
|
401
|
-
fn_signature = (["repo: &mut impl crate::kernel::Repository<#{record}>", "id: &str", "args: #{cmd}Args",
|
|
402
|
-
"mutations: &mut Vec<crate::kernel::MutationRecord>", *DEREF_PARAMS, TENANT_BOUNDARY_PARAM]).join(", ")
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
dispatch_fn = Exemplar.render(
|
|
406
|
-
"dispatch_fn",
|
|
407
|
-
"repo: &mut impl crate::kernel::Repository<TmplRecord>, id: &str, args: TmplArgs, mutations: &mut Vec<crate::kernel::MutationRecord>, #{TENANT_BOUNDARY_PARAM}" => fn_signature,
|
|
408
|
-
"dispatch_tmpl" => "dispatch_#{dispatch_fn_name(cmd)}",
|
|
409
|
-
"TmplRecord" => record,
|
|
410
|
-
"tmpl_invariant_check_placeholder()?;" => invariant_checks.join("\n"),
|
|
411
|
-
"let tmpl_eval_fielded = tmpl_with_references_placeholder();" => with_references_binding,
|
|
412
|
-
"&tmpl_eval_fielded," => "&with_references,",
|
|
413
|
-
"let tmpl_seed_projections = tmpl_seed_projections_placeholder();" => seed_projections_binding(aggregate),
|
|
414
|
-
"tmpl_seed_projections," => "seed_projections,",
|
|
415
|
-
"tmpl_hydrate_placeholder()" => hydrate,
|
|
416
|
-
"tmpl_prelude_placeholder();" => prelude,
|
|
417
|
-
'"TmplCmdName"' => cmd.inspect,
|
|
418
|
-
'"TmplQualifiedName"' => "#{domain_name}::#{aggregate[:name]}".inspect,
|
|
419
|
-
'"TmplAggregateName"' => aggregate_name.inspect,
|
|
420
|
-
'"TmplIdentityReading"' => identity_reading.inspect,
|
|
421
|
-
"tmpl_given_spec_placeholder()," => given_specs.join("\n"),
|
|
422
|
-
"tmpl_transition_placeholder()" => transition_arg,
|
|
423
|
-
"tmpl_mutation_lines_placeholder(record);" => mutation_lines.join("\n"),
|
|
424
|
-
"tmpl_ensures_spec_placeholder()," => ensures_specs.join("\n"),
|
|
425
|
-
"tmpl_invariants_placeholder()" => "#{invariants_fn_name(aggregate)}()",
|
|
426
|
-
"tmpl_emit_placeholder()" => emits_out.map(&:inspect).join(", "),
|
|
427
|
-
"args.to_json()," => payload
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
"#{emit_fielded_flat("#{cmd}Args", command[:attributes], value_objects_by_name)}\n\n#[derive(Debug, Clone)]\n#{args_struct.join("\n")}\n\n#{dispatch_fn}"
|
|
431
|
-
end
|
|
432
|
-
|
|
433
|
-
# `corrects "Event"`: a synthetic prepended GivenSpec reads a per-record flag set when a
|
|
434
|
-
# command emitting the event succeeds. `reverses: true` is refused; Ruby has not finished
|
|
435
|
-
# designing it (ADR 0041).
|
|
436
|
-
def corrects_of(command) = command[:mutations].find { |m| m[:op].to_s == "corrects" }
|
|
437
|
-
|
|
438
|
-
def corrects_reverses?(mutation) = !!(mutation[:source].is_a?(Hash) && mutation[:source][:value].is_a?(Hash) && mutation[:source][:value][:reverses])
|
|
439
|
-
|
|
440
|
-
# Synthetic snake_case flag field name, e.g. `emitted_fee_applied` for `FeeApplied`.
|
|
441
|
-
def corrects_flag_field(event_name) = "emitted_#{event_name.to_s.gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase}"
|
|
442
|
-
|
|
443
|
-
def corrects_given_specs(command)
|
|
444
|
-
corrects = corrects_of(command)
|
|
445
|
-
return [] unless corrects
|
|
446
|
-
|
|
447
|
-
event_name = corrects[:target].to_s
|
|
448
|
-
[" crate::kernel::GivenSpec { description: \"\", " \
|
|
449
|
-
"expr: crate::kernel::Expr::Lookup(#{corrects_flag_field(event_name).inspect}), " \
|
|
450
|
-
"corrects_event: Some(#{event_name.inspect}) },"]
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
# Event names any command or entity command on this aggregate corrects. The flag lives on
|
|
454
|
-
# the parent record whichever level declares `corrects`; entities have no event stream.
|
|
455
|
-
def correctable_event_names(aggregate)
|
|
456
|
-
names = corrects_targets_of(aggregate[:commands])
|
|
457
|
-
(aggregate[:entities] || []).each { |entity| names.concat(entity_correctable_event_names(entity)) }
|
|
458
|
-
names.uniq
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def entity_correctable_event_names(entity)
|
|
462
|
-
names = corrects_targets_of(entity[:commands])
|
|
463
|
-
(entity[:entities] || []).each { |nested| names.concat(entity_correctable_event_names(nested)) }
|
|
464
|
-
names
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
def corrects_targets_of(commands)
|
|
468
|
-
commands.flat_map { |c| c[:mutations] }
|
|
469
|
-
.select { |m| m[:op].to_s == "corrects" }
|
|
470
|
-
.map { |m| m[:target].to_s }
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# `extra_fields:` entries so the flag rides the record's ordinary JSON round-trip.
|
|
474
|
-
def corrects_extra_fields(aggregate)
|
|
475
|
-
correctable_event_names(aggregate).map do |ev|
|
|
476
|
-
field = corrects_flag_field(ev)
|
|
477
|
-
deserialize_rhs = "match v.require(#{field.inspect}, #{aggregate[:name].to_s.inspect})? { " \
|
|
478
|
-
"crate::kernel::Json::Bool(b) => *b, " \
|
|
479
|
-
"_ => return Err(#{json_type_error(aggregate[:name].to_s, field, 'a boolean')}) }"
|
|
480
|
-
[field, "crate::kernel::Json::Bool(self.#{field})", deserialize_rhs]
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# Stamps the flag onto the record when a command emitting a correctable event succeeds;
|
|
485
|
-
# Rust has no ambient event log to consult.
|
|
486
|
-
def corrects_flag_mutation_lines(command, aggregate)
|
|
487
|
-
correctable = correctable_event_names(aggregate)
|
|
488
|
-
Array(command[:emits]).map(&:to_s).uniq.select { |name| correctable.include?(name) }
|
|
489
|
-
.map { |name| " record.#{corrects_flag_field(name)} = true;" }
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
# `delegates_to "Entity.Command"`: the door's only mutation, run on the record inside the
|
|
493
|
-
# dispatch closure.
|
|
494
|
-
def delegate_of(command) = command[:mutations].find { |m| m[:op].to_s == "delegate" }
|
|
495
|
-
|
|
496
|
-
def delegate_mapping(delegation)
|
|
497
|
-
(delegation[:fields] || {}).to_h { |target_key, source_key| [target_key.to_s, source_key.to_s.delete_prefix(":")] }
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
def delegate_target(delegation, aggregate)
|
|
501
|
-
entity_name, _dot, command_name = delegation[:target].to_s.rpartition(".")
|
|
502
|
-
entity = (aggregate[:entities] || []).find { |e| e[:name].to_s == entity_name }
|
|
503
|
-
target = entity && entity[:commands].find { |c| c[:name].to_s == command_name }
|
|
504
|
-
[entity, target]
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
def delegate_skip_reason(command, aggregate, value_objects_by_name)
|
|
508
|
-
delegation = delegate_of(command)
|
|
509
|
-
return nil unless delegation
|
|
510
|
-
|
|
511
|
-
label = "delegates_to #{delegation[:target]}"
|
|
512
|
-
return "#{label} alongside other sets — not generated yet" if command[:mutations].size > 1
|
|
513
|
-
|
|
514
|
-
entity, target = delegate_target(delegation, aggregate)
|
|
515
|
-
return "#{label}: #{aggregate[:name]} has no such entity" unless entity
|
|
516
|
-
return "#{label}: #{entity[:name]} declares no such command" unless target
|
|
517
|
-
return "#{label}: #{entity[:name]} cannot be addressed by identity" unless extract_id_supported?(entity)
|
|
518
|
-
|
|
519
|
-
target_problem = entity_command_skip_reason(target, entity, value_objects_by_name)
|
|
520
|
-
return "#{label}: #{target_problem}" if target_problem
|
|
521
|
-
|
|
522
|
-
mapping = delegate_mapping(delegation)
|
|
523
|
-
target[:attributes].each do |attr|
|
|
524
|
-
source_name = mapping.fetch(attr[:name].to_s, attr[:name].to_s)
|
|
525
|
-
source = command[:attributes].find { |a| a[:name].to_s == source_name }
|
|
526
|
-
next if source.nil? && attr[:optional]
|
|
527
|
-
return "#{label}: target argument #{attr[:name]} has no source on the door" unless source
|
|
528
|
-
return "#{label}: door argument #{source_name} is a list, target wants a scalar (or vice versa) — not generated yet" if !!source[:list] != !!attr[:list]
|
|
529
|
-
# Compare with `bridgeable_value_types?`, not type-name equality: Ruby copies the raw wire
|
|
530
|
-
# value and lets the target's coercion decide (ADR 0045).
|
|
531
|
-
unless bridgeable_value_types?(source[:type].to_s, attr[:type].to_s, value_objects_by_name)
|
|
532
|
-
return "#{label}: door argument #{source_name} is #{source[:type]}, target wants #{attr[:type]} — not generated yet"
|
|
533
|
-
end
|
|
534
|
-
return "#{label}: optional door argument #{source_name} feeds required #{attr[:name]}" if source[:optional] && !attr[:optional]
|
|
535
|
-
end
|
|
536
|
-
entity[:identified_by].each do |path|
|
|
537
|
-
head = path.to_s.split(".").first
|
|
538
|
-
source_name = mapping.fetch(head, head)
|
|
539
|
-
next if command[:attributes].any? { |a| a[:name].to_s == source_name && !a[:optional] }
|
|
540
|
-
|
|
541
|
-
return "#{label}: the element's identity #{head} has no source on the door"
|
|
542
|
-
end
|
|
543
|
-
nil
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# The prelude (before `dispatch`), the apply block (the closure body), and the target's
|
|
547
|
-
# events, as `step_emit` answers for a delegation.
|
|
548
|
-
def delegation_of(command, aggregate, value_objects_by_name, domain_name)
|
|
549
|
-
delegation = delegate_of(command)
|
|
550
|
-
return nil unless delegation
|
|
551
|
-
|
|
552
|
-
entity, target = delegate_target(delegation, aggregate)
|
|
553
|
-
list_attr = aggregate[:attributes].find { |a| a[:list] && a[:type] == entity[:name] }
|
|
554
|
-
raise "#{entity[:name]}: no list attribute on #{aggregate[:name]} holds it" unless list_attr
|
|
555
|
-
|
|
556
|
-
element_record = rust_ident(entity[:name])
|
|
557
|
-
target_args_name = "#{element_record}#{rust_ident(target[:name])}EntityArgs"
|
|
558
|
-
aliases = delegate_mapping(delegation).map { |target_key, source_key| "(#{target_key.inspect}, #{source_key.inspect})" }
|
|
559
|
-
given_specs = target[:givens].map do |given|
|
|
560
|
-
" crate::kernel::GivenSpec { description: #{rust_string_literal(given[:description])}, expr: #{ExprEmitter.emit_ast(given[:ast])}, corrects_event: None },"
|
|
561
|
-
end
|
|
562
|
-
ensures_specs = target[:ensures].map do |rule|
|
|
563
|
-
" crate::kernel::EnsuresSpec { description: #{rust_string_literal(rule[:description])}, expr: #{ExprEmitter.emit_ast(rule[:ast])} },"
|
|
564
|
-
end
|
|
565
|
-
transition = lifecycle_transition_for(target, entity)
|
|
566
|
-
mutation_lines = target[:mutations].map { |m| emit_mutation_line(m, entity, target, value_objects_by_name, optional: false) }
|
|
567
|
-
mutation_lines.unshift(pre_state_line) if reads_pre_state?(target[:mutations])
|
|
568
|
-
mutation_lines << " record.#{rust_ident_field(transition[:field])} = #{transition[:to_state].inspect}.to_string();" if transition && transition[:to_state]
|
|
569
|
-
mutation_lines = [" let _ = record;"] if mutation_lines.empty?
|
|
570
|
-
|
|
571
|
-
{
|
|
572
|
-
prelude: Exemplar.render(
|
|
573
|
-
"delegate_prelude",
|
|
574
|
-
"tmpl_aliases_placeholder()" => aliases.join(", "),
|
|
575
|
-
"TmplTargetArgs" => target_args_name
|
|
576
|
-
).lines.map { |l| " #{l}" }.join.rstrip,
|
|
577
|
-
# Rendered separately: the element extraction must run after `dispatch`'s hydrate, as the
|
|
578
|
-
# first closure lines.
|
|
579
|
-
element: Exemplar.render(
|
|
580
|
-
"delegate_element",
|
|
581
|
-
"TmplElement" => element_record
|
|
582
|
-
).lines.map { |l| " #{l}" }.join.rstrip,
|
|
583
|
-
apply: Exemplar.render(
|
|
584
|
-
"delegate_apply",
|
|
585
|
-
"TmplRecord" => rust_ident(aggregate[:name]),
|
|
586
|
-
"tmpl_list_field" => rust_ident_field(list_attr[:name]),
|
|
587
|
-
"TmplElement" => element_record,
|
|
588
|
-
# Bare command name: it feeds refusal text, and Ruby's `hecks_name` is never
|
|
589
|
-
# entity-qualified.
|
|
590
|
-
'"TmplQualifiedCommandName"' => target[:name].to_s.inspect,
|
|
591
|
-
# `apply_entity_command` needs the qualified name to render `NothingToCorrect` should a
|
|
592
|
-
# corrects-flagged given reach this path.
|
|
593
|
-
'"TmplQualifiedName"' => "#{domain_name}::#{aggregate[:name]}".inspect,
|
|
594
|
-
'"TmplAggregateName"' => aggregate[:name].to_s.inspect,
|
|
595
|
-
'"TmplEntityName"' => entity[:name].to_s.inspect,
|
|
596
|
-
'"TmplEntityIdentityReading"' => entity[:identified_by].join(", ").inspect,
|
|
597
|
-
"tmpl_given_spec_placeholder()," => given_specs.join("\n"),
|
|
598
|
-
"tmpl_transition_placeholder()" => transition_check_arg(transition),
|
|
599
|
-
"tmpl_entity_mutation_lines_placeholder(record);" => mutation_lines.join("\n"),
|
|
600
|
-
"tmpl_ensures_spec_placeholder()," => ensures_specs.join("\n")
|
|
601
|
-
).lines.map { |l| " #{l}" }.join.rstrip,
|
|
602
|
-
emits: target[:emits]
|
|
603
|
-
}
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
# `command_skip_reason` with `entity` standing in for `aggregate`; both share one IR shape.
|
|
607
|
-
def entity_command_skip_reason(command, entity, value_objects_by_name)
|
|
608
|
-
command_skip_reason(command, entity, value_objects_by_name, creating_possible: false)
|
|
609
|
-
end
|
|
610
|
-
|
|
611
|
-
# An entity command: `kernel::dispatch_entity` ports `EntityInterpreter#call`, with no
|
|
612
|
-
# `Hydrate` branch; the entity is found by `identity()` within the parent.
|
|
613
|
-
def emit_entity_command(command, entity, parent_aggregate, domain_name, value_objects_by_name, aggregates_by_name,
|
|
614
|
-
process_managers: [])
|
|
615
|
-
parent_record = rust_ident(parent_aggregate[:name])
|
|
616
|
-
element_record = rust_ident(entity[:name])
|
|
617
|
-
cmd = rust_ident(command[:name])
|
|
618
|
-
|
|
619
|
-
# The parent lookup and the element lookup each need their own bare name and identity
|
|
620
|
-
# reading.
|
|
621
|
-
aggregate_name = parent_aggregate[:name].to_s
|
|
622
|
-
parent_identity_reading = parent_aggregate[:identified_by].join(", ")
|
|
623
|
-
entity_name = entity[:name].to_s
|
|
624
|
-
entity_identity_reading = entity[:identified_by].join(", ")
|
|
625
|
-
|
|
626
|
-
# The parent's list attribute holding this entity, resolved at generation time.
|
|
627
|
-
list_attr = parent_aggregate[:attributes].find { |a| a[:list] && a[:type] == entity[:name] }
|
|
628
|
-
raise "#{entity[:name]}: no list attribute on #{parent_aggregate[:name]} holds it — unsupported_attribute_types should have caught this" unless list_attr
|
|
629
|
-
|
|
630
|
-
list_field = rust_ident_field(list_attr[:name])
|
|
631
|
-
# `EntityArgs` suffix keeps a door named after the entity command it delegates to from
|
|
632
|
-
# colliding.
|
|
633
|
-
args_struct_name = "#{element_record}#{cmd}EntityArgs"
|
|
634
|
-
|
|
635
|
-
args_struct = ["pub struct #{args_struct_name} {"]
|
|
636
|
-
command[:attributes].each do |attr|
|
|
637
|
-
type = rust_type(attr[:type], list: attr[:list])
|
|
638
|
-
type = "Option<#{type}>" if attr[:optional]
|
|
639
|
-
args_struct << " #{Exemplar.render('struct_field', 'TmplFieldType' => type, 'tmpl_field' => rust_ident_field(attr[:name]))}"
|
|
640
|
-
end
|
|
641
|
-
args_struct << "}"
|
|
642
|
-
|
|
643
|
-
invariant_checks = invariant_checks_for(command, aggregates_by_name, value_objects_by_name)
|
|
644
|
-
|
|
645
|
-
# `corrects_given_specs` is prepended as in `emit_command`; `apply_entity_command`
|
|
646
|
-
# evaluates it against the parent record, never the element.
|
|
647
|
-
given_specs = corrects_given_specs(command) + command[:givens].map do |given|
|
|
648
|
-
" crate::kernel::GivenSpec { description: #{rust_string_literal(given[:description])}, expr: #{ExprEmitter.emit_ast(given[:ast])}, corrects_event: None },"
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
ensures_specs = command[:ensures].map do |rule|
|
|
652
|
-
" crate::kernel::EnsuresSpec { description: #{rust_string_literal(rule[:description])}, expr: #{ExprEmitter.emit_ast(rule[:ast])} },"
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
# The entity's own lifecycle, not the parent's.
|
|
656
|
-
transition = lifecycle_transition_for(command, entity)
|
|
657
|
-
transition_arg =
|
|
658
|
-
transition_check_arg(transition)
|
|
659
|
-
|
|
660
|
-
# A `:corrects` mutation reaches `emit_mutation_line` unfiltered and yields a blank line, a
|
|
661
|
-
# no-op; the check already ran via `corrects_given_specs`.
|
|
662
|
-
# Not handled: an entity command emitting a correctable event would need the flag set on the
|
|
663
|
-
# parent, but `record` in this closure is the element.
|
|
664
|
-
mutation_lines = command[:mutations].map { |m| emit_mutation_line(m, entity, command, value_objects_by_name, optional: false) }
|
|
665
|
-
mutation_lines.unshift(pre_state_line) if reads_pre_state?(command[:mutations])
|
|
666
|
-
mutation_lines << " record.#{rust_ident_field(transition[:field])} = #{transition[:to_state].inspect}.to_string();" if transition && transition[:to_state]
|
|
667
|
-
mutation_lines = [" let _ = record;"] if mutation_lines.empty?
|
|
668
|
-
|
|
669
|
-
# Bare name: it feeds refusal text, which Ruby never qualifies.
|
|
670
|
-
qualified_command_name = command[:name].to_s
|
|
671
|
-
|
|
672
|
-
entity_dispatch_fn = Exemplar.render(
|
|
673
|
-
"entity_dispatch_fn",
|
|
674
|
-
"dispatch_entity_tmpl" => "dispatch_entity_#{entity[:name].downcase}_#{dispatch_fn_name(cmd)}",
|
|
675
|
-
"TmplRecord" => parent_record,
|
|
676
|
-
"TmplArgs" => args_struct_name,
|
|
677
|
-
"tmpl_deref_params_placeholder: ()" => DEREF_PARAMS.join(", "),
|
|
678
|
-
"tmpl_invariant_check_placeholder()?;" => invariant_checks.join("\n"),
|
|
679
|
-
"let tmpl_eval_fielded = tmpl_with_references_placeholder();" => with_references_binding,
|
|
680
|
-
"&tmpl_eval_fielded," => "&with_references,",
|
|
681
|
-
"let tmpl_seed_projections = tmpl_seed_projections_placeholder();" => seed_projections_binding(parent_aggregate),
|
|
682
|
-
"tmpl_seed_projections," => "seed_projections,",
|
|
683
|
-
"tmpl_list_field" => list_field,
|
|
684
|
-
"TmplElement" => element_record,
|
|
685
|
-
'"TmplQualifiedCommandName"' => qualified_command_name.inspect,
|
|
686
|
-
'"TmplQualifiedName"' => "#{domain_name}::#{parent_aggregate[:name]}".inspect,
|
|
687
|
-
'"TmplAggregateName"' => aggregate_name.inspect,
|
|
688
|
-
'"TmplParentIdentityReading"' => parent_identity_reading.inspect,
|
|
689
|
-
'"TmplEntityName"' => entity_name.inspect,
|
|
690
|
-
'"TmplEntityIdentityReading"' => entity_identity_reading.inspect,
|
|
691
|
-
"tmpl_given_spec_placeholder()," => given_specs.join("\n"),
|
|
692
|
-
"tmpl_transition_placeholder()" => transition_arg,
|
|
693
|
-
"tmpl_entity_mutation_lines_placeholder(record);" => mutation_lines.join("\n"),
|
|
694
|
-
"tmpl_ensures_spec_placeholder()," => ensures_specs.join("\n"),
|
|
695
|
-
"tmpl_invariants_placeholder()" => "#{invariants_fn_name(parent_aggregate)}()",
|
|
696
|
-
"tmpl_emit_placeholder()" => command[:emits].map(&:inspect).join(", ")
|
|
697
|
-
)
|
|
698
|
-
|
|
699
|
-
[
|
|
700
|
-
emit_fielded_flat(args_struct_name, command[:attributes], value_objects_by_name),
|
|
701
|
-
"#[derive(Debug, Clone)]\n#{args_struct.join("\n")}",
|
|
702
|
-
emit_to_json_flat(args_struct_name, command[:attributes], value_objects_by_name, sparse: true),
|
|
703
|
-
# Allowlist matches the aggregate-command call site plus the entity's own identity head
|
|
704
|
-
# (`extra_identity_heads:`).
|
|
705
|
-
emit_from_json_flat(args_struct_name, command[:attributes], value_objects_by_name,
|
|
706
|
-
unknown_argument_allowlist: command_argument_allowlist(
|
|
707
|
-
parent_aggregate, command, process_managers,
|
|
708
|
-
extra_identity_heads: entity[:identified_by].map { |path| path.split(".").first }
|
|
709
|
-
),
|
|
710
|
-
command_name: command[:name].to_s, absent_argument_check: true,
|
|
711
|
-
interleave_checks: true, aggregates_by_name: aggregates_by_name),
|
|
712
|
-
# Argument gates run in `EntityStep::ORDER`; see `emit_argument_gates` (json_codec.rb).
|
|
713
|
-
emit_argument_gates(args_struct_name, command[:name].to_s, command[:attributes],
|
|
714
|
-
command_argument_allowlist(
|
|
715
|
-
parent_aggregate, command, process_managers,
|
|
716
|
-
extra_identity_heads: entity[:identified_by].map { |path| path.split(".").first }
|
|
717
|
-
)),
|
|
718
|
-
entity_dispatch_fn,
|
|
719
|
-
].join("\n\n")
|
|
720
|
-
end
|
|
721
|
-
|
|
722
|
-
# A command owned by an entity nested two levels deep. The second hop is another
|
|
723
|
-
# `apply_entity_command` inside the first hop's `apply_mutations` closure.
|
|
724
|
-
# The generated function takes plain `&str` ids per hop, so flat-args and routed callers
|
|
725
|
-
# share it. The outer hop has no given/ensures/transition; those belong to the inner call.
|
|
726
|
-
def emit_nested_entity_command(command, nested, entity, parent_aggregate, domain_name, value_objects_by_name, aggregates_by_name,
|
|
727
|
-
process_managers: [])
|
|
728
|
-
parent_record = rust_ident(parent_aggregate[:name])
|
|
729
|
-
entity_record = rust_ident(entity[:name])
|
|
730
|
-
nested_record = rust_ident(nested[:name])
|
|
731
|
-
cmd = rust_ident(command[:name])
|
|
732
|
-
|
|
733
|
-
aggregate_name = parent_aggregate[:name].to_s
|
|
734
|
-
parent_identity_reading = parent_aggregate[:identified_by].join(", ")
|
|
735
|
-
entity_name = entity[:name].to_s
|
|
736
|
-
entity_identity_reading = entity[:identified_by].join(", ")
|
|
737
|
-
nested_name = nested[:name].to_s
|
|
738
|
-
nested_identity_reading = nested[:identified_by].join(", ")
|
|
739
|
-
|
|
740
|
-
# The two list attributes the chain walks: the parent's holding `entity`, and
|
|
741
|
-
# `entity`'s holding `nested`.
|
|
742
|
-
list_attr1 = parent_aggregate[:attributes].find { |a| a[:list] && a[:type] == entity[:name] }
|
|
743
|
-
raise "#{entity[:name]}: no list attribute on #{parent_aggregate[:name]} holds it — unsupported_attribute_types should have caught this" unless list_attr1
|
|
744
|
-
|
|
745
|
-
list_attr2 = entity[:attributes].find { |a| a[:list] && a[:type] == nested[:name] }
|
|
746
|
-
raise "#{nested[:name]}: no list attribute on #{entity[:name]} holds it — unsupported_attribute_types should have caught this" unless list_attr2
|
|
747
|
-
|
|
748
|
-
list_field1 = rust_ident_field(list_attr1[:name])
|
|
749
|
-
list_field2 = rust_ident_field(list_attr2[:name])
|
|
750
|
-
|
|
751
|
-
# `NestedEntityArgs` suffix, distinct from `Args` and `EntityArgs`, for the same reason.
|
|
752
|
-
args_struct_name = "#{nested_record}#{cmd}NestedEntityArgs"
|
|
753
|
-
|
|
754
|
-
args_struct = ["pub struct #{args_struct_name} {"]
|
|
755
|
-
command[:attributes].each do |attr|
|
|
756
|
-
type = rust_type(attr[:type], list: attr[:list])
|
|
757
|
-
type = "Option<#{type}>" if attr[:optional]
|
|
758
|
-
args_struct << " #{Exemplar.render('struct_field', 'TmplFieldType' => type, 'tmpl_field' => rust_ident_field(attr[:name]))}"
|
|
759
|
-
end
|
|
760
|
-
args_struct << "}"
|
|
761
|
-
|
|
762
|
-
invariant_checks = invariant_checks_for(command, aggregates_by_name, value_objects_by_name)
|
|
763
|
-
|
|
764
|
-
given_specs = command[:givens].map do |given|
|
|
765
|
-
" crate::kernel::GivenSpec { description: #{rust_string_literal(given[:description])}, expr: #{ExprEmitter.emit_ast(given[:ast])}, corrects_event: None },"
|
|
766
|
-
end
|
|
767
|
-
|
|
768
|
-
ensures_specs = command[:ensures].map do |rule|
|
|
769
|
-
" crate::kernel::EnsuresSpec { description: #{rust_string_literal(rule[:description])}, expr: #{ExprEmitter.emit_ast(rule[:ast])} },"
|
|
770
|
-
end
|
|
771
|
-
|
|
772
|
-
# The nested entity's own lifecycle.
|
|
773
|
-
transition = lifecycle_transition_for(command, nested)
|
|
774
|
-
transition_arg = transition_check_arg(transition)
|
|
775
|
-
|
|
776
|
-
mutation_lines = command[:mutations].map { |m| emit_mutation_line(m, nested, command, value_objects_by_name, optional: false) }
|
|
777
|
-
mutation_lines.unshift(pre_state_line) if reads_pre_state?(command[:mutations])
|
|
778
|
-
mutation_lines << " record.#{rust_ident_field(transition[:field])} = #{transition[:to_state].inspect}.to_string();" if transition && transition[:to_state]
|
|
779
|
-
mutation_lines = [" let _ = record;"] if mutation_lines.empty?
|
|
780
|
-
|
|
781
|
-
# Bare name, as in `emit_entity_command`.
|
|
782
|
-
qualified_command_name = command[:name].to_s
|
|
783
|
-
fn_name = "dispatch_entity_#{entity[:name].downcase}_#{nested[:name].downcase}_#{dispatch_fn_name(cmd)}"
|
|
784
|
-
|
|
785
|
-
# The hop-2 call gets the root aggregate's qualified name only so it compiles; `corrects`
|
|
786
|
-
# admissibility is not generated at this depth, since the inner `record` is the hop-1 entity,
|
|
787
|
-
# not the root.
|
|
788
|
-
# The trailing `parent_in_args` literal is `true`; `rust/codegen/src/commands.rs` needs the
|
|
789
|
-
# same value (`spec/project_rust_pipeline_spec.rb` compares byte for byte).
|
|
790
|
-
nested_dispatch_fn = <<~RUST
|
|
791
|
-
pub fn #{fn_name}(
|
|
792
|
-
repo: &mut impl crate::kernel::Repository<#{parent_record}>, parent_id: &str, hop1_id: &str, hop1_wants: &str,
|
|
793
|
-
hop2_id: &str, hop2_wants: &str, args: #{args_struct_name}, mutations: &mut Vec<crate::kernel::MutationRecord>,
|
|
794
|
-
owner_deref: Vec<(&'static str, crate::kernel::DerefNode)>, command_deref: Vec<(&'static str, crate::kernel::DerefNode)>,
|
|
795
|
-
) -> crate::kernel::DispatchResult<#{parent_record}> {
|
|
796
|
-
#{invariant_checks.join("\n")}
|
|
797
|
-
#{with_references_binding}
|
|
798
|
-
#{seed_projections_binding(parent_aggregate)}
|
|
799
|
-
|
|
800
|
-
crate::kernel::dispatch_entity(
|
|
801
|
-
repo,
|
|
802
|
-
parent_id,
|
|
803
|
-
|r: &#{parent_record}| &r.#{list_field1},
|
|
804
|
-
|r: &mut #{parent_record}| &mut r.#{list_field1},
|
|
805
|
-
|el: &#{entity_record}| el.identity() == hop1_id,
|
|
806
|
-
#{qualified_command_name.inspect},
|
|
807
|
-
#{"#{domain_name}::#{parent_aggregate[:name]}".inspect},
|
|
808
|
-
#{aggregate_name.inspect},
|
|
809
|
-
#{parent_identity_reading.inspect},
|
|
810
|
-
#{entity_name.inspect},
|
|
811
|
-
#{entity_identity_reading.inspect},
|
|
812
|
-
hop1_wants,
|
|
813
|
-
&with_references,
|
|
814
|
-
&[],
|
|
815
|
-
None,
|
|
816
|
-
|nested_owner: &mut #{entity_record}| {
|
|
817
|
-
crate::kernel::apply_entity_command(
|
|
818
|
-
nested_owner,
|
|
819
|
-
hop1_id,
|
|
820
|
-
|r: &#{entity_record}| &r.#{list_field2},
|
|
821
|
-
|r: &mut #{entity_record}| &mut r.#{list_field2},
|
|
822
|
-
|el: &#{nested_record}| el.identity() == hop2_id,
|
|
823
|
-
#{qualified_command_name.inspect},
|
|
824
|
-
#{"#{domain_name}::#{aggregate_name}".inspect},
|
|
825
|
-
#{aggregate_name.inspect},
|
|
826
|
-
#{nested_name.inspect},
|
|
827
|
-
#{nested_identity_reading.inspect},
|
|
828
|
-
hop2_wants,
|
|
829
|
-
&with_references,
|
|
830
|
-
&[
|
|
831
|
-
#{given_specs.join("\n")}
|
|
832
|
-
],
|
|
833
|
-
#{transition_arg},
|
|
834
|
-
|record| {
|
|
835
|
-
#{mutation_lines.join("\n")}
|
|
836
|
-
Ok(())
|
|
837
|
-
},
|
|
838
|
-
&[
|
|
839
|
-
#{ensures_specs.join("\n")}
|
|
840
|
-
],
|
|
841
|
-
true,
|
|
842
|
-
)
|
|
843
|
-
},
|
|
844
|
-
&[],
|
|
845
|
-
&#{invariants_fn_name(parent_aggregate)}(),
|
|
846
|
-
&[#{command[:emits].map(&:inspect).join(', ')}],
|
|
847
|
-
args.to_json(),
|
|
848
|
-
mutations,
|
|
849
|
-
seed_projections,
|
|
850
|
-
)
|
|
851
|
-
}
|
|
852
|
-
RUST
|
|
853
|
-
|
|
854
|
-
[
|
|
855
|
-
emit_fielded_flat(args_struct_name, command[:attributes], value_objects_by_name),
|
|
856
|
-
"#[derive(Debug, Clone)]\n#{args_struct.join("\n")}",
|
|
857
|
-
emit_to_json_flat(args_struct_name, command[:attributes], value_objects_by_name, sparse: true),
|
|
858
|
-
# Both hops' identity heads are allowed, matching `ctx.chain.flat_map(&:identity_heads)`
|
|
859
|
-
# (entity_interpreter.rb).
|
|
860
|
-
emit_from_json_flat(args_struct_name, command[:attributes], value_objects_by_name,
|
|
861
|
-
unknown_argument_allowlist: command_argument_allowlist(
|
|
862
|
-
parent_aggregate, command, process_managers,
|
|
863
|
-
extra_identity_heads: (entity[:identified_by] + nested[:identified_by]).map { |path| path.split(".").first }
|
|
864
|
-
),
|
|
865
|
-
command_name: command[:name].to_s, absent_argument_check: true,
|
|
866
|
-
interleave_checks: true, aggregates_by_name: aggregates_by_name),
|
|
867
|
-
# Argument gates, as in the one-hop entity command above.
|
|
868
|
-
emit_argument_gates(args_struct_name, command[:name].to_s, command[:attributes],
|
|
869
|
-
command_argument_allowlist(
|
|
870
|
-
parent_aggregate, command, process_managers,
|
|
871
|
-
extra_identity_heads: (entity[:identified_by] + nested[:identified_by]).map { |path| path.split(".").first }
|
|
872
|
-
)),
|
|
873
|
-
nested_dispatch_fn,
|
|
874
|
-
].join("\n\n")
|
|
875
|
-
end
|
|
876
|
-
end
|
|
877
|
-
end
|