Skip to main content

golem_schema/schema/
graph.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::schema::metadata::{MetadataEnvelope, TypeId};
16use crate::schema::schema_type::SchemaType;
17use crate::schema::schema_value::SchemaValue;
18use golem_schema_derive::{FromSchema, IntoSchema};
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet};
21
22/// A self-contained schema graph in the recursive in-memory form.
23///
24/// Anywhere a schema travels with a value (typed pair, oplog `custom`
25/// payload, REST/RPC envelope, public oplog rendering), the payload owns its
26/// own [`SchemaGraph`] — there is no implicit external registry that
27/// consumers must look up.
28///
29/// Recursive references between types go through [`SchemaType::Ref`], pointing
30/// at named definitions in [`SchemaGraph::defs`].
31///
32/// ## Single-root vs multi-root carriers
33///
34/// The common case is single-root: one `SchemaGraph` describes one
35/// payload, with `root` as the entry type and `defs` as the named-type
36/// registry reachable from it.
37///
38/// Multi-root carriers (today, agent-layer carriers such as
39/// [`crate::schema::agent::AgentTypeSchema`] and
40/// [`crate::schema::agent::AgentDependencySchema`]) use the same shape
41/// purely as a definition registry: many roots embedded elsewhere in the
42/// carrier reference shared types in `defs`, and the `SchemaGraph::root`
43/// field is a sentinel (see [`SchemaGraph::empty`]). Such carriers must
44/// not be passed to root-oriented walkers/renderers as if `root` were the
45/// payload root.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, IntoSchema, FromSchema)]
47#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))]
48#[cfg_attr(feature = "full", desert(evolution()))]
49#[serde(rename_all = "camelCase")]
50#[schema(named = "schema-graph")]
51#[cfg_attr(feature = "full", derive(golem_schema_derive::PoemSchema))]
52pub struct SchemaGraph {
53    /// Named type definitions in this graph. The defining set is exactly the
54    /// types reachable from `root` (directly or transitively) that need to be
55    /// named — typically because they participate in a recursive cycle or are
56    /// referenced multiple times.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub defs: Vec<SchemaTypeDef>,
59    /// Root schema type. Anonymous types appear inline; named types appear
60    /// as [`SchemaType::Ref`].
61    pub root: SchemaType,
62}
63
64impl SchemaGraph {
65    /// Convenience: an anonymous root schema with no named definitions.
66    pub fn anonymous(root: SchemaType) -> Self {
67        Self {
68            defs: Vec::new(),
69            root,
70        }
71    }
72
73    /// Convenience: an empty graph with no definitions and a sentinel root.
74    ///
75    /// Used as the initial value for multi-root carriers such as
76    /// [`crate::schema::agent::AgentTypeSchema`] and
77    /// [`crate::schema::agent::AgentDependencySchema`], where the agent's
78    /// constructor and methods each act as their own root and only the
79    /// `defs` registry is consulted. The `root` field is a placeholder
80    /// (empty record) and is not consumed by agent-layer helpers.
81    pub fn empty() -> Self {
82        Self {
83            defs: Vec::new(),
84            root: SchemaType::Record {
85                fields: Vec::new(),
86                metadata: MetadataEnvelope::default(),
87            },
88        }
89    }
90
91    /// Look up a named definition by its [`TypeId`].
92    pub fn lookup(&self, id: &TypeId) -> Option<&SchemaTypeDef> {
93        self.defs.iter().find(|d| &d.id == id)
94    }
95
96    /// Whether any type reachable from the root is a stream, including unselected alternatives.
97    pub fn contains_stream_type(&self) -> bool {
98        let mut pending = vec![&self.root];
99        let mut visited = HashSet::new();
100        while let Some(ty) = pending.pop() {
101            match ty {
102                SchemaType::Stream { .. } => return true,
103                SchemaType::Ref { id, .. } => {
104                    if visited.insert(id)
105                        && let Some(def) = self.lookup(id)
106                    {
107                        pending.push(&def.body);
108                    }
109                }
110                SchemaType::Record { fields, .. } => {
111                    pending.extend(fields.iter().map(|field| &field.body));
112                }
113                SchemaType::Variant { cases, .. } => {
114                    pending.extend(cases.iter().filter_map(|case| case.payload.as_ref()));
115                }
116                SchemaType::Tuple { elements, .. } => pending.extend(elements),
117                SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
118                    pending.push(element);
119                }
120                SchemaType::Map { key, value, .. } => {
121                    pending.extend([key.as_ref(), value.as_ref()])
122                }
123                SchemaType::Option { inner, .. } => pending.push(inner),
124                SchemaType::Result { spec, .. } => {
125                    pending.extend(spec.ok.as_deref());
126                    pending.extend(spec.err.as_deref());
127                }
128                SchemaType::Union { spec, .. } => {
129                    pending.extend(spec.branches.iter().map(|branch| &branch.body));
130                }
131                SchemaType::Future { inner, .. } => pending.extend(inner.as_deref()),
132                SchemaType::Secret { spec, .. } => pending.push(&spec.inner),
133                SchemaType::Bool { .. }
134                | SchemaType::S8 { .. }
135                | SchemaType::S16 { .. }
136                | SchemaType::S32 { .. }
137                | SchemaType::S64 { .. }
138                | SchemaType::U8 { .. }
139                | SchemaType::U16 { .. }
140                | SchemaType::U32 { .. }
141                | SchemaType::U64 { .. }
142                | SchemaType::F32 { .. }
143                | SchemaType::F64 { .. }
144                | SchemaType::Char { .. }
145                | SchemaType::String { .. }
146                | SchemaType::Enum { .. }
147                | SchemaType::Flags { .. }
148                | SchemaType::Text { .. }
149                | SchemaType::Binary { .. }
150                | SchemaType::Path { .. }
151                | SchemaType::Url { .. }
152                | SchemaType::Uuid { .. }
153                | SchemaType::Datetime { .. }
154                | SchemaType::Duration { .. }
155                | SchemaType::Quantity { .. }
156                | SchemaType::QuotaToken { .. }
157                | SchemaType::PermissionCard { .. } => {}
158            }
159        }
160        false
161    }
162
163    /// Walk through any number of [`SchemaType::Ref`] indirections and return
164    /// the first non-ref body in this graph. Detects recursive cycles
165    /// ([`RefResolutionError::RecursiveRef`]) and dangling references
166    /// ([`RefResolutionError::DanglingRef`]).
167    pub fn resolve_ref<'a>(
168        &'a self,
169        ty: &'a SchemaType,
170    ) -> Result<&'a SchemaType, RefResolutionError> {
171        let mut visiting: Vec<TypeId> = Vec::new();
172        let mut current = ty;
173        loop {
174            match current {
175                SchemaType::Ref { id, .. } => {
176                    if visiting.iter().any(|x| x == id) {
177                        return Err(RefResolutionError::RecursiveRef(id.clone()));
178                    }
179                    let def = self
180                        .lookup(id)
181                        .ok_or_else(|| RefResolutionError::DanglingRef(id.clone()))?;
182                    visiting.push(id.clone());
183                    current = &def.body;
184                }
185                other => return Ok(other),
186            }
187        }
188    }
189}
190
191/// A borrowed, per-traversal accelerator for [`SchemaGraph::lookup`].
192///
193/// [`SchemaGraph::lookup`] is a linear scan over `defs`. When a single
194/// traversal (value validation, rendering, ref resolution) resolves many
195/// [`SchemaType::Ref`]s against a wide graph, those repeated scans dominate.
196/// `GraphIndex` borrows a graph once and, for graphs above a small size
197/// threshold, builds a one-shot hash index so each subsequent lookup is O(1).
198/// Small graphs keep the plain linear scan, because building and hashing an
199/// index is a net loss there (and would otherwise regress narrow inputs such
200/// as a typical agent method parameter record).
201///
202/// It only borrows the graph and never mutates it, so [`SchemaGraph`] stays a
203/// pure data/wire model (no embedded lazy cache that would travel through
204/// serde / desert / protobuf / poem derives).
205pub struct GraphIndex<'a> {
206    defs: &'a [SchemaTypeDef],
207    index: Option<HashMap<&'a str, &'a SchemaTypeDef>>,
208}
209
210impl<'a> GraphIndex<'a> {
211    /// Graphs with at most this many definitions use a linear scan instead of
212    /// building a hash index.
213    const INDEX_THRESHOLD: usize = 8;
214
215    /// Borrow `graph` and prepare a lookup accelerator for it.
216    pub fn new(graph: &'a SchemaGraph) -> Self {
217        let defs = graph.defs.as_slice();
218        let index = (defs.len() > Self::INDEX_THRESHOLD).then(|| {
219            let mut map = HashMap::with_capacity(defs.len());
220            for def in defs {
221                // First definition wins on duplicate ids, matching the
222                // `defs.iter().find(...)` scan in `SchemaGraph::lookup`.
223                map.entry(def.id.as_str()).or_insert(def);
224            }
225            map
226        });
227        Self { defs, index }
228    }
229
230    /// Number of named definitions in the indexed graph.
231    pub fn defs_len(&self) -> usize {
232        self.defs.len()
233    }
234
235    /// Look up a named definition by its [`TypeId`], with the same semantics as
236    /// [`SchemaGraph::lookup`].
237    pub fn lookup(&self, id: &TypeId) -> Option<&'a SchemaTypeDef> {
238        match &self.index {
239            Some(map) => map.get(id.as_str()).copied(),
240            None => self.defs.iter().find(|d| &d.id == id),
241        }
242    }
243}
244
245/// Error from resolving a chain of [`SchemaType::Ref`] indirections in a
246/// [`SchemaGraph`].
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum RefResolutionError {
249    /// The schema graph has a recursive cycle.
250    RecursiveRef(TypeId),
251    /// A [`SchemaType::Ref`] pointed at a [`TypeId`] not present in the graph.
252    DanglingRef(TypeId),
253}
254
255impl std::fmt::Display for RefResolutionError {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        match self {
258            Self::RecursiveRef(id) => {
259                write!(f, "schema graph contains a recursive reference: {id}")
260            }
261            Self::DanglingRef(id) => write!(f, "schema graph contains dangling reference: {id}"),
262        }
263    }
264}
265
266impl std::error::Error for RefResolutionError {}
267
268/// A named type definition inside a [`SchemaGraph`].
269///
270/// The def itself does not carry metadata; metadata lives on the
271/// [`SchemaType`] body so there is one source of truth for docs / aliases /
272/// examples / deprecation / role per type.
273#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, IntoSchema, FromSchema)]
274#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))]
275#[cfg_attr(feature = "full", desert(evolution()))]
276#[serde(rename_all = "camelCase")]
277#[schema(named = "schema-type-def")]
278#[cfg_attr(feature = "full", derive(golem_schema_derive::PoemSchema))]
279pub struct SchemaTypeDef {
280    /// Stable identifier; unique within the enclosing graph.
281    pub id: TypeId,
282    /// Optional human-readable qualified name (display only).
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub name: Option<String>,
285    /// The body of this definition. Its metadata envelope is the def's
286    /// metadata.
287    pub body: SchemaType,
288}
289
290/// A typed value: a self-contained [`SchemaGraph`] paired with a value tree
291/// built against that schema.
292///
293/// The pair is the only public form for typed values; there is no bare-value
294/// overload of any walker / renderer / encoder API.
295#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, IntoSchema, FromSchema)]
296#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))]
297#[cfg_attr(feature = "full", desert(evolution()))]
298#[serde(rename_all = "camelCase")]
299#[schema(named = "typed-schema-value")]
300#[cfg_attr(feature = "full", derive(golem_schema_derive::PoemSchema))]
301pub struct TypedSchemaValue {
302    graph: SchemaGraph,
303    value: SchemaValue,
304}
305
306impl TypedSchemaValue {
307    pub fn new(graph: SchemaGraph, value: SchemaValue) -> Self {
308        Self { graph, value }
309    }
310
311    pub fn graph(&self) -> &SchemaGraph {
312        &self.graph
313    }
314
315    pub fn value(&self) -> &SchemaValue {
316        &self.value
317    }
318
319    pub fn root_type(&self) -> &SchemaType {
320        &self.graph.root
321    }
322
323    pub fn into_parts(self) -> (SchemaGraph, SchemaValue) {
324        (self.graph, self.value)
325    }
326}
327
328/// Collect, in the source graph's definition order, the named definitions of
329/// `graph` that are transitively reachable from `root` through
330/// [`SchemaType::Ref`] indirections.
331///
332/// A [`TypedSchemaValue`] is a self-contained, single-root carrier, so its
333/// `defs` must be exactly the definitions reachable from its `root`. The source
334/// `graph` here is the agent's whole multi-root definition registry; projecting
335/// to the reachable subset honors that contract and avoids cloning defs the
336/// value can never reference.
337///
338/// Dangling refs (no matching def in `graph`) are skipped; validation of the
339/// value against the projected graph reports them. Each reachable id is emitted
340/// once, first-def-wins on duplicate ids, matching [`SchemaGraph::lookup`].
341pub fn reachable_defs<'a>(graph: &'a SchemaGraph, root: &'a SchemaType) -> Vec<SchemaTypeDef> {
342    // Seed the worklist from refs in the synthesized root. The common
343    // primitive/no-ref input returns here without building any lookup index.
344    let mut stack: Vec<&'a TypeId> = Vec::new();
345    collect_refs(root, &mut stack);
346    if stack.is_empty() {
347        return Vec::new();
348    }
349
350    let index = GraphIndex::new(graph);
351    let mut reachable: HashSet<&'a str> = HashSet::new();
352    while let Some(id) = stack.pop() {
353        if !reachable.insert(id.as_str()) {
354            continue;
355        }
356        if let Some(def) = index.lookup(id) {
357            collect_refs(&def.body, &mut stack);
358        }
359    }
360
361    // Emit reachable defs in the source graph's order. Removing each id as it is
362    // emitted yields first-def-wins on duplicate ids (matching
363    // [`SchemaGraph::lookup`]) without a second visited set.
364    graph
365        .defs
366        .iter()
367        .filter(|d| reachable.remove(d.id.as_str()))
368        .cloned()
369        .collect()
370}
371
372/// Push every [`SchemaType::Ref`] id directly contained in `ty` (descending
373/// through all structural children, but not following the refs themselves) onto
374/// `out`. All schema alternatives are visited — every variant case, every union
375/// branch, both result arms — so projection follows *schema* reachability, not
376/// the shape of any particular value.
377fn collect_refs<'a>(ty: &'a SchemaType, out: &mut Vec<&'a TypeId>) {
378    match ty {
379        SchemaType::Ref { id, .. } => out.push(id),
380        SchemaType::Record { fields, .. } => {
381            for field in fields {
382                collect_refs(&field.body, out);
383            }
384        }
385        SchemaType::Variant { cases, .. } => {
386            for case in cases {
387                if let Some(payload) = &case.payload {
388                    collect_refs(payload, out);
389                }
390            }
391        }
392        SchemaType::Tuple { elements, .. } => {
393            for element in elements {
394                collect_refs(element, out);
395            }
396        }
397        SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
398            collect_refs(element, out);
399        }
400        SchemaType::Map { key, value, .. } => {
401            collect_refs(key, out);
402            collect_refs(value, out);
403        }
404        SchemaType::Option { inner, .. } => collect_refs(inner, out),
405        SchemaType::Result { spec, .. } => {
406            if let Some(ok) = &spec.ok {
407                collect_refs(ok, out);
408            }
409            if let Some(err) = &spec.err {
410                collect_refs(err, out);
411            }
412        }
413        SchemaType::Union { spec, .. } => {
414            for branch in &spec.branches {
415                collect_refs(&branch.body, out);
416            }
417        }
418        SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
419            if let Some(inner) = inner {
420                collect_refs(inner, out);
421            }
422        }
423        SchemaType::Secret { spec, .. } => collect_refs(&spec.inner, out),
424        // Leaf nodes carrying no child `SchemaType`. Listed explicitly (no
425        // wildcard) so a future ref-bearing variant forces this match to be
426        // updated.
427        SchemaType::Bool { .. }
428        | SchemaType::S8 { .. }
429        | SchemaType::S16 { .. }
430        | SchemaType::S32 { .. }
431        | SchemaType::S64 { .. }
432        | SchemaType::U8 { .. }
433        | SchemaType::U16 { .. }
434        | SchemaType::U32 { .. }
435        | SchemaType::U64 { .. }
436        | SchemaType::F32 { .. }
437        | SchemaType::F64 { .. }
438        | SchemaType::Char { .. }
439        | SchemaType::String { .. }
440        | SchemaType::Enum { .. }
441        | SchemaType::Flags { .. }
442        | SchemaType::Text { .. }
443        | SchemaType::Binary { .. }
444        | SchemaType::Path { .. }
445        | SchemaType::Url { .. }
446        | SchemaType::Uuid { .. }
447        | SchemaType::Datetime { .. }
448        | SchemaType::Duration { .. }
449        | SchemaType::Quantity { .. }
450        | SchemaType::QuotaToken { .. }
451        | SchemaType::PermissionCard { .. } => {}
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use test_r::test;
459
460    #[test]
461    fn stream_detection_follows_reachable_recursive_definitions() {
462        let recursive = TypeId::new("recursive");
463        let output = TypeId::new("output");
464        let mut graph = SchemaGraph {
465            root: SchemaType::ref_to(recursive.clone()),
466            defs: vec![
467                SchemaTypeDef {
468                    id: recursive.clone(),
469                    name: None,
470                    body: SchemaType::tuple(vec![SchemaType::ref_to(recursive)]),
471                },
472                SchemaTypeDef {
473                    id: output.clone(),
474                    name: None,
475                    body: SchemaType::option(SchemaType::stream(Some(SchemaType::u32()))),
476                },
477            ],
478        };
479        assert!(
480            !graph.contains_stream_type(),
481            "unreachable streams do not count"
482        );
483        let SchemaType::Tuple { elements, .. } = &mut graph.defs[0].body else {
484            unreachable!()
485        };
486        elements.push(SchemaType::ref_to(output));
487        assert!(
488            graph.contains_stream_type(),
489            "a cycle must not hide a sibling stream"
490        );
491        assert!(SchemaGraph::anonymous(SchemaType::stream(None)).contains_stream_type());
492    }
493
494    fn def(id: &str, name: Option<&str>) -> SchemaTypeDef {
495        SchemaTypeDef {
496            id: TypeId::new(id),
497            name: name.map(|n| n.to_string()),
498            body: SchemaType::bool(),
499        }
500    }
501
502    /// `GraphIndex::lookup` must agree with `SchemaGraph::lookup` for every id,
503    /// on both the linear (small) and indexed (wide) branches, including the
504    /// first-def-wins rule for duplicate ids.
505    fn assert_index_matches_linear(graph: &SchemaGraph) {
506        let index = GraphIndex::new(graph);
507        // Probe every present id plus a guaranteed-absent one.
508        let mut ids: Vec<TypeId> = graph.defs.iter().map(|d| d.id.clone()).collect();
509        ids.push(TypeId::new("definitely-absent-id"));
510        for id in &ids {
511            assert_eq!(
512                index.lookup(id),
513                graph.lookup(id),
514                "GraphIndex and SchemaGraph disagree on id {id:?}"
515            );
516        }
517    }
518
519    #[test]
520    fn graph_index_matches_linear_small_with_duplicates() {
521        // 3 defs (<= threshold => linear branch), with a duplicate id whose
522        // first occurrence must win.
523        let graph = SchemaGraph {
524            defs: vec![
525                def("a", Some("first-a")),
526                def("b", Some("b")),
527                def("a", Some("second-a")),
528            ],
529            root: SchemaType::bool(),
530        };
531        let index = GraphIndex::new(&graph);
532        assert!(
533            index.index.is_none(),
534            "small graph must use linear fallback"
535        );
536        assert_eq!(
537            index
538                .lookup(&TypeId::new("a"))
539                .and_then(|d| d.name.as_deref()),
540            Some("first-a"),
541            "first definition must win on duplicate ids"
542        );
543        assert_index_matches_linear(&graph);
544    }
545
546    #[test]
547    fn graph_index_matches_linear_wide_with_duplicates() {
548        // > threshold defs => indexed branch; include a duplicate id.
549        let mut defs: Vec<SchemaTypeDef> = (0..20)
550            .map(|i| def(&format!("t{i:02}"), Some(&format!("name-{i:02}"))))
551            .collect();
552        defs.push(def("t05", Some("duplicate-of-t05")));
553        let graph = SchemaGraph {
554            defs,
555            root: SchemaType::bool(),
556        };
557        let index = GraphIndex::new(&graph);
558        assert!(index.index.is_some(), "wide graph must build an index");
559        assert_eq!(
560            index
561                .lookup(&TypeId::new("t05"))
562                .and_then(|d| d.name.as_deref()),
563            Some("name-05"),
564            "first definition must win on duplicate ids in the indexed branch"
565        );
566        assert_index_matches_linear(&graph);
567    }
568}