hecks 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acccf4e21574d64b971cfbfe6a34f08f0bc86fdb8c74a8c58a472042138f01f1
4
- data.tar.gz: 582d4e043a4b0145c25f6dfa8e223c47da7f55e60205fbef6485f6b406a2ffe2
3
+ metadata.gz: 4a6fed3e5b894585b2fa7253696873a4218980f6982b4afeeda5effdd5a4aba4
4
+ data.tar.gz: b29a6ce60d5ae9142f0b53ee062979397f3c1384ce2f6da8c70b14396425c989
5
5
  SHA512:
6
- metadata.gz: 01f4db2b60db4d05aa7682fb7abdab6206abae36f45031656b9461fa064ec61236301b731f0b64c58cdee4d26624fe8a9d61042c379ae34f3274d036b31644ad
7
- data.tar.gz: 9d1c2b9990aa214173c76634eda0dbe2f32651e80fe27e54c204d1cf0877a25e00334fc2784700754cf502196548b8e3a59549974f68789e5e31dd8defb5c0be
6
+ metadata.gz: 77b6974a39536f06fd5bffeb121899ca35b1563e5db172bac48dfeef426c445ed459bea60bc004ea29fdb14b14149a72467b61dddce02aa1cc61b2bb9c11b944
7
+ data.tar.gz: 56fd3680ca94d0060d0ff5e1324c3ccbefb03f5dfc4ac03760cdfb8ee6ad2081c70cef85e4161a66098acb396bdb17c2a485185ff473bee49546a9f0a7336d65
data/exe/hecks CHANGED
@@ -63,7 +63,7 @@ if reason
63
63
  if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
64
64
  why = begin
65
65
  require "json"
66
- JSON.parse(text).dig("state", "refusal", "value")
66
+ JSON.parse(text).dig("state", "refusal")
67
67
  rescue JSON::ParserError
68
68
  nil
69
69
  end
@@ -83,20 +83,19 @@ module Hecks
83
83
  # with Symbol keys; a leaf is returned as it came
84
84
  def deep_symbolize(value) = Runtime::Value.deep_symbolize(value)
85
85
 
86
- # Unwraps a record, a query row, or any value holding `Runtime::Value`s into plain
87
- # Hashes, Arrays and scalars a JSON encoder can walk.
88
- #
89
- # Delegates to `Runtime::Value.materialize`; the only addition is `#to_h` on a
90
- # `Handle`, which `materialize` does not recognize.
86
+ # Unwraps a record, a query row, or any value holding `Runtime::Value`s into the plain
87
+ # data a caller is answered with. A single-field value object answers as its bare
88
+ # field (`name: "Margherita"`), the shape a command takes it in; stored state stays
89
+ # wrapped.
91
90
  #
92
91
  # @param value [Adapters::Driving::Handle, Runtime::Value, Hash, Array, Object] what to
93
- # unwrap;
94
- # a `Handle` is read through its `to_h`, so its `:id` comes along
92
+ # unwrap; a `Handle` is read through its `to_h`, so its `:id` comes along
95
93
  # @return [Hash, Array, Object] the same data with every `Runtime::Value` replaced
96
- # by a Hash of its fields; any other value is returned as it came
94
+ # by its sole field's value, or by a Hash of its fields when it has more than one;
95
+ # any other value is returned as it came
97
96
  def materialize(value)
98
97
  value = value.to_h if value.is_a?(Handle)
99
- Runtime::Value.materialize(value)
98
+ Runtime::Value.materialize_unwrapped(value)
100
99
  end
101
100
 
102
101
  # Turns a command's JSON body, raw or already parsed, into the `to:`/`with:`
@@ -36,7 +36,7 @@ module Hecks
36
36
  if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
37
37
  why = begin
38
38
  require "json"
39
- JSON.parse(text).dig("state", "refusal", "value")
39
+ JSON.parse(text).dig("state", "refusal")
40
40
  rescue JSON::ParserError
41
41
  nil
42
42
  end
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require_relative "../page"
3
+ require_relative "../../runtime/value"
3
4
 
4
5
  module Hecks
5
6
  module Forms
@@ -13,8 +14,9 @@ module Hecks
13
14
  respond(status, "text/html; charset=utf-8", Page.render(title: title, body: body, breadcrumbs: breadcrumbs))
14
15
  end
15
16
 
17
+ # A single-field value object answers as its bare field, as `Json.materialize` does.
16
18
  def json(status, payload)
17
- respond(status, "application/json", JSON.pretty_generate(payload))
19
+ respond(status, "application/json", JSON.pretty_generate(Runtime::Value.materialize_unwrapped(payload)))
18
20
  end
19
21
 
20
22
  def redirect(location)
@@ -57,7 +57,7 @@ module Hecks
57
57
  provider = membership_provider
58
58
  verbs, gate, people = check_unadministered(provider)
59
59
 
60
- admitted = people.none? { |person| person.dig(:email, :value) == email }
60
+ admitted = people.none? { |person| person[:email] == email }
61
61
  admit(provider, verbs.fetch(:admit), email, name || email[/\A[^@]+/]) if admitted
62
62
  grant(provider, verbs.fetch(:grant), email, role || gate)
63
63
  Result.new(email: email, role: role || gate, admitted: admitted)
@@ -103,10 +103,10 @@ module Hecks
103
103
  end
104
104
 
105
105
  def refuse_if_administered!(people, roles)
106
- held = people.select { |person| roles.map(&:downcase).include?(person.dig(:role, :value).to_s.downcase) }
106
+ held = people.select { |person| roles.map(&:downcase).include?(person[:role].to_s.downcase) }
107
107
  return if held.empty?
108
108
 
109
- who = held.map { |person| person.dig(:email, :value) }.join(", ")
109
+ who = held.map { |person| person[:email] }.join(", ")
110
110
  raise Runtime::NotFound, "an administrator already exists (#{who}); grant roles through the " \
111
111
  "domain's own #{roles.first} access instead"
112
112
  end
@@ -23,9 +23,14 @@ export function hostUrl(options: HostOptions = {}): string {
23
23
  return (options.url ?? (process.env[EDITOR.hostEnv] || EDITOR.hostDefault)).replace(/\/+$/, "");
24
24
  }
25
25
 
26
- /** A client for the domain this editor edits. */
26
+ /**
27
+ * A client for the domain this editor edits. It reads and writes the stored shape (`bare: false`),
28
+ * since its forms walk every value object by the schema's own fields.
29
+ */
27
30
  export function hostClient(options: HostOptions = {}): HostClient {
28
- return new HostClient({ domain: options.domain ?? SCHEMA.domain, url: hostUrl(options), fetch: options.fetch, timeoutMs: EDITOR.timeoutMs });
31
+ return new HostClient({
32
+ domain: options.domain ?? SCHEMA.domain, url: hostUrl(options), fetch: options.fetch, timeoutMs: EDITOR.timeoutMs, bare: false,
33
+ });
29
34
  }
30
35
 
31
36
  /**
@@ -0,0 +1,60 @@
1
+ module Hecks
2
+ module Runtime
3
+ class Value
4
+ # The shape a caller is answered with: a single-attribute value object as its bare field,
5
+ # following the declared types down through nested value objects and lists. Extended into
6
+ # `Value` beside `Coercion`; `materialize` keeps the wrapped shape stored state depends on.
7
+ module Unwrapping
8
+ # `materialize`, but a single-attribute value object unwraps to its bare field. What
9
+ # `group_by` rows and `Driving::Json.materialize` answer with.
10
+ #
11
+ # @param value [Object] the value to materialize; anything that is not a `Runtime::Value`,
12
+ # Array or Hash passes through unchanged
13
+ # @return [Object] `value` with every nested `Runtime::Value` replaced by its own sole
14
+ # field's value (recursively unwrapped), or by its own `Hash` of fields when it has
15
+ # more than one
16
+ def materialize_unwrapped(value)
17
+ case value
18
+ when self then unwrap_typed(value.value_object, value.raw_fields)
19
+ when Array then value.map { |item| materialize_unwrapped(item) }
20
+ when Hash then value.transform_values { |item| materialize_unwrapped(item) }
21
+ else value
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # A value object's fields by its declared attributes. A nested value object is often held
28
+ # as a plain Hash, so its own type is looked up rather than read off the field.
29
+ def unwrap_typed(value_object, fields)
30
+ sole = value_object.sole_attribute
31
+ return unwrap_field(value_object, sole, fields[sole.name]) if sole
32
+
33
+ value_object.attributes.to_h { |attr| [attr.name, unwrap_field(value_object, attr, fields[attr.name])] }
34
+ end
35
+
36
+ def unwrap_field(value_object, attribute, held)
37
+ case held
38
+ when Array then held.map { |item| unwrap_field(value_object, attribute, item) }
39
+ when Hash then unwrap_held_hash(value_object, attribute, held)
40
+ else materialize_unwrapped(held)
41
+ end
42
+ end
43
+
44
+ def unwrap_held_hash(value_object, attribute, held)
45
+ nested = nested_value_object(value_object, attribute.type)
46
+ return materialize_unwrapped(held) unless nested
47
+
48
+ unwrap_typed(nested, held.transform_keys(&:to_sym))
49
+ end
50
+
51
+ # The value object a field's type names, resolved against the aggregate that declares
52
+ # `value_object`; nil for a primitive, or when the owner is not known.
53
+ def nested_value_object(value_object, type)
54
+ owner = value_object.respond_to?(:hecks_owner) ? value_object.hecks_owner : nil
55
+ owner.respond_to?(:value_object) ? value_object_for(owner, type) : nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -9,12 +9,13 @@ require_relative "value/flags"
9
9
  require_relative "value/identifiers"
10
10
  require_relative "value/entity_list_coercion"
11
11
  require_relative "value/admission"
12
+ require_relative "value/unwrapping"
12
13
 
13
14
  module Hecks
14
15
  module Runtime
15
16
  # A typed value object: frozen fields, read by name.
16
17
  # Construction lives in value/coercion.rb, field_checks.rb, entity_list_coercion.rb and
17
- # admission.rb.
18
+ # admission.rb; the shape a caller is answered with in unwrapping.rb.
18
19
  class Value
19
20
  extend FieldChecks
20
21
  extend Coercion
@@ -24,6 +25,7 @@ module Hecks
24
25
  extend Identifiers
25
26
  extend EntityListCoercion
26
27
  extend Admission
28
+ extend Unwrapping
27
29
 
28
30
  attr_reader :value_object
29
31
 
@@ -115,32 +117,6 @@ module Hecks
115
117
  end
116
118
  end
117
119
 
118
- # `materialize`, but a single-attribute value object unwraps to its bare field.
119
- # Opt-in for `group_by` rows; `materialize` keeps the wrapped shape callers depend on.
120
- #
121
- # @param value [Object] the value to materialize; anything that is not a `Runtime::Value`,
122
- # Array or Hash passes through unchanged
123
- # @return [Object] `value` with every nested `Runtime::Value` replaced by its own sole
124
- # field's value (recursively unwrapped), or by its own `Hash` of fields when it has
125
- # more than one
126
- def self.materialize_unwrapped(value)
127
- case value
128
- when self then unwrap_value_object(value)
129
- when Array then value.map { |item| materialize_unwrapped(item) }
130
- when Hash then value.transform_values { |item| materialize_unwrapped(item) }
131
- else value
132
- end
133
- end
134
-
135
- def self.unwrap_value_object(value)
136
- sole = value.value_object.sole_attribute
137
- return materialize_unwrapped(value[sole.name]) if sole
138
-
139
- # Not `value.to_h`: it materializes nested values to Hashes, ending the recursion.
140
- value.value_object.attributes.to_h { |attr| [attr.name, materialize_unwrapped(value[attr.name])] }
141
- end
142
- private_class_method :unwrap_value_object
143
-
144
120
  # Reduces an append-only `list_of` sub-log to its current state: the latest row per `key`.
145
121
  # Grouping only; what counts as removed, and the ordering, belong to the caller.
146
122
  #
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "4.0.0".freeze # rust/host/HECKS_RELEASE names the same release
4
+ VERSION = "5.0.0".freeze # rust/host/HECKS_RELEASE names the same release
5
5
  end
@@ -1116,6 +1116,12 @@ pub fn generate(
1116
1116
  }
1117
1117
  }
1118
1118
 
1119
+ puts_blank(&mut out);
1120
+ puts_str(
1121
+ &mut out,
1122
+ &crate::presenters::emit_aggregate_presenter(aggregate, &value_objects_by_name),
1123
+ );
1124
+
1119
1125
  aggregate_files.push(GeneratedFile {
1120
1126
  name: format!("{}.rs", crate::naming::aggregate_module(agg_name)),
1121
1127
  content: out,
@@ -26,6 +26,7 @@ mod naming;
26
26
  mod optional_pass;
27
27
  mod ports;
28
28
  mod prelude;
29
+ mod presenters;
29
30
  mod queries;
30
31
  mod reactions;
31
32
  mod read_models;
@@ -377,6 +378,11 @@ fn run_full(args: &[String]) -> Result<(), String> {
377
378
  &mut merged_rs,
378
379
  &read_models::emit_read_model_table(&ex, &merged_read_models),
379
380
  );
381
+ puts_blank(&mut merged_rs);
382
+ puts_str(
383
+ &mut merged_rs,
384
+ &presenters::emit_present_router(&merged_aggregates),
385
+ );
380
386
 
381
387
  let merged_path = format!("{target_out_dir}/merged.rs");
382
388
  std::fs::write(&merged_path, &merged_rs).map_err(|e| format!("writing {merged_path}: {e}"))?;
@@ -0,0 +1,335 @@
1
+ //! The bare answer shape, generated: a port of Ruby's `Value.materialize_unwrapped`, emitted as
2
+ //! type-directed Rust over `crate::kernel::Json`.
3
+ //!
4
+ //! A single-attribute value object answers as its sole field's value, a multi-field one as an
5
+ //! object of its fields, each unwrapped the same way; a list maps item-wise and an entity's
6
+ //! fields are unwrapped. Stored state stays wrapped: this is only what a caller is answered with
7
+ //! (`group_by` rows, and the kernel's `{"present": answer}` input).
8
+
9
+ use crate::json::Json;
10
+ use crate::registry::AggregateEntry;
11
+ use std::collections::HashMap;
12
+
13
+ /// Emits an expression unwrapping the `Json` named `expr` as a `type_name` (a `list` of them
14
+ /// when `list`), resolving value objects in `value_objects_by_name` and entities in `aggregate`.
15
+ pub fn unwrap_json_expr(
16
+ expr: &str,
17
+ type_name: &str,
18
+ list: bool,
19
+ aggregate: &Json,
20
+ value_objects_by_name: &HashMap<String, &Json>,
21
+ ) -> String {
22
+ Unwrapper {
23
+ aggregate,
24
+ value_objects_by_name,
25
+ }
26
+ .expr(expr, type_name, list, &mut Vec::new())
27
+ }
28
+
29
+ /// Emits `pub fn present(state) -> Json` for one aggregate's module: its state (or one of its
30
+ /// events' payloads) with every field it declares unwrapped, other keys passed through.
31
+ ///
32
+ /// The fields are the aggregate's attributes and projected fields, then every aggregate and
33
+ /// entity command argument not already named, since an event payload carries a command's
34
+ /// arguments. A name declared with more than one type unwraps by whichever type's field names
35
+ /// the value's own keys fit, first declared first.
36
+ pub fn emit_aggregate_presenter(
37
+ aggregate: &Json,
38
+ value_objects_by_name: &HashMap<String, &Json>,
39
+ ) -> String {
40
+ let unwrapper = Unwrapper {
41
+ aggregate,
42
+ value_objects_by_name,
43
+ };
44
+ let arms: Vec<String> = presented_fields(aggregate)
45
+ .iter()
46
+ .map(|(name, candidates)| format!("{name:?} => {},", unwrapper.candidates("v", candidates)))
47
+ .collect();
48
+ if arms.is_empty() {
49
+ return format!("{PRESENT_DOC}pub fn present(state: crate::kernel::Json) -> crate::kernel::Json {{\n state\n}}\n");
50
+ }
51
+ let arms = arms.join(" ");
52
+ format!(
53
+ "{PRESENT_DOC}pub fn present(state: crate::kernel::Json) -> crate::kernel::Json {{\n match state {{ crate::kernel::Json::Object(fields) => crate::kernel::Json::Object(fields.into_iter().map(|(k, v)| {{ let new_v = match k.as_str() {{ {arms} _ => v }}; (k, new_v) }}).collect()), other => other }}\n}}\n"
54
+ )
55
+ }
56
+
57
+ const PRESENT_DOC: &str = "/// This aggregate's state, or one of its events' payloads, as a caller is answered with\n/// it: each single-field value object as its bare field (the shape a command takes it in).\n";
58
+
59
+ /// Emits the merged target's `present(aggregate, state)`: one arm per generated aggregate across
60
+ /// every chapter, keyed by its qualified `Domain::Aggregate` name; any other name passes through.
61
+ pub fn emit_present_router(aggregates: &[AggregateEntry]) -> String {
62
+ let arms: Vec<String> = aggregates
63
+ .iter()
64
+ .map(|a| {
65
+ format!(
66
+ " {} => crate::generated::{}::{}::present(state),",
67
+ crate::naming::ruby_inspect_string(&format!("{}::{}", a.domain_name, a.name)),
68
+ a.chapter_mod,
69
+ a.module_name
70
+ )
71
+ })
72
+ .collect();
73
+ format!(
74
+ "/// One `\"Domain::Aggregate\"` state as a caller is answered with it (the kernel's\n/// `{{\"present\": answer}}` input); stored state stays wrapped. An unknown name passes through.\npub fn present(aggregate: &str, state: crate::kernel::Json) -> crate::kernel::Json {{\n match aggregate {{\n{}\n _ => state,\n }}\n}}\n",
75
+ arms.join("\n")
76
+ )
77
+ }
78
+
79
+ // Each presented name with its distinct declared (type, list) pairs, in declaration order.
80
+ fn presented_fields(aggregate: &Json) -> Vec<(String, Vec<(String, bool)>)> {
81
+ let mut attributes: Vec<Json> = aggregate
82
+ .get("attributes")
83
+ .map_or_default(Json::each)
84
+ .to_vec();
85
+ attributes.extend(crate::types::projected_field_pseudo_attributes(aggregate));
86
+ let commands = aggregate
87
+ .get("commands")
88
+ .map_or_default(Json::each)
89
+ .iter()
90
+ .chain(
91
+ aggregate
92
+ .get("entities")
93
+ .map_or_default(Json::each)
94
+ .iter()
95
+ .flat_map(|e| e.get("commands").map_or_default(Json::each)),
96
+ );
97
+ for command in commands {
98
+ attributes.extend(
99
+ command
100
+ .get("attributes")
101
+ .map_or_default(Json::each)
102
+ .iter()
103
+ .cloned(),
104
+ );
105
+ }
106
+
107
+ let mut fields: Vec<(String, Vec<(String, bool)>)> = Vec::new();
108
+ for attribute in &attributes {
109
+ let name = crate::attr::name(attribute).to_string();
110
+ let candidate = (
111
+ crate::attr::type_name(attribute).to_string(),
112
+ crate::attr::list(attribute),
113
+ );
114
+ match fields.iter_mut().find(|(n, _)| *n == name) {
115
+ Some((_, candidates)) if !candidates.contains(&candidate) => candidates.push(candidate),
116
+ Some(_) => {}
117
+ None => fields.push((name, vec![candidate])),
118
+ }
119
+ }
120
+ fields
121
+ }
122
+
123
+ struct Unwrapper<'a> {
124
+ aggregate: &'a Json,
125
+ value_objects_by_name: &'a HashMap<String, &'a Json>,
126
+ }
127
+
128
+ impl Unwrapper<'_> {
129
+ // The declared fields of a value object or entity type, and whether it is a value object.
130
+ fn fields_of(&self, type_name: &str) -> Option<(&[Json], bool)> {
131
+ if let Some(vo) = self.value_objects_by_name.get(type_name) {
132
+ return vo.get("attributes").map(|a| (Json::each(a), true));
133
+ }
134
+ self.aggregate
135
+ .get("entities")
136
+ .map_or_default(Json::each)
137
+ .iter()
138
+ .find(|e| e.get("name").map(Json::to_s).unwrap_or_default() == type_name)
139
+ .and_then(|e| e.get("attributes").map(|a| (Json::each(a), false)))
140
+ }
141
+
142
+ // `seen` holds the types being expanded, so a type that contains itself stops there and
143
+ // passes its nested value through rather than recursing without end.
144
+ fn expr(&self, expr: &str, type_name: &str, list: bool, seen: &mut Vec<String>) -> String {
145
+ if list {
146
+ let inner = self.expr("item", type_name, false, seen);
147
+ return format!("match {expr} {{ crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| {inner}).collect()), other => other }}");
148
+ }
149
+ let Some((fields_meta, is_value_object)) = self.fields_of(type_name) else {
150
+ return expr.to_string();
151
+ };
152
+ if seen.iter().any(|s| s == type_name) {
153
+ return expr.to_string();
154
+ }
155
+ seen.push(type_name.to_string());
156
+ let out = if is_value_object && fields_meta.len() == 1 {
157
+ let field = &fields_meta[0];
158
+ let unwrapped = self.expr(
159
+ "field_value",
160
+ crate::attr::type_name(field),
161
+ crate::attr::list(field),
162
+ seen,
163
+ );
164
+ let field_name = crate::attr::name(field);
165
+ format!("match {expr} {{ crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == {field_name:?}).map_or(crate::kernel::Json::Null, |(_, field_value)| {unwrapped}), other => other }}")
166
+ } else {
167
+ let inner_arms: Vec<String> = fields_meta
168
+ .iter()
169
+ .map(|f| {
170
+ let unwrapped =
171
+ self.expr("v", crate::attr::type_name(f), crate::attr::list(f), seen);
172
+ format!("{:?} => {unwrapped},", crate::attr::name(f))
173
+ })
174
+ .collect();
175
+ let inner_arms = inner_arms.join(" ");
176
+ format!("match {expr} {{ crate::kernel::Json::Object(fields) => crate::kernel::Json::Object(fields.into_iter().map(|(k, v)| {{ let new_v = match k.as_str() {{ {inner_arms} _ => v }}; (k, new_v) }}).collect()), other => other }}")
177
+ };
178
+ seen.pop();
179
+ out
180
+ }
181
+
182
+ // One declared type unwraps unconditionally; several are told apart by the value's keys.
183
+ fn candidates(&self, expr: &str, candidates: &[(String, bool)]) -> String {
184
+ if let [(type_name, list)] = candidates {
185
+ return self.expr(expr, type_name, *list, &mut Vec::new());
186
+ }
187
+ let branches: Vec<String> = candidates
188
+ .iter()
189
+ .filter_map(|(type_name, list)| {
190
+ let guard = self.fits(&format!("&{expr}"), type_name, *list)?;
191
+ let unwrapped = self.expr(expr, type_name, *list, &mut Vec::new());
192
+ Some(format!("if {guard} {{ {unwrapped} }}"))
193
+ })
194
+ .collect();
195
+ if branches.is_empty() {
196
+ return expr.to_string();
197
+ }
198
+ format!("{} else {{ {expr} }}", branches.join(" else "))
199
+ }
200
+
201
+ // Whether the value at `expr` (a `&Json`) has the shape of `type_name`: an object whose keys
202
+ // are all its declared fields. `None` for a type with no fields to unwrap.
203
+ fn fits(&self, expr: &str, type_name: &str, list: bool) -> Option<String> {
204
+ if list {
205
+ let item = self.fits("item", type_name, false)?;
206
+ return Some(format!("matches!({expr}, crate::kernel::Json::Array(items) if items.iter().all(|item| {item}))"));
207
+ }
208
+ let (fields_meta, _) = self.fields_of(type_name)?;
209
+ let names: Vec<String> = fields_meta
210
+ .iter()
211
+ .map(|f| format!("{:?}", crate::attr::name(f)))
212
+ .collect();
213
+ Some(format!("matches!({expr}, crate::kernel::Json::Object(fields) if fields.iter().all(|(k, _)| matches!(k.as_str(), {})))", names.join(" | ")))
214
+ }
215
+ }
216
+
217
+ #[cfg(test)]
218
+ mod tests {
219
+ use super::*;
220
+
221
+ fn attribute(name: &str, type_name: &str, list: bool) -> String {
222
+ format!(r#"{{"name": "{name}", "type": "{type_name}", "list": {list}}}"#)
223
+ }
224
+
225
+ fn value_object(name: &str, attributes: &[String]) -> String {
226
+ format!(
227
+ r#"{{"name": "{name}", "attributes": [{}]}}"#,
228
+ attributes.join(", ")
229
+ )
230
+ }
231
+
232
+ // Pizzas' `Order`, cut down: a single-field name, a multi-field pizza holding two
233
+ // single-field value objects, a list of single-field tags, and two commands that both
234
+ // take an `amount` of a different single-field type.
235
+ fn order() -> Json {
236
+ let text = format!(
237
+ r#"{{"name": "Order",
238
+ "attributes": [{}, {}, {}],
239
+ "value_objects": [{}, {}, {}, {}, {}, {}],
240
+ "commands": [
241
+ {{"name": "CreatePizza", "attributes": [{}, {}]}},
242
+ {{"name": "AddTopping", "attributes": [{}]}},
243
+ {{"name": "Purchase", "attributes": [{}]}}
244
+ ]}}"#,
245
+ attribute("name", "PizzaName", false),
246
+ attribute("pizza", "Pizza", false),
247
+ attribute("tags", "Tag", true),
248
+ value_object("PizzaName", &[attribute("value", "String", false)]),
249
+ value_object("Price", &[attribute("cents", "Integer", false)]),
250
+ value_object("Size", &[attribute("value", "String", false)]),
251
+ value_object("Tag", &[attribute("value", "String", false)]),
252
+ value_object("ToppingAmount", &[attribute("value", "Integer", false)]),
253
+ value_object(
254
+ "Pizza",
255
+ &[
256
+ attribute("price_cents", "Price", false),
257
+ attribute("size", "Size", false)
258
+ ]
259
+ ),
260
+ attribute("name", "PizzaName", false),
261
+ attribute("pizza", "Pizza", false),
262
+ attribute("amount", "ToppingAmount", false),
263
+ attribute("amount", "Price", false),
264
+ );
265
+ Json::parse(&text).unwrap()
266
+ }
267
+
268
+ fn presenter() -> String {
269
+ let order = order();
270
+ let vos: Vec<&Json> = order
271
+ .get("value_objects")
272
+ .map_or_default(Json::each)
273
+ .iter()
274
+ .collect();
275
+ let by_name: HashMap<String, &Json> = vos
276
+ .iter()
277
+ .map(|vo| (vo.get("name").map(Json::to_s).unwrap_or_default(), *vo))
278
+ .collect();
279
+ emit_aggregate_presenter(&order, &by_name)
280
+ }
281
+
282
+ #[test]
283
+ fn a_single_field_attribute_presents_as_its_sole_field() {
284
+ assert!(presenter().contains(r#""name" => match v { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "value")"#));
285
+ }
286
+
287
+ #[test]
288
+ fn a_multi_field_value_object_keeps_its_object_and_unwraps_each_field() {
289
+ let text = presenter();
290
+ assert!(text.contains(r#""pizza" => match v { crate::kernel::Json::Object(fields) => crate::kernel::Json::Object("#));
291
+ assert!(text.contains(r#""price_cents" => match v { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "cents")"#));
292
+ assert!(text.contains(r#""size" => match v { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "value")"#));
293
+ }
294
+
295
+ #[test]
296
+ fn a_list_of_single_field_value_objects_maps_item_wise() {
297
+ assert!(presenter().contains(r#""tags" => match v { crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| match item { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "value")"#));
298
+ }
299
+
300
+ #[test]
301
+ fn a_name_declared_with_two_types_is_told_apart_by_its_keys() {
302
+ let text = presenter();
303
+ let arm = text.split(r#""amount" => "#).nth(1).unwrap();
304
+ assert!(arm.starts_with(r#"if matches!(&v, crate::kernel::Json::Object(fields) if fields.iter().all(|(k, _)| matches!(k.as_str(), "value")))"#));
305
+ assert!(arm.contains(r#"else if matches!(&v, crate::kernel::Json::Object(fields) if fields.iter().all(|(k, _)| matches!(k.as_str(), "cents")))"#));
306
+ }
307
+
308
+ #[test]
309
+ fn a_command_argument_already_named_by_an_attribute_adds_no_second_arm() {
310
+ assert_eq!(presenter().matches(r#""name" => "#).count(), 1);
311
+ }
312
+
313
+ #[test]
314
+ fn the_router_names_each_aggregate_qualified_and_passes_any_other_through() {
315
+ let entry = AggregateEntry {
316
+ name: "Order".to_string(),
317
+ module_name: "order".to_string(),
318
+ record: "Order".to_string(),
319
+ commands: vec![],
320
+ entity_commands: vec![],
321
+ nested_entity_commands: vec![],
322
+ ports: vec![],
323
+ chapter_mod: "pizzas".to_string(),
324
+ domain_name: "Pizzas".to_string(),
325
+ reference_specs: vec![],
326
+ identified_by: vec![],
327
+ entities: vec![],
328
+ };
329
+ let text = emit_present_router(&[entry]);
330
+ assert!(
331
+ text.contains(r#""Pizzas::Order" => crate::generated::pizzas::order::present(state),"#)
332
+ );
333
+ assert!(text.contains("_ => state,"));
334
+ }
335
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  use crate::exemplar::Exemplar;
5
5
  use crate::json::Json;
6
+ use crate::presenters::unwrap_json_expr;
6
7
  use crate::queries;
7
8
  use crate::shared::ruby_to_i;
8
9
  use crate::skip_reason::{reskip, skip, SkipReason};
@@ -1030,63 +1031,6 @@ fn group_by_leaf_check(
1030
1031
  )
1031
1032
  }
1032
1033
 
1033
- // Port of `Value.materialize_unwrapped` (Ruby).
1034
- fn unwrap_json_expr(
1035
- expr: &str,
1036
- type_name: &str,
1037
- list: bool,
1038
- aggregate: &Json,
1039
- value_objects_by_name: &HashMap<String, &Json>,
1040
- ) -> String {
1041
- if list {
1042
- let inner = unwrap_json_expr("item", type_name, false, aggregate, value_objects_by_name);
1043
- return format!("match {expr} {{ crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| {inner}).collect()), other => other }}");
1044
- }
1045
-
1046
- let vo = value_objects_by_name.get(type_name).copied();
1047
- let entity = aggregate
1048
- .get("entities")
1049
- .map_or_default(Json::each)
1050
- .iter()
1051
- .find(|e| e.get("name").map(Json::to_s).unwrap_or_default() == type_name);
1052
- let fields_meta: Option<&[Json]> = vo.map_or_else(
1053
- || entity.and_then(|e| e.get("attributes").map(Json::each)),
1054
- |vo| vo.get("attributes").map(Json::each),
1055
- );
1056
- let Some(fields_meta) = fields_meta else {
1057
- return expr.to_string();
1058
- };
1059
-
1060
- if vo.is_some() && fields_meta.len() == 1 {
1061
- let field = &fields_meta[0];
1062
- let unwrapped = unwrap_json_expr(
1063
- "field_value",
1064
- crate::attr::type_name(field),
1065
- crate::attr::list(field),
1066
- aggregate,
1067
- value_objects_by_name,
1068
- );
1069
- let field_name = crate::attr::name(field);
1070
- return format!("match {expr} {{ crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == {field_name:?}).map_or(crate::kernel::Json::Null, |(_, field_value)| {unwrapped}), other => other }}");
1071
- }
1072
-
1073
- let inner_arms: Vec<String> = fields_meta
1074
- .iter()
1075
- .map(|f| {
1076
- let unwrapped = unwrap_json_expr(
1077
- "v",
1078
- crate::attr::type_name(f),
1079
- crate::attr::list(f),
1080
- aggregate,
1081
- value_objects_by_name,
1082
- );
1083
- format!("{:?} => {unwrapped},", crate::attr::name(f))
1084
- })
1085
- .collect();
1086
- let inner_arms = inner_arms.join(" ");
1087
- format!("match {expr} {{ crate::kernel::Json::Object(fields) => crate::kernel::Json::Object(fields.into_iter().map(|(k, v)| {{ let new_v = match k.as_str() {{ {inner_arms} _ => v }}; (k, new_v) }}).collect()), other => other }}")
1088
- }
1089
-
1090
1034
  const READ_MODEL_TABLE_ROW_PLACEHOLDER: &str = "crate::kernel::read_model::ReadModelDef {\n verb: \"tmpl_verb\",\n reference_name: Some(\"tmpl_reference_name\"),\n heads: &[\n crate::kernel::read_model::ReadModelHead {\n aggregate: \"tmpl_aggregate\",\n as_name: \"tmpl_as_name\",\n many: true,\n is_root: false,\n reference_fields: &[\n crate::kernel::read_model::ReferenceField { target_aggregate: \"tmpl_target_aggregate\", field: \"tmpl_field\" },\n ],\n },\n ],\n filtered_heads: &[\n crate::kernel::read_model::FilteredHead { as_name: \"tmpl_as_name\", conditions: &[crate::kernel::QueryCondition { field: \"tmpl_field\", comparator: crate::kernel::query_comparators::QueryComparator::Eq, value: crate::kernel::QueryConditionValue::Literal(\"tmpl_literal\") },], reference_hop_conditions: &[crate::kernel::read_model::ReferenceHopCondition { via_field: \"tmpl_via_field\", target_aggregate: \"tmpl_target_aggregate\", through: &[crate::kernel::read_model::HopStep { via_field: \"tmpl_via_field\", target_aggregate: \"tmpl_target_aggregate\" }], inner_field: \"tmpl_inner_field\", inner_comparator: crate::kernel::query_comparators::QueryComparator::Eq, inner_value: crate::kernel::QueryConditionValue::Literal(\"tmpl_literal\") },], order_by: Some(crate::kernel::read_model::ReadModelOrderBy { field: \"tmpl_order_field\", descending: true, nulls: crate::kernel::query_ordering::NullsMode::Last }), offset: Some(crate::kernel::read_model::ReadModelOffset::Literal(1)), limit: Some(crate::kernel::read_model::ReadModelLimit::Literal(5)) },\n ],\n authorization: Some(crate::kernel::named_query::TenantAuth { query_name: \"tmpl_query_name\", tenant_field: \"tmpl_tenant_field\", policy: \"tmpl_policy\" }),\n group_by: None,\n reduction: None,\n},";
1091
1035
 
1092
1036
  pub fn emit_read_model_table(exemplar: &Exemplar, read_model_defs: &[ReadModelDef]) -> String {
@@ -1 +1 @@
1
- 4.0.0
1
+ 5.0.0
@@ -715,6 +715,29 @@ pub async fn query(
715
715
  parse_kernel_output(&output)
716
716
  }
717
717
 
718
+ /// Answers `answer` (a kernel answer already computed and persisted) in the bare shape a caller
719
+ /// reads, by one more wasm run of the kernel's `{"present": answer}` input: each single-field
720
+ /// value object as its sole field. Nothing is stored or replayed.
721
+ pub async fn present(
722
+ wasm_path: &Path,
723
+ answer: serde_json::Value,
724
+ ) -> anyhow::Result<serde_json::Value> {
725
+ let owned_wasm_path = wasm_path.to_path_buf();
726
+ tokio::task::spawn_blocking(move || {
727
+ present_with(&answer, |input| wasm_runner::run(&owned_wasm_path, input))
728
+ })
729
+ .await?
730
+ }
731
+
732
+ /// Presents `answer` as `present` does, with `run` standing in for the kernel (text in, text out).
733
+ pub(crate) fn present_with(
734
+ answer: &serde_json::Value,
735
+ run: impl FnOnce(&str) -> anyhow::Result<String>,
736
+ ) -> anyhow::Result<serde_json::Value> {
737
+ let input = serde_json::json!({ "present": answer }).to_string();
738
+ parse_kernel_output(&run(&input)?)
739
+ }
740
+
718
741
  #[cfg(test)]
719
742
  pub(crate) mod tests {
720
743
 
@@ -963,6 +986,30 @@ pub(crate) mod tests {
963
986
  }
964
987
  }
965
988
 
989
+ #[test]
990
+ fn present_hands_the_kernel_the_answer_under_present_and_answers_what_it_returns() {
991
+ let answer = serde_json::json!({"instances": {"Shop::Item#a": {"name": {"value": "x"}}}});
992
+ let mut sent = String::new();
993
+ let presented = present_with(&answer, |input| {
994
+ sent = input.to_string();
995
+ Ok(r#"{"instances":{"Shop::Item#a":{"name":"x"}}}"#.to_string())
996
+ })
997
+ .unwrap();
998
+ assert_eq!(
999
+ serde_json::from_str::<serde_json::Value>(&sent).unwrap(),
1000
+ serde_json::json!({ "present": answer })
1001
+ );
1002
+ assert_eq!(presented["instances"]["Shop::Item#a"]["name"], "x");
1003
+ }
1004
+
1005
+ #[test]
1006
+ fn present_is_an_error_when_the_kernel_fails() {
1007
+ let failed = present_with(&serde_json::json!({}), |_| {
1008
+ Ok(r#"{"error":"expected a top-level {\"steps\": [...]} object"}"#.to_string())
1009
+ });
1010
+ assert!(failed.is_err());
1011
+ }
1012
+
966
1013
  pub(crate) fn wasm_path() -> std::path::PathBuf {
967
1014
  std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/banking.wasm")
968
1015
  }
@@ -25,16 +25,36 @@ use tokio_postgres::Client;
25
25
 
26
26
  /// The per-request dispatch decision, shared between the Lambda custom-runtime path and the
27
27
  /// axum fallback route below. `body` is already-parsed JSON in one of its recognized shapes.
28
+ ///
29
+ /// `"bare": true` beside any of them (`verb` with `to`/`with` or `args`, `read`, `query`) answers
30
+ /// the same outcome with each single-field value object as its bare field
31
+ /// (`{"name": "Margherita"}`, not `{"name": {"value": "Margherita"}}`). What is stored is the
32
+ /// same either way; without `bare` the answer is the stored shape.
28
33
  pub async fn dispatch_body(body: Value, host: Host<'_>) -> Result<Value, Error> {
34
+ if let Some(response) = web::render(&body, host).await {
35
+ return Ok(response);
36
+ }
37
+ let answer = kernel_answer(&body, host).await?;
38
+ if !wants_bare(&body) {
39
+ return Ok(answer);
40
+ }
41
+ Ok(dispatch::present(host.wasm_path, answer)
42
+ .await
43
+ .map_err(|e| format!("{e:#}"))?)
44
+ }
45
+
46
+ fn wants_bare(body: &Value) -> bool {
47
+ body.get("bare").and_then(Value::as_bool) == Some(true)
48
+ }
49
+
50
+ // The kernel's outcome for a read, query or command body, in the stored shape.
51
+ async fn kernel_answer(body: &Value, host: Host<'_>) -> Result<Value, Error> {
29
52
  let Host {
30
53
  client,
31
54
  wasm_path,
32
55
  config,
33
56
  ..
34
57
  } = host;
35
- if let Some(response) = web::render(&body, host).await {
36
- return Ok(response);
37
- }
38
58
 
39
59
  if body.get("read").and_then(serde_json::Value::as_bool) == Some(true) {
40
60
  // `{e:#}`: anyhow's alternate Display walks `source()` to the real message,
@@ -48,7 +68,7 @@ pub async fn dispatch_body(body: Value, host: Host<'_>) -> Result<Value, Error>
48
68
  // A query step reads current state and writes nothing to the journal.
49
69
  if body.get("query").is_some() {
50
70
  return Ok(crate::query_step::answer(
51
- &body,
71
+ body,
52
72
  crate::ir::ir(),
53
73
  &config.domain,
54
74
  client,
@@ -1041,6 +1061,58 @@ mod tests {
1041
1061
  );
1042
1062
  }
1043
1063
 
1064
+ // The checkout fixture's `Event`: `slug`, `name` and `capacity` hold one `value` field and
1065
+ // `price` one `cents` field, so each answers as a bare scalar when asked to.
1066
+ #[tokio::test]
1067
+ async fn a_bare_body_answers_single_field_value_objects_bare_and_the_default_stays_wrapped() {
1068
+ let client = crate::dispatch::tests::scratch_db("rust_host_serve_bare").await;
1069
+ crate::dispatch::tests::provision_lineage(
1070
+ &*client.lock().await,
1071
+ "CheckoutFixture",
1072
+ 1,
1073
+ &["Event"],
1074
+ )
1075
+ .await;
1076
+ let config = LineageConfig {
1077
+ domain: "CheckoutFixture".to_string(),
1078
+ era: Some(1),
1079
+ mirrored: None,
1080
+ };
1081
+ let wasm = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm");
1082
+ let host = Host::new(&client, &wasm, &config, &crate::lambda_client::NeverInvoker);
1083
+ let schedule = serde_json::json!({
1084
+ "verb": "CheckoutFixture::Event.Schedule",
1085
+ "role": "Organizer",
1086
+ "with": {"slug": "intro", "name": "Intro", "price": 1500, "capacity": 10},
1087
+ "bare": true,
1088
+ });
1089
+
1090
+ let scheduled = dispatch_body(schedule, host).await.unwrap();
1091
+ let wrapped = dispatch_body(serde_json::json!({"read": true}), host)
1092
+ .await
1093
+ .unwrap();
1094
+ let bare = dispatch_body(serde_json::json!({"read": true, "bare": true}), host)
1095
+ .await
1096
+ .unwrap();
1097
+
1098
+ let event = "CheckoutFixture::Event#intro";
1099
+ assert_eq!(
1100
+ scheduled["instances"][event]["name"], "Intro",
1101
+ "{scheduled}"
1102
+ );
1103
+ assert_eq!(
1104
+ scheduled["events"][0]["payload"]["price"], 1500,
1105
+ "{scheduled}"
1106
+ );
1107
+ assert_eq!(
1108
+ wrapped["instances"][event]["name"],
1109
+ serde_json::json!({"value": "Intro"}),
1110
+ "{wrapped}"
1111
+ );
1112
+ assert_eq!(bare["instances"][event]["price"], 1500, "{bare}");
1113
+ assert_eq!(bare["instances"][event]["capacity"], 10, "{bare}");
1114
+ }
1115
+
1044
1116
  #[test]
1045
1117
  fn build_is_the_env_value_when_set() {
1046
1118
  assert_eq!(version_body("e", "h", Some("cms-1"))["build"], "cms-1");
@@ -1,5 +1,6 @@
1
1
  //! The stdin/stdout JSON CLI shared by the native binary and the wasm32-wasip1 module.
2
- //! `run` reads `{"steps": [...]}` and hands each step to `kernel::orchestrate` (ADR 0012).
2
+ //! `run` reads `{"steps": [...]}` and hands each step to `kernel::orchestrate` (ADR 0012), or
3
+ //! `{"present": answer}` and answers that answer bare (`kernel::present`).
3
4
 
4
5
  use super::{
5
6
  named_query, orchestrate, query_comparators, read_model, repository, AggregateScan,
@@ -24,6 +25,18 @@ pub fn run(input: &str) -> String {
24
25
  Err(e) => return error_output(&format!("invalid JSON on stdin: {e}")),
25
26
  };
26
27
 
28
+ // `{"present": answer}`: a run's answer in the bare shape a caller reads (single-field value
29
+ // objects as their sole field); no step runs, and stored state stays wrapped.
30
+ // Parsed again keeping number text, so a Float the answer carries is written back unchanged.
31
+ if parsed.get("present").is_some() {
32
+ let answer = Json::parse_keeping_number_text(input)
33
+ .ok()
34
+ .and_then(|whole| whole.get("present").cloned())
35
+ .unwrap_or(Json::Null);
36
+ return super::present::present_answer(answer, crate::generated::active::present)
37
+ .to_json_string();
38
+ }
39
+
27
40
  // Optional `"needs"`: which commands need which outside facts (ADR 0081), passed by the host.
28
41
  super::needs::install(parsed.get("needs"));
29
42
  // Optional `"query_needs"`: which queries need which outside facts, filled the same way.
@@ -371,9 +371,21 @@ impl Json {
371
371
  }
372
372
 
373
373
  pub fn parse(input: &str) -> Result<Self, String> {
374
+ Self::parse_with(input, false)
375
+ }
376
+
377
+ /// Parses `input` keeping every number literal's source text, so writing the value back
378
+ /// reproduces each number exactly as given (`0.0` stays `0.0`, not `0`). For a document that
379
+ /// is only reshaped and written out again, never evaluated.
380
+ pub fn parse_keeping_number_text(input: &str) -> Result<Self, String> {
381
+ Self::parse_with(input, true)
382
+ }
383
+
384
+ fn parse_with(input: &str, keep_number_text: bool) -> Result<Self, String> {
374
385
  let mut parser = Parser {
375
386
  chars: input.chars().peekable(),
376
387
  depth: 0,
388
+ keep_number_text,
377
389
  };
378
390
  let value = parser.parse_value()?;
379
391
  parser.skip_ws();
@@ -703,6 +715,7 @@ fn write_escaped_string(s: &str, out: &mut String) {
703
715
  struct Parser<'a> {
704
716
  chars: std::iter::Peekable<std::str::Chars<'a>>,
705
717
  depth: usize,
718
+ keep_number_text: bool,
706
719
  }
707
720
 
708
721
  /// How deeply arrays and objects may nest before `parse` refuses. Each level is a recursive call,
@@ -895,7 +908,8 @@ impl Parser<'_> {
895
908
  // A literal with a `.`/exponent is a genuine `Float`, and Ruby's `Float` is the same
896
909
  // `f64` Rust's is, so there is nothing to preserve there.
897
910
  let is_plain_integer = !s.contains('.') && !s.contains(['e', 'E']);
898
- let keeps_source = is_plain_integer && canonical_integer_digits(value) != s;
911
+ let keeps_source =
912
+ self.keep_number_text || (is_plain_integer && canonical_integer_digits(value) != s);
899
913
  // `1e999` parses to infinity with no source text kept, and `write` would print it as `inf`:
900
914
  // not JSON, so refuse it. A plain integer keeps its digits, which `write` re-emits as-is.
901
915
  if !value.is_finite() && !keeps_source {
@@ -10,6 +10,7 @@ pub mod named_query;
10
10
  pub mod needs;
11
11
  pub mod orchestrate;
12
12
  pub mod pattern;
13
+ pub mod present;
13
14
  pub mod query_ordering;
14
15
  pub mod vocab;
15
16
  // Kept at its old path so existing `refusal_wording::RefusalSite` call sites still resolve.
@@ -0,0 +1,261 @@
1
+ //! The kernel's `{"present": answer}` input: a run's answer turned into the bare shape a caller
2
+ //! is answered with, by the generated `present(aggregate, state)` router.
3
+ //!
4
+ //! It runs no step and touches no store. Only what a caller reads is presented: `instances`,
5
+ //! each event's `payload`, and each declared query's `rows`. Everything a host stores or replays
6
+ //! (`mutations`, `sagas`, `saga_snapshot`, ...) is left exactly as it came, because stored state
7
+ //! stays wrapped.
8
+
9
+ use super::Json;
10
+
11
+ /// The generated router's shape: one `"Domain::Aggregate"` state in, its bare shape out.
12
+ pub type Presenter = fn(&str, Json) -> Json;
13
+
14
+ /// Returns `answer` with its `instances`, event payloads and declared query rows presented
15
+ /// through `present`; every other key passes through verbatim.
16
+ pub fn present_answer(answer: Json, present: Presenter) -> Json {
17
+ let Json::Object(fields) = answer else {
18
+ return answer;
19
+ };
20
+ Json::Object(
21
+ fields
22
+ .into_iter()
23
+ .map(|(key, value)| {
24
+ let value = match key.as_str() {
25
+ "instances" => present_instances(value, present),
26
+ "events" => map_items(value, |event| present_event(event, present)),
27
+ "queries" => map_items(value, |entry| present_query(entry, present)),
28
+ _ => value,
29
+ };
30
+ (key, value)
31
+ })
32
+ .collect(),
33
+ )
34
+ }
35
+
36
+ // `"Domain::Aggregate#id" => state`: the aggregate is the key up to its first `#`.
37
+ fn present_instances(instances: Json, present: Presenter) -> Json {
38
+ let Json::Object(entries) = instances else {
39
+ return instances;
40
+ };
41
+ Json::Object(
42
+ entries
43
+ .into_iter()
44
+ .map(|(key, state)| {
45
+ let aggregate = key.split_once('#').map_or(key.as_str(), |(head, _)| head);
46
+ let state = present(aggregate, state);
47
+ (key, state)
48
+ })
49
+ .collect(),
50
+ )
51
+ }
52
+
53
+ fn present_event(event: Json, present: Presenter) -> Json {
54
+ let Some(aggregate) = event
55
+ .get("aggregate")
56
+ .and_then(Json::as_str)
57
+ .map(str::to_string)
58
+ else {
59
+ return event;
60
+ };
61
+ replace_field(event, "payload", |payload| present(&aggregate, payload))
62
+ }
63
+
64
+ // A declared query is named `"Domain::Aggregate.Query"`; its rows are that aggregate's state.
65
+ // A read model or an ad hoc filter names no aggregate this way, and the router passes an unknown
66
+ // name through, so its rows come back as they were.
67
+ fn present_query(entry: Json, present: Presenter) -> Json {
68
+ let Some(aggregate) = entry
69
+ .get("query")
70
+ .and_then(Json::as_str)
71
+ .and_then(|question| question.rsplit_once('.'))
72
+ .map(|(head, _)| head.to_string())
73
+ else {
74
+ return entry;
75
+ };
76
+ replace_field(entry, "rows", |rows| {
77
+ map_items(rows, |row| present(&aggregate, row))
78
+ })
79
+ }
80
+
81
+ fn replace_field(object: Json, name: &str, f: impl Fn(Json) -> Json) -> Json {
82
+ let Json::Object(fields) = object else {
83
+ return object;
84
+ };
85
+ Json::Object(
86
+ fields
87
+ .into_iter()
88
+ .map(|(key, value)| {
89
+ let value = if key == name { f(value) } else { value };
90
+ (key, value)
91
+ })
92
+ .collect(),
93
+ )
94
+ }
95
+
96
+ fn map_items(list: Json, f: impl Fn(Json) -> Json) -> Json {
97
+ match list {
98
+ Json::Array(items) => Json::Array(items.into_iter().map(f).collect()),
99
+ other => other,
100
+ }
101
+ }
102
+
103
+ #[cfg(test)]
104
+ mod tests {
105
+ use super::*;
106
+
107
+ // Stands in for the generated router: `Shop::Item` unwraps its `name` object to its
108
+ // `value`; any other aggregate passes through, as the generated one does.
109
+ fn shop_present(aggregate: &str, state: Json) -> Json {
110
+ if aggregate != "Shop::Item" {
111
+ return state;
112
+ }
113
+ replace_field(state, "name", |name| {
114
+ name.get("value").cloned().unwrap_or(name)
115
+ })
116
+ }
117
+
118
+ fn present(text: &str) -> String {
119
+ present_answer(Json::parse(text).unwrap(), shop_present).to_json_string()
120
+ }
121
+
122
+ #[test]
123
+ fn instances_are_presented_by_the_aggregate_their_key_names() {
124
+ assert_eq!(
125
+ present(
126
+ r#"{"instances":{"Shop::Item#a":{"name":{"value":"x"}},"Other::Thing#b":{"name":{"value":"y"}}}}"#
127
+ ),
128
+ r#"{"instances":{"Shop::Item#a":{"name":"x"},"Other::Thing#b":{"name":{"value":"y"}}}}"#
129
+ );
130
+ }
131
+
132
+ #[test]
133
+ fn an_event_payload_is_presented_by_its_aggregate() {
134
+ assert_eq!(
135
+ present(
136
+ r#"{"events":[{"name":"Named","aggregate":"Shop::Item","payload":{"name":{"value":"x"}}}]}"#
137
+ ),
138
+ r#"{"events":[{"name":"Named","aggregate":"Shop::Item","payload":{"name":"x"}}]}"#
139
+ );
140
+ }
141
+
142
+ #[test]
143
+ fn declared_query_rows_are_presented_by_the_aggregate_the_query_names() {
144
+ assert_eq!(
145
+ present(
146
+ r#"{"queries":[{"query":"Shop::Item.All","rows":[{"id":"a","name":{"value":"x"}}],"reference_rows":[{"name":{"value":"x"}}]}]}"#
147
+ ),
148
+ r#"{"queries":[{"query":"Shop::Item.All","rows":[{"id":"a","name":"x"}],"reference_rows":[{"name":{"value":"x"}}]}]}"#
149
+ );
150
+ }
151
+
152
+ #[test]
153
+ fn mutations_sagas_and_refusals_pass_through_verbatim() {
154
+ let text = r#"{"mutations":[[{"aggregate":"Shop::Item","state":{"name":{"value":"x"}}}]],"sagas":[],"refusals":[{"verb":"Shop::Item.Name","error":"no"}]}"#;
155
+ assert_eq!(present(text), text);
156
+ }
157
+ }
158
+
159
+ // Through `cli::run` and the generated pizzas router: what `{"present": answer}` answers for a
160
+ // real run's answer.
161
+ #[cfg(test)]
162
+ #[cfg(feature = "pizzas")]
163
+ mod pizzas_tests {
164
+ use crate::kernel::cli::run;
165
+ use crate::kernel::Json;
166
+
167
+ const STEPS: &str = r#"{"steps":[
168
+ {"verb":"Pizzas::Order.CreatePizza","with":{"name":"Margherita","pizza":{"price_cents":1200,"size":"large"}}},
169
+ {"verb":"Pizzas::Order.AddTopping","to":"Margherita","with":{"topping":"basil","amount":2}},
170
+ {"verb":"Pizzas::Order.Purchase","to":"Margherita","with":{"amount":1500,"customer_name":"Ann"}},
171
+ {"query":"Pizzas::Order.Everything"}]}"#;
172
+
173
+ fn answers() -> (Json, Json) {
174
+ let answer = run(STEPS);
175
+ let presented = run(&format!(r#"{{"present":{answer}}}"#));
176
+ (
177
+ Json::parse(&answer).unwrap(),
178
+ Json::parse(&presented).unwrap(),
179
+ )
180
+ }
181
+
182
+ fn text(value: Option<&Json>) -> String {
183
+ value.map_or_else(|| "missing".to_string(), Json::to_json_string)
184
+ }
185
+
186
+ fn items<'a>(value: &'a Json, key: &str) -> &'a [Json] {
187
+ value.get(key).and_then(Json::as_array).unwrap_or_default()
188
+ }
189
+
190
+ #[test]
191
+ fn an_instance_answers_bare_and_a_multi_field_value_object_keeps_its_object() {
192
+ let (_, presented) = answers();
193
+ assert_eq!(
194
+ text(presented.dig("instances.Pizzas::Order#Margherita")),
195
+ r#"{"name":"Margherita","pizza":{"price_cents":1200,"size":"large"},"toppings":[{"name":"basil","amount":2}],"customer_name":"Ann","status":"sold"}"#
196
+ );
197
+ }
198
+
199
+ #[test]
200
+ fn each_event_payload_answers_bare_even_where_two_commands_type_one_name_apart() {
201
+ let (_, presented) = answers();
202
+ let payloads: Vec<String> = items(&presented, "events")
203
+ .iter()
204
+ .map(|event| text(event.get("payload")))
205
+ .collect();
206
+ assert_eq!(
207
+ payloads,
208
+ [
209
+ r#"{"name":"Margherita","pizza":{"price_cents":1200,"size":"large"}}"#,
210
+ r#"{"topping":"basil","amount":2}"#,
211
+ r#"{"amount":1500,"customer_name":"Ann"}"#,
212
+ ]
213
+ );
214
+ }
215
+
216
+ #[test]
217
+ fn a_declared_query_row_answers_bare() {
218
+ let (_, presented) = answers();
219
+ assert_eq!(
220
+ text(items(&presented, "queries")[0].get("rows")),
221
+ r#"[{"id":"Margherita","name":"Margherita","pizza":{"price_cents":1200,"size":"large"},"toppings":[{"name":"basil","amount":2}],"customer_name":"Ann","status":"sold"}]"#
222
+ );
223
+ }
224
+
225
+ #[test]
226
+ fn mutations_and_the_run_without_present_stay_wrapped() {
227
+ let (answer, presented) = answers();
228
+ assert_eq!(
229
+ text(presented.get("mutations")),
230
+ text(answer.get("mutations"))
231
+ );
232
+ assert_eq!(
233
+ text(answer.dig("instances.Pizzas::Order#Margherita.name")),
234
+ r#"{"value":"Margherita"}"#
235
+ );
236
+ }
237
+ }
238
+
239
+ #[cfg(test)]
240
+ #[cfg(feature = "tally")]
241
+ mod tally_tests {
242
+ use crate::kernel::cli::run;
243
+ use crate::kernel::Json;
244
+
245
+ #[test]
246
+ fn a_list_of_single_field_value_objects_answers_as_scalars_and_a_float_stays_one() {
247
+ let answer = run(
248
+ r#"{"steps":[{"verb":"Tallying::Tally.Open","with":{"tag":"a"}},
249
+ {"verb":"Tallying::Tally.TagWith","to":"a","with":{"label":"red"}},
250
+ {"verb":"Tallying::Tally.TagWith","to":"a","with":{"label":"blue"}}]}"#,
251
+ );
252
+ let presented =
253
+ Json::parse_keeping_number_text(&run(&format!(r#"{{"present":{answer}}}"#))).unwrap();
254
+ let tally = presented.dig("instances.Tallying::Tally#a").unwrap();
255
+ assert_eq!(
256
+ tally.get("tags").unwrap().to_json_string(),
257
+ r#"["red","blue"]"#
258
+ );
259
+ assert_eq!(tally.get("weight").unwrap().to_json_string(), "0.0");
260
+ }
261
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -1543,6 +1543,7 @@ files:
1543
1543
  - lib/hecks/runtime/value/invariant_violation.rb
1544
1544
  - lib/hecks/runtime/value/references.rb
1545
1545
  - lib/hecks/runtime/value/shape_checks.rb
1546
+ - lib/hecks/runtime/value/unwrapping.rb
1546
1547
  - lib/hecks/runtime/value/validation.rb
1547
1548
  - lib/hecks/rust_build.rb
1548
1549
  - lib/hecks/rust_build/conformance.rb
@@ -1685,9 +1686,9 @@ files:
1685
1686
  - lib/rubocop/cop/hecks/symbol_string_key_mix.rb
1686
1687
  - lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb
1687
1688
  - lib/rubocop/cop/hecks/untyped_raise.rb
1688
- - prebuilt/02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal
1689
- - prebuilt/bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal
1690
- - prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json
1689
+ - prebuilt/ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal
1690
+ - prebuilt/ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal
1691
+ - prebuilt/verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json
1691
1692
  - qa/settings.yml
1692
1693
  - rust/.cargo/config.toml
1693
1694
  - rust/.cargo/mutants.toml
@@ -1743,6 +1744,7 @@ files:
1743
1744
  - rust/codegen/src/optional_pass.rs
1744
1745
  - rust/codegen/src/ports.rs
1745
1746
  - rust/codegen/src/prelude.rs
1747
+ - rust/codegen/src/presenters.rs
1746
1748
  - rust/codegen/src/queries.rs
1747
1749
  - rust/codegen/src/reactions.rs
1748
1750
  - rust/codegen/src/read_models.rs
@@ -1946,6 +1948,7 @@ files:
1946
1948
  - rust/src/kernel/orchestrate.rs
1947
1949
  - rust/src/kernel/orchestrate_guard_tests.rs
1948
1950
  - rust/src/kernel/pattern.rs
1951
+ - rust/src/kernel/present.rs
1949
1952
  - rust/src/kernel/query_comparators.rs
1950
1953
  - rust/src/kernel/query_ordering.rs
1951
1954
  - rust/src/kernel/read_model.rs