Skip to main content

distributed/graphql/command_contract/
projections.rs

1use serde::Serialize;
2
3use crate::domain_event::{DomainEventBodyContract, DomainEventContract};
4use crate::projection::lower::{ProjectionBodyMetadata, ProjectionPortableType};
5use crate::{
6    DomainEvent, DomainEventBodyKind, DomainEventDescriptor, DomainState, ProjectionEnvelopeField,
7    ProjectionEventSelector, ProjectionValue,
8};
9
10/// One preview-time source for an emitted domain-event body field.
11///
12/// The source is retained only in the server-side command contract. Client
13/// manifests lower body paths to program-scoped opaque slots and never expose
14/// the outward event schema's private source paths.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum CommandProjectionPreviewSource {
18    /// Read a value from canonical GraphQL command input.
19    InputPath { path: Vec<String> },
20    /// Read a value generated into canonical command input before dispatch.
21    GeneratedDefaultPath { path: Vec<String> },
22    /// Read a framework-authenticated scoped preset by public descriptor.
23    TrustedPreset { name: String, codec: String },
24    /// Use a portable typed constant.
25    Constant { value: ProjectionValue },
26    /// Use explicit null.
27    Null,
28    /// The body property is known to be omitted.
29    Absent,
30    /// This one field cannot be predicted; other fields remain usable.
31    Unknown,
32    /// Deliberately non-portable server-only source.
33    ///
34    /// Registration rejects this variant. It exists as a fail-closed sentinel
35    /// for generated declarations, not as a client escape hatch.
36    ServerOnly,
37}
38
39impl CommandProjectionPreviewSource {
40    /// Construct a canonical input-path source.
41    pub fn input(path: impl IntoIterator<Item = impl Into<String>>) -> Self {
42        Self::InputPath {
43            path: path.into_iter().map(Into::into).collect(),
44        }
45    }
46
47    /// Construct a generated-default path source.
48    pub fn generated_default(path: impl IntoIterator<Item = impl Into<String>>) -> Self {
49        Self::GeneratedDefaultPath {
50            path: path.into_iter().map(Into::into).collect(),
51        }
52    }
53
54    /// Construct a trusted scoped-preset source without retaining its value.
55    pub fn trusted(name: impl Into<String>, codec: impl Into<String>) -> Self {
56        Self::TrustedPreset {
57            name: name.into(),
58            codec: codec.into(),
59        }
60    }
61
62    /// Construct a portable constant source.
63    pub fn constant(value: ProjectionValue) -> Self {
64        Self::Constant { value }
65    }
66}
67
68/// One emitted-body path and its preview provenance.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
70pub struct CommandProjectionPreviewField {
71    pub(crate) body_path: Vec<String>,
72    pub(crate) envelope: Option<ProjectionEnvelopeField>,
73    #[serde(skip)]
74    pub(crate) body_type: Option<ProjectionPortableType>,
75    #[serde(skip)]
76    pub(crate) body_rust_type: Option<&'static str>,
77    #[serde(skip)]
78    pub(crate) body_nullable: Option<bool>,
79    #[serde(skip)]
80    pub(crate) body_always_present: Option<bool>,
81    pub(crate) source: CommandProjectionPreviewSource,
82}
83
84/// Partial, field-by-field preview for one exact emitted event set.
85///
86/// Missing fields are unknown by definition. Unknown fields do not poison
87/// known fields: manifest lowering can still produce a safe partial patch and
88/// attach narrow recovery for the unresolved remainder.
89#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
90pub struct CommandProjectionPreview {
91    pub(crate) selectors: Vec<ProjectionEventSelector>,
92    pub(crate) declaration_errors: Vec<String>,
93    pub(crate) fields: Vec<CommandProjectionPreviewField>,
94}
95
96impl CommandProjectionPreview {
97    /// Begin an empty, intentionally partial preview.
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Bind this preview to the exact outward event-set value also passed to
103    /// [`crate::graphql::TypedCommand::emits`].
104    #[must_use]
105    pub fn events(mut self, events: CommandProjectionEventSet) -> Self {
106        self.selectors = events.selectors;
107        self.declaration_errors = events.declaration_errors;
108        self
109    }
110
111    /// Bind one emitted-event body path to preview provenance.
112    #[must_use]
113    pub fn field(
114        mut self,
115        body_path: impl IntoIterator<Item = impl Into<String>>,
116        source: CommandProjectionPreviewSource,
117    ) -> Self {
118        self.fields.push(CommandProjectionPreviewField {
119            body_path: body_path.into_iter().map(Into::into).collect(),
120            envelope: None,
121            body_type: None,
122            body_rust_type: None,
123            body_nullable: None,
124            body_always_present: None,
125            source,
126        });
127        self
128    }
129
130    /// Bind one non-intrinsic occurrence-envelope field to command provenance.
131    #[must_use]
132    pub fn envelope(
133        mut self,
134        field: ProjectionEnvelopeField,
135        source: CommandProjectionPreviewSource,
136    ) -> Self {
137        self.fields.push(CommandProjectionPreviewField {
138            body_path: Vec::new(),
139            envelope: Some(field),
140            body_type: None,
141            body_rust_type: None,
142            body_nullable: None,
143            body_always_present: None,
144            source,
145        });
146        self
147    }
148}
149
150/// Preview declaration for one exact emitted event selector.
151#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
152pub(crate) struct CommandProjectionEventPreview {
153    pub selector: ProjectionEventSelector,
154    pub preview: CommandProjectionPreview,
155}
156
157/// Pure reducer over a known cache row for client auto-optimism.
158///
159/// Domain owns pure semantics. Client delivery is either:
160/// - **WASM** ([`Self::wasm`]): gen-client emits a `createWasmJsonPure` host in
161///   `pures.ts` — no app TypeScript pure file required.
162/// - **Hand module** ([`Self::client_module`]): gen-client imports a named export
163///   from `$lib/<module>` (escape hatch).
164#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
165pub struct CommandProjectionPureReduce {
166    /// Stable pure id, e.g. `blob.simulate_move`.
167    pub fn_name: String,
168    /// Path under app `$lib` without extension for a hand-written pure (empty if WASM).
169    pub client_module: String,
170    /// Named export in that hand module (empty if WASM).
171    pub client_export: String,
172    /// wasm-pack package under `$lib` without extension, e.g. `blob/pkg/blob_wasm`.
173    pub wasm_package: String,
174    /// Named WASM export `(recordJson, argsJson) -> assignJson | undefined`.
175    pub wasm_export: String,
176    /// Projection model id (e.g. `BlobGames`).
177    pub model: String,
178    /// Record key fields: `name` is the model field; `source` is input/default/preset.
179    pub key: Vec<CommandProjectionPureArg>,
180    /// Pure function arguments (resolved like preview values).
181    pub args: Vec<CommandProjectionPureArg>,
182    /// Fields taken from the pure result and patched onto the known row.
183    pub assign: Vec<String>,
184}
185
186/// One named pure-reduce binding (key field or pure arg).
187#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
188pub struct CommandProjectionPureArg {
189    pub name: String,
190    pub source: CommandProjectionPreviewSource,
191}
192
193impl CommandProjectionPureReduce {
194    /// Hand-written pure under `$lib/<module>` exporting `client_export`.
195    pub fn client_module(
196        fn_name: impl Into<String>,
197        client_module: impl Into<String>,
198        client_export: impl Into<String>,
199        model: impl Into<String>,
200    ) -> Self {
201        Self {
202            fn_name: fn_name.into(),
203            client_module: client_module.into(),
204            client_export: client_export.into(),
205            wasm_package: String::new(),
206            wasm_export: String::new(),
207            model: model.into(),
208            key: Vec::new(),
209            args: Vec::new(),
210            assign: Vec::new(),
211        }
212    }
213
214    /// Domain pure shipped as wasm-pack under `$lib/<package>`; gen-client hosts it.
215    pub fn wasm(
216        fn_name: impl Into<String>,
217        wasm_package: impl Into<String>,
218        wasm_export: impl Into<String>,
219        model: impl Into<String>,
220    ) -> Self {
221        Self {
222            fn_name: fn_name.into(),
223            client_module: String::new(),
224            client_export: String::new(),
225            wasm_package: wasm_package.into(),
226            wasm_export: wasm_export.into(),
227            model: model.into(),
228            key: Vec::new(),
229            args: Vec::new(),
230            assign: Vec::new(),
231        }
232    }
233
234    /// Deprecated alias for [`Self::client_module`].
235    #[deprecated(note = "use client_module() or wasm()")]
236    pub fn new(
237        fn_name: impl Into<String>,
238        client_module: impl Into<String>,
239        client_export: impl Into<String>,
240        model: impl Into<String>,
241    ) -> Self {
242        Self::client_module(fn_name, client_module, client_export, model)
243    }
244
245    #[must_use]
246    pub fn key_input(
247        mut self,
248        field: impl Into<String>,
249        path: impl IntoIterator<Item = impl Into<String>>,
250    ) -> Self {
251        self.key.push(CommandProjectionPureArg {
252            name: field.into(),
253            source: CommandProjectionPreviewSource::input(path),
254        });
255        self
256    }
257
258    #[must_use]
259    pub fn arg_input(
260        mut self,
261        name: impl Into<String>,
262        path: impl IntoIterator<Item = impl Into<String>>,
263    ) -> Self {
264        self.args.push(CommandProjectionPureArg {
265            name: name.into(),
266            source: CommandProjectionPreviewSource::input(path),
267        });
268        self
269    }
270
271    #[must_use]
272    pub fn assign(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
273        self.assign.extend(fields.into_iter().map(Into::into));
274        self.assign.sort();
275        self.assign.dedup();
276        self
277    }
278}
279
280/// Exact outward events a command may emit, independent of any projector.
281#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
282pub(crate) struct CommandProjectionEvents {
283    pub selectors: Vec<ProjectionEventSelector>,
284    pub previews: Vec<CommandProjectionEventPreview>,
285    /// Values proved by a generated domain transition. Projector arms decide
286    /// whether these event values have any client-visible consequence.
287    #[serde(skip)]
288    pub inferred_values: Vec<CommandProjectionEventPreview>,
289    /// Pure reducers over known cache rows (client auto-optimism).
290    pub pure_reduces: Vec<CommandProjectionPureReduce>,
291    pub declaration_errors: Vec<String>,
292}
293
294impl CommandProjectionEvents {
295    pub(crate) fn add_event_set(&mut self, events: CommandProjectionEventSet) {
296        self.selectors.extend(events.selectors);
297        self.declaration_errors.extend(events.declaration_errors);
298    }
299
300    pub(crate) fn add_preview(&mut self, preview: CommandProjectionPreview) {
301        self.declaration_errors
302            .extend(preview.declaration_errors.clone());
303        if preview.selectors.is_empty() {
304            self.declaration_errors
305                .push("projection preview must bind exactly one emitted event variant".to_owned());
306            return;
307        }
308        if preview.selectors.len() != 1 {
309            self.declaration_errors.push(
310                "projection preview must bind one exact event variant, not a multi-event set"
311                    .to_owned(),
312            );
313            return;
314        }
315        self.previews
316            .extend(preview.selectors.iter().cloned().map(|selector| {
317                CommandProjectionEventPreview {
318                    selector,
319                    preview: preview.clone(),
320                }
321            }));
322    }
323
324    pub(crate) fn add_inferred_values(&mut self, values: CommandProjectionPreview) {
325        self.declaration_errors
326            .extend(values.declaration_errors.clone());
327        if values.selectors.len() != 1 {
328            self.declaration_errors.push(
329                "inferred transition values must bind exactly one emitted event variant".to_owned(),
330            );
331            return;
332        }
333        let selector = values.selectors[0].clone();
334        if let Some(existing) = self
335            .inferred_values
336            .iter_mut()
337            .find(|candidate| candidate.selector == selector)
338        {
339            existing.preview.fields.extend(values.fields);
340            existing
341                .preview
342                .declaration_errors
343                .extend(values.declaration_errors);
344        } else {
345            self.inferred_values.push(CommandProjectionEventPreview {
346                selector,
347                preview: values,
348            });
349        }
350    }
351
352    pub(crate) fn add_authenticated_user_field(
353        &mut self,
354        rust_field: &str,
355        values: CommandProjectionPreview,
356    ) {
357        if values.fields.len() != 1 {
358            self.declaration_errors.push(
359                format!(
360                    "authenticated-user inference field `{rust_field}` is not one exact emitted-event body field"
361                ),
362            );
363            return;
364        }
365        self.add_inferred_values(values);
366    }
367
368    pub(crate) fn add_pure_reduce(&mut self, reduce: CommandProjectionPureReduce) {
369        self.pure_reduces.push(reduce);
370    }
371
372    pub(crate) fn canonicalize_and_validate(&mut self, command: &str) -> Result<(), String> {
373        if let Some(error) = self.declaration_errors.first() {
374            return Err(format!(
375                "typed command `{command}` has an invalid domain-event declaration: {error}"
376            ));
377        }
378        self.selectors
379            .sort_by(ProjectionEventSelector::canonical_cmp);
380        if self.selectors.windows(2).any(|pair| pair[0] == pair[1]) {
381            return Err(format!(
382                "typed command `{command}` repeats an exact emitted domain event selector"
383            ));
384        }
385        for pair in self.selectors.windows(2) {
386            if pair[0].event_name() == pair[1].event_name()
387                && pair[0].event_version() == pair[1].event_version()
388                && pair[0] != pair[1]
389            {
390                return Err(format!(
391                    "typed command `{command}` declares conflicting schemas for domain event `{}` v{}",
392                    pair[0].event_name(),
393                    pair[0].event_version()
394                ));
395            }
396        }
397        // Preview declarations are synthetic occurrences, not another view of
398        // the allowed event set. Preserve their declaration order so generated
399        // clients can apply them deterministically until the authoritative
400        // ordered command delta replaces the optimistic overlay.
401        canonicalize_preview_declarations(command, &self.selectors, &mut self.previews, false)?;
402        canonicalize_preview_declarations(
403            command,
404            &self.selectors,
405            &mut self.inferred_values,
406            true,
407        )?;
408        for reduce in &mut self.pure_reduces {
409            if reduce.fn_name.trim().is_empty() || reduce.model.trim().is_empty() {
410                return Err(format!(
411                    "typed command `{command}` pure reduce requires non-empty fn and model"
412                ));
413            }
414            let hand =
415                !reduce.client_module.trim().is_empty() || !reduce.client_export.trim().is_empty();
416            let wasm =
417                !reduce.wasm_package.trim().is_empty() || !reduce.wasm_export.trim().is_empty();
418            if hand == wasm {
419                return Err(format!(
420                    "typed command `{command}` pure reduce `{}` must declare either client_module+client_export or wasm_package+wasm_export (not both, not neither)",
421                    reduce.fn_name
422                ));
423            }
424            if hand
425                && (reduce.client_module.trim().is_empty()
426                    || reduce.client_export.trim().is_empty())
427            {
428                return Err(format!(
429                    "typed command `{command}` pure reduce `{}` client module requires non-empty client_module and client_export",
430                    reduce.fn_name
431                ));
432            }
433            if wasm
434                && (reduce.wasm_package.trim().is_empty() || reduce.wasm_export.trim().is_empty())
435            {
436                return Err(format!(
437                    "typed command `{command}` pure reduce `{}` wasm package requires non-empty wasm_package and wasm_export",
438                    reduce.fn_name
439                ));
440            }
441            if reduce.key.is_empty() {
442                return Err(format!(
443                    "typed command `{command}` pure reduce `{}` requires at least one key field",
444                    reduce.fn_name
445                ));
446            }
447            if reduce.assign.is_empty() {
448                return Err(format!(
449                    "typed command `{command}` pure reduce `{}` requires at least one assign field",
450                    reduce.fn_name
451                ));
452            }
453            reduce.key.sort_by(|a, b| a.name.cmp(&b.name));
454            reduce.args.sort_by(|a, b| a.name.cmp(&b.name));
455            reduce.assign.sort();
456            reduce.assign.dedup();
457            for arg in reduce.key.iter().chain(reduce.args.iter()) {
458                match &arg.source {
459                    CommandProjectionPreviewSource::InputPath { path }
460                    | CommandProjectionPreviewSource::GeneratedDefaultPath { path } => {
461                        validate_path(command, "pure reduce", path)?;
462                    }
463                    CommandProjectionPreviewSource::TrustedPreset { name, codec } => {
464                        if name.trim().is_empty() || codec.trim().is_empty() {
465                            return Err(format!(
466                                "typed command `{command}` pure reduce trusted preset name and codec must not be empty"
467                            ));
468                        }
469                    }
470                    other => {
471                        return Err(format!(
472                            "typed command `{command}` pure reduce `{}` arg `{}` uses unsupported source {other:?}",
473                            reduce.fn_name, arg.name
474                        ));
475                    }
476                }
477            }
478        }
479        self.pure_reduces
480            .sort_by(|left, right| left.fn_name.cmp(&right.fn_name));
481        if self
482            .pure_reduces
483            .windows(2)
484            .any(|pair| pair[0].fn_name == pair[1].fn_name)
485        {
486            return Err(format!(
487                "typed command `{command}` repeats pure reduce fn name"
488            ));
489        }
490        Ok(())
491    }
492}
493
494fn canonicalize_preview_declarations(
495    command: &str,
496    selectors: &[ProjectionEventSelector],
497    previews: &mut [CommandProjectionEventPreview],
498    inferred: bool,
499) -> Result<(), String> {
500    for preview in previews {
501        if selectors
502            .binary_search_by(|selector| selector.canonical_cmp(&preview.selector))
503            .is_err()
504        {
505            let source = if inferred {
506                "infers transition values"
507            } else {
508                "declares preview provenance"
509            };
510            return Err(format!(
511                "typed command `{command}` {source} outside its exact emitted event set"
512            ));
513        }
514        preview.preview.fields.sort_by_key(preview_field_key);
515        for pair in preview.preview.fields.windows(2) {
516            if preview_field_key(&pair[0]) == preview_field_key(&pair[1]) {
517                let source = if inferred {
518                    "inferred transition value"
519                } else {
520                    "preview provenance"
521                };
522                return Err(format!(
523                    "typed command `{command}` repeats {source} for one event value"
524                ));
525            }
526        }
527        for field in &preview.preview.fields {
528            if field.envelope.is_none() {
529                validate_path(command, "emitted body", &field.body_path)?;
530            }
531            match &field.source {
532                CommandProjectionPreviewSource::InputPath { path }
533                | CommandProjectionPreviewSource::GeneratedDefaultPath { path } => {
534                    validate_path(command, "preview input", path)?;
535                }
536                CommandProjectionPreviewSource::TrustedPreset { name, codec } => {
537                    if name.trim().is_empty() || codec.trim().is_empty() {
538                        return Err(format!(
539                            "typed command `{command}` preview trusted preset name and codec must not be empty"
540                        ));
541                    }
542                }
543                CommandProjectionPreviewSource::ServerOnly => {
544                    return Err(format!(
545                        "typed command `{command}` cannot expose server-only preview provenance"
546                    ));
547                }
548                CommandProjectionPreviewSource::Constant { .. }
549                | CommandProjectionPreviewSource::Null
550                | CommandProjectionPreviewSource::Absent
551                | CommandProjectionPreviewSource::Unknown => {}
552            }
553        }
554    }
555    Ok(())
556}
557
558/// Sealed exact event-set value produced by [`events!`](crate::events).
559#[derive(Clone, Debug, Default, PartialEq, Eq)]
560pub struct CommandProjectionEventSet {
561    selectors: Vec<ProjectionEventSelector>,
562    declaration_errors: Vec<String>,
563}
564
565/// Build the sealed command event-set value used by `events!`.
566#[doc(hidden)]
567pub fn __command_projection_events(
568    descriptors: impl IntoIterator<Item = Result<DomainEventDescriptor, String>>,
569) -> CommandProjectionEventSet {
570    let mut events = CommandProjectionEventSet::default();
571    for descriptor in descriptors {
572        let descriptor = match descriptor {
573            Ok(descriptor) => descriptor,
574            Err(error) => {
575                events.declaration_errors.push(error);
576                continue;
577            }
578        };
579        match ProjectionEventSelector::try_from_descriptor(&descriptor) {
580            Ok(selector) => events.selectors.push(selector),
581            Err(error) => events.declaration_errors.push(error.to_string()),
582        }
583    }
584    events
585}
586
587/// Resolve one exact typed command event descriptor for `events!`.
588#[doc(hidden)]
589pub fn __command_projection_event_descriptor<E: DomainEventContract>(
590) -> Result<DomainEventDescriptor, String> {
591    let descriptor = E::descriptor();
592    if descriptor.name != E::EVENT_NAME {
593        return Err(format!(
594            "event contract name `{}` differs from descriptor name `{}`",
595            E::EVENT_NAME,
596            descriptor.name
597        ));
598    }
599    if descriptor.version != E::EVENT_VERSION {
600        return Err(format!(
601            "event contract `{}` version {} differs from descriptor version {}",
602            E::EVENT_NAME,
603            E::EVENT_VERSION,
604            descriptor.version
605        ));
606    }
607    Ok(descriptor)
608}
609
610/// Build a structured state preview from generated body metadata.
611#[doc(hidden)]
612pub fn __command_projection_state_preview<E, S>(
613    fields: Vec<(&'static str, CommandProjectionPreviewSource)>,
614) -> CommandProjectionPreview
615where
616    E: DomainEventBodyContract<S>,
617    S: DomainState + ProjectionBodyMetadata,
618{
619    let descriptor = __command_projection_event_descriptor::<E>().and_then(|descriptor| {
620        let expected = DomainEventDescriptor::state::<S>(E::EVENT_NAME, E::EVENT_VERSION);
621        if descriptor != expected || descriptor.body.kind != DomainEventBodyKind::State {
622            return Err(format!(
623                "state preview event contract `{}` does not exactly describe `{}` state",
624                E::EVENT_NAME,
625                std::any::type_name::<S>()
626            ));
627        }
628        Ok(descriptor)
629    });
630    structured_preview::<S>(__command_projection_events([descriptor]), fields)
631}
632
633/// Build compiler-inferred state values from a sourced transition.
634///
635/// Unlike the application-authored preview helper, aggregate fields absent
636/// from the outward `DomainState` are ignored. This lets the sourced macro
637/// inspect recorder assignments without coupling private aggregate storage to
638/// the public event schema.
639#[doc(hidden)]
640pub fn __command_projection_state_known_values<E, S>(
641    fields: Vec<(&'static str, CommandProjectionPreviewSource)>,
642) -> CommandProjectionPreview
643where
644    E: DomainEventBodyContract<S>,
645    S: DomainState + ProjectionBodyMetadata,
646{
647    let descriptor = __command_projection_event_descriptor::<E>().and_then(|descriptor| {
648        let expected = DomainEventDescriptor::state::<S>(E::EVENT_NAME, E::EVENT_VERSION);
649        if descriptor != expected || descriptor.body.kind != DomainEventBodyKind::State {
650            return Err(format!(
651                "inferred transition event contract `{}` does not exactly describe `{}` state",
652                E::EVENT_NAME,
653                std::any::type_name::<S>()
654            ));
655        }
656        Ok(descriptor)
657    });
658    let mut values =
659        CommandProjectionPreview::new().events(__command_projection_events([descriptor]));
660    for (rust_name, source) in fields {
661        let Some(field) = S::PROJECTION_FIELDS
662            .iter()
663            .find(|field| field.rust_name == rust_name && field.present)
664        else {
665            continue;
666        };
667        values.fields.push(CommandProjectionPreviewField {
668            body_path: vec![field.wire_name.to_owned()],
669            envelope: None,
670            body_type: Some(field.portable_type),
671            body_rust_type: Some(field.rust_type),
672            body_nullable: Some(field.nullable),
673            body_always_present: Some(field.always_present),
674            source,
675        });
676    }
677    values
678}
679
680/// Build a structured sparse-event preview from generated body metadata.
681#[doc(hidden)]
682pub fn __command_projection_event_preview<E, B>(
683    fields: Vec<(&'static str, CommandProjectionPreviewSource)>,
684) -> CommandProjectionPreview
685where
686    E: DomainEventBodyContract<B>,
687    B: DomainEvent + ProjectionBodyMetadata,
688{
689    let descriptor = __command_projection_event_descriptor::<E>().and_then(|descriptor| {
690        if descriptor != B::DESCRIPTOR || descriptor.body.kind != DomainEventBodyKind::Event {
691            return Err(format!(
692                "event preview contract `{}` differs from its exact typed body descriptor",
693                E::EVENT_NAME
694            ));
695        }
696        Ok(descriptor)
697    });
698    structured_preview::<B>(__command_projection_events([descriptor]), fields)
699}
700
701fn structured_preview<B: ProjectionBodyMetadata>(
702    events: CommandProjectionEventSet,
703    fields: Vec<(&'static str, CommandProjectionPreviewSource)>,
704) -> CommandProjectionPreview {
705    let mut preview = CommandProjectionPreview::new().events(events);
706    for (rust_name, source) in fields {
707        match B::PROJECTION_FIELDS
708            .iter()
709            .find(|field| field.rust_name == rust_name && field.present)
710        {
711            Some(field) => preview.fields.push(CommandProjectionPreviewField {
712                body_path: vec![field.wire_name.to_owned()],
713                envelope: None,
714                body_type: Some(field.portable_type),
715                body_rust_type: Some(field.rust_type),
716                body_nullable: Some(field.nullable),
717                body_always_present: Some(field.always_present),
718                source,
719            }),
720            None => preview.declaration_errors.push(format!(
721                "state preview references unknown body field `{rust_name}`"
722            )),
723        }
724    }
725    preview
726}
727
728/// Convert a typed constant into the portable preview value lattice.
729#[doc(hidden)]
730pub fn __command_projection_preview_constant(
731    value: impl Serialize,
732) -> CommandProjectionPreviewSource {
733    match serde_json::to_value(value)
734        .map_err(|error| error.to_string())
735        .and_then(|value| ProjectionValue::try_from_json(value).map_err(|error| error.to_string()))
736    {
737        Ok(value) => CommandProjectionPreviewSource::Constant { value },
738        Err(_) => CommandProjectionPreviewSource::Unknown,
739    }
740}
741
742/// Type-level source of a command's outward domain-event set.
743///
744/// Implemented for:
745/// - every [`DomainEventContract`] marker (for example `TodoCreatedDomainEvent`)
746/// - tuples of those markers
747/// - `#[sourced]`-generated `domain_commands::*` transition witnesses (public
748///   aggregate methods that call domain-marked `#[event]` recorders)
749///
750/// Prefer [`crate::graphql::TypedCommand::emits_events`] with these types over
751/// hand-maintaining a parallel event list when the domain already owns the
752/// transition.
753pub trait CommandEventSet {
754    /// Build the sealed event-set value used by command registration.
755    fn command_event_set() -> CommandProjectionEventSet;
756
757    /// Return values proved by a generated domain transition.
758    ///
759    /// Ordinary event markers have none. `#[sourced]` transition witnesses
760    /// override this with portable unconditional recorder values; projection
761    /// arms remain the sole definition of their cache consequences.
762    fn command_event_known_values() -> Vec<CommandProjectionPreview> {
763        Vec::new()
764    }
765}
766
767impl<E: DomainEventContract> CommandEventSet for E {
768    fn command_event_set() -> CommandProjectionEventSet {
769        __command_projection_events([__command_projection_event_descriptor::<E>()])
770    }
771}
772
773macro_rules! impl_command_event_set_tuple {
774    ($($E:ident),+) => {
775        impl<$($E: DomainEventContract),+> CommandEventSet for ($($E,)+) {
776            fn command_event_set() -> CommandProjectionEventSet {
777                __command_projection_events([
778                    $(__command_projection_event_descriptor::<$E>()),+
779                ])
780            }
781        }
782    };
783}
784
785impl_command_event_set_tuple!(E1, E2);
786impl_command_event_set_tuple!(E1, E2, E3);
787impl_command_event_set_tuple!(E1, E2, E3, E4);
788impl_command_event_set_tuple!(E1, E2, E3, E4, E5);
789impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6);
790impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7);
791impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7, E8);
792
793/// Declare an exact, type-checked outward domain-event set.
794#[macro_export]
795macro_rules! events {
796    ($($event:ty),+ $(,)?) => {
797        $crate::graphql::__command_projection_events([
798            $($crate::graphql::__command_projection_event_descriptor::<$event>()),+
799        ])
800    };
801}
802
803/// Build partial preview provenance for an exact outward event set.
804///
805/// Body paths remain server-only. Manifest lowering replaces body-path leaves
806/// in authoritative projection expressions with program-scoped opaque slots.
807#[macro_export]
808macro_rules! state_preview {
809    (
810        $event:ty => $state:ty { $($fields:tt)* }
811    ) => {{
812        $crate::graphql::__command_projection_state_preview::<$event, $state>(
813            $crate::__distributed_state_preview_fields!(@collect [] ; $($fields)*)
814        )
815    }};
816}
817
818/// Build partial preview provenance for one exact sparse outward event.
819#[macro_export]
820macro_rules! event_preview {
821    (
822        $event:ty => $body:ty { $($fields:tt)* }
823    ) => {{
824        $crate::graphql::__command_projection_event_preview::<$event, $body>(
825            $crate::__distributed_state_preview_fields!(@collect [] ; $($fields)*)
826        )
827    }};
828}
829
830#[doc(hidden)]
831#[macro_export]
832macro_rules! __distributed_state_preview_fields {
833    (@collect [$($out:expr,)*] ; ..unknown $(,)?) => {
834        vec![$($out,)*]
835    };
836    (@collect [$($out:expr,)*] ; ) => {
837        vec![$($out,)*]
838    };
839    (@collect [$($out:expr,)*] ;
840        $field:ident : input.$first:ident $(.$rest:ident)*,
841        $($tail:tt)*
842    ) => {
843        $crate::__distributed_state_preview_fields!(
844            @collect [
845                $($out,)*
846                (
847                    stringify!($field),
848                    $crate::graphql::CommandProjectionPreviewSource::input([
849                        stringify!($first) $(, stringify!($rest))*
850                    ])
851                ),
852            ];
853            $($tail)*
854        )
855    };
856    (@collect [$($out:expr,)*] ;
857        $field:ident : generated.$first:ident $(.$rest:ident)*,
858        $($tail:tt)*
859    ) => {
860        $crate::__distributed_state_preview_fields!(
861            @collect [
862                $($out,)*
863                (
864                    stringify!($field),
865                    $crate::graphql::CommandProjectionPreviewSource::generated_default([
866                        stringify!($first) $(, stringify!($rest))*
867                    ])
868                ),
869            ];
870            $($tail)*
871        )
872    };
873    (@collect [$($out:expr,)*] ;
874        $field:ident : trusted($name:expr, $codec:expr),
875        $($tail:tt)*
876    ) => {
877        $crate::__distributed_state_preview_fields!(
878            @collect [
879                $($out,)*
880                (
881                    stringify!($field),
882                    $crate::graphql::CommandProjectionPreviewSource::trusted($name, $codec)
883                ),
884            ];
885            $($tail)*
886        )
887    };
888    (@collect [$($out:expr,)*] ; $field:ident : unknown, $($tail:tt)*) => {
889        $crate::__distributed_state_preview_fields!(
890            @collect [$($out,)* (stringify!($field), $crate::graphql::CommandProjectionPreviewSource::Unknown),];
891            $($tail)*
892        )
893    };
894    (@collect [$($out:expr,)*] ; $field:ident : absent, $($tail:tt)*) => {
895        $crate::__distributed_state_preview_fields!(
896            @collect [$($out,)* (stringify!($field), $crate::graphql::CommandProjectionPreviewSource::Absent),];
897            $($tail)*
898        )
899    };
900    (@collect [$($out:expr,)*] ; $field:ident : null, $($tail:tt)*) => {
901        $crate::__distributed_state_preview_fields!(
902            @collect [$($out,)* (stringify!($field), $crate::graphql::CommandProjectionPreviewSource::Null),];
903            $($tail)*
904        )
905    };
906    (@collect [$($out:expr,)*] ; $field:ident : $constant:path, $($tail:tt)*) => {
907        $crate::__distributed_state_preview_fields!(
908            @collect [
909                $($out,)*
910                (
911                    stringify!($field),
912                    $crate::graphql::__command_projection_preview_constant($constant)
913                ),
914            ];
915            $($tail)*
916        )
917    };
918    (@collect [$($out:expr,)*] ; $field:ident : $constant:literal, $($tail:tt)*) => {
919        $crate::__distributed_state_preview_fields!(
920            @collect [
921                $($out,)*
922                (
923                    stringify!($field),
924                    $crate::graphql::__command_projection_preview_constant($constant)
925                ),
926            ];
927            $($tail)*
928        )
929    };
930}
931
932fn validate_path(command: &str, label: &str, path: &[String]) -> Result<(), String> {
933    if path.is_empty() || path.iter().any(|segment| segment.trim().is_empty()) {
934        return Err(format!(
935            "typed command `{command}` {label} path must contain only non-empty segments"
936        ));
937    }
938    Ok(())
939}
940
941fn preview_field_key(field: &CommandProjectionPreviewField) -> (u8, Vec<String>) {
942    match field.envelope {
943        Some(envelope) => (
944            1,
945            vec![serde_json::to_string(&envelope)
946                .expect("projection envelope field serialization cannot fail")],
947        ),
948        None => (0, field.body_path.clone()),
949    }
950}