Skip to main content

icydb_model/
fragment.rs

1//! Module: fragment
2//!
3//! Responsibility: lower one sealed host graph into a store-free database closure.
4//!
5//! Does not own: proposal routing, accepted identity, deployment configuration, or persistence.
6//!
7//! Boundary: converts compiler-authored logical facts into bounded public schema fragments.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    str::FromStr,
12};
13
14use icydb_schema::{
15    Account, Blob, ConstraintFragment, ConstraintSourceKey, DEFAULT_BIG_INT_MAX_BYTES, Date,
16    Decimal, Duration, EntityFragment, EntitySourceKey, EnumTypeFragment, EnumVariantFragment,
17    FieldFragment, FieldInsertPolicy, FieldManagementPolicy, FieldSourceKey, FieldType, Float32,
18    Float64, IndexFragment, IndexKeyFragment, IndexSourceKey, IntBig, NamedTypeFragment, NatBig,
19    Principal, RecordFieldFragment, RecordTypeFragment, RelationDeleteAction, RelationFragment,
20    RelationSourceKey, RuleSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
21    SchemaFragment, SchemaName, SourceCheckExpr, SourceCheckInstruction, Subaccount, Timestamp,
22    TupleElementFragment, TypeSourceKey, Ulid, Unit,
23};
24use thiserror::Error;
25
26use crate::{
27    node::{
28        Arg, ArgNumber, Canister, CheckConstraint, Entity, Enum, Field, FieldWriteManagement,
29        Index, IndexExpression, IndexKeyItem, IndexKeyItemsRef, Item, ItemTarget, List, Map,
30        Record, RelationEdge, Schema, SchemaNode, Set, SourceRule, SourceRuleKind, Store, Tuple,
31        Value,
32    },
33    types::{Cardinality, Primitive},
34};
35
36/// Failure while projecting one validated host graph into public fragments.
37
38#[derive(Debug, Error)]
39pub enum FragmentLoweringError {
40    /// A selected canister has no registered stores.
41    #[error("schema canister has no registered stores: {0}")]
42    CanisterHasNoStores(String),
43
44    /// The selected canister path is not registered.
45    #[error("schema canister path is not registered: {0}")]
46    CanisterNotFound(String),
47
48    /// The public bounded proposal contract rejected the projection.
49    #[error(transparent)]
50    Contract(#[from] SchemaContractError),
51
52    /// Fragment projection requires the immutable post-validation graph.
53    #[error("schema graph must be sealed before fragment lowering")]
54    GraphNotSealed,
55
56    /// A declared default cannot be represented by the public proposal atom.
57    #[error("schema field default cannot be lowered: {0}")]
58    InvalidDefault(String),
59
60    /// One graph reference no longer resolves to the expected node kind.
61    #[error("schema fragment reference is invalid: {0}")]
62    InvalidReference(String),
63
64    /// One authored value cardinality has no accepted proposal representation.
65    #[error("schema value cardinality is unsupported at {0}")]
66    UnsupportedCardinality(String),
67}
68
69// -----------------------------------------------------------------------------
70// Database closure
71// -----------------------------------------------------------------------------
72
73impl Schema {
74    /// Lower every persisted entity belonging to one canister, plus its exact
75    /// reachable named-type and relation closure, into one store-free fragment.
76    ///
77    /// Store assignment remains a later proposal-composition concern.
78    ///
79    /// # Errors
80    ///
81    /// Returns a typed error when the graph is not sealed, the selected
82    /// canister/store closure is incomplete, or one authored fact cannot be
83    /// represented by the bounded public proposal contract.
84    pub fn schema_fragment_for_canister(
85        &self,
86        canister_path: &str,
87    ) -> Result<SchemaFragment, FragmentLoweringError> {
88        if !self.is_sealed() {
89            return Err(FragmentLoweringError::GraphNotSealed);
90        }
91        self.cast_node::<Canister>(canister_path)
92            .map_err(|_| FragmentLoweringError::CanisterNotFound(canister_path.to_string()))?;
93        let stores = self
94            .filter_nodes::<Store>(|store| store.canister() == canister_path)
95            .map(|(path, _)| path.to_string())
96            .collect::<BTreeSet<_>>();
97        if stores.is_empty() {
98            return Err(FragmentLoweringError::CanisterHasNoStores(
99                canister_path.to_string(),
100            ));
101        }
102
103        let entities = self
104            .get_nodes::<Entity>()
105            .filter(|(_, entity)| stores.contains(entity.store()))
106            .map(|(_, entity)| entity)
107            .collect::<Vec<_>>();
108        let selected_entities = entities
109            .iter()
110            .map(|entity| entity.def().path())
111            .collect::<BTreeSet<_>>();
112        for entity in &entities {
113            ensure_relation_targets_in_database(self, entity, &selected_entities)?;
114        }
115
116        let mut pending_types = Vec::new();
117        let entity_fragments = entities
118            .iter()
119            .map(|entity| lower_entity(self, entity, &mut pending_types))
120            .collect::<Result<Vec<_>, _>>()?;
121        let types = lower_reachable_types(self, pending_types)?;
122
123        SchemaFragment::try_new(entity_fragments, types).map_err(Into::into)
124    }
125}
126
127fn ensure_relation_targets_in_database(
128    schema: &Schema,
129    entity: &Entity,
130    selected_entities: &BTreeSet<String>,
131) -> Result<(), FragmentLoweringError> {
132    for target in entity
133        .fields()
134        .fields()
135        .iter()
136        .filter_map(|field| field.value().item().relation())
137        .chain(entity.relations().iter().map(RelationEdge::target))
138    {
139        schema
140            .cast_node::<Entity>(target)
141            .map_err(|_| FragmentLoweringError::InvalidReference(target.to_string()))?;
142        if !selected_entities.contains(target) {
143            return Err(FragmentLoweringError::InvalidReference(format!(
144                "relation target '{target}' is outside the selected database"
145            )));
146        }
147    }
148    Ok(())
149}
150
151fn lower_entity(
152    schema: &Schema,
153    entity: &Entity,
154    pending_types: &mut Vec<String>,
155) -> Result<EntityFragment, FragmentLoweringError> {
156    let fields = entity
157        .fields()
158        .fields()
159        .iter()
160        .map(|field| lower_entity_field(schema, field, pending_types))
161        .collect::<Result<Vec<_>, _>>()?;
162    let primary_key = entity
163        .primary_key()
164        .fields()
165        .iter()
166        .map(|name| entity_field_source_key(entity, name))
167        .collect::<Result<Vec<_>, _>>()?;
168    let indexes = entity
169        .indexes()
170        .iter()
171        .map(|index| lower_index(schema, entity, index))
172        .collect::<Result<Vec<_>, _>>()?;
173    let mut relations = entity
174        .fields()
175        .fields()
176        .iter()
177        .filter(|field| field.value().item().relation().is_some())
178        .map(|field| lower_scalar_relation(schema, entity, field))
179        .collect::<Result<Vec<_>, _>>()?;
180    relations.extend(
181        entity
182            .relations()
183            .iter()
184            .map(|relation| lower_composite_relation(schema, entity, relation))
185            .collect::<Result<Vec<_>, _>>()?,
186    );
187    let mut constraints = entity
188        .constraints()
189        .iter()
190        .map(|constraint| lower_constraint(schema, constraint))
191        .collect::<Result<Vec<_>, _>>()?;
192    for field in entity.fields().fields() {
193        constraints.extend(lower_field_rules(schema, field)?);
194    }
195
196    EntityFragment::try_new(
197        EntitySourceKey::try_new(entity.source_key())?,
198        SchemaName::try_new(entity.resolved_name())?,
199        fields,
200        primary_key,
201        indexes,
202        relations,
203        constraints,
204    )
205    .map_err(Into::into)
206}
207
208fn lower_entity_field(
209    schema: &Schema,
210    field: &Field,
211    pending_types: &mut Vec<String>,
212) -> Result<FieldFragment, FragmentLoweringError> {
213    let field_type = lower_value_type(schema, field.value(), pending_types)?;
214    let nullable = field.value().cardinality() == Cardinality::Opt;
215    let insert_policy = if field.generated().is_some() {
216        FieldInsertPolicy::Generated
217    } else if let Some(default) = field.default() {
218        FieldInsertPolicy::Default(lower_default(schema, field, default)?)
219    } else if nullable {
220        FieldInsertPolicy::Nullable
221    } else {
222        FieldInsertPolicy::Required
223    };
224    let management = match field.write_management() {
225        Some(FieldWriteManagement::CreatedAt) => Some(FieldManagementPolicy::CreatedAt),
226        Some(FieldWriteManagement::UpdatedAt) => Some(FieldManagementPolicy::UpdatedAt),
227        None => None,
228    };
229    Ok(FieldFragment::new(
230        FieldSourceKey::try_new(field.source_key())?,
231        SchemaName::try_new(field.ident())?,
232        field_type,
233        nullable,
234        insert_policy,
235        management,
236    ))
237}
238
239fn lower_index(
240    schema: &Schema,
241    entity: &Entity,
242    index: &Index,
243) -> Result<IndexFragment, FragmentLoweringError> {
244    let key = match index.key_items() {
245        IndexKeyItemsRef::Fields(fields) => fields
246            .iter()
247            .map(|field| entity_field_source_key(entity, field).map(IndexKeyFragment::Field))
248            .collect::<Result<Vec<_>, _>>()?,
249        IndexKeyItemsRef::Items(items) => items
250            .iter()
251            .map(|item| lower_index_key(entity, item))
252            .collect::<Result<Vec<_>, _>>()?,
253    };
254    IndexFragment::try_new(
255        IndexSourceKey::try_new(index.source_key())?,
256        SchemaName::try_new(index.name())?,
257        key,
258        index.is_unique(),
259        index.source_predicate(schema)?,
260    )
261    .map_err(Into::into)
262}
263
264fn lower_index_key(
265    entity: &Entity,
266    item: &IndexKeyItem,
267) -> Result<IndexKeyFragment, FragmentLoweringError> {
268    let field = entity_field_source_key(entity, item.field())?;
269    Ok(match item {
270        IndexKeyItem::Field(_) => IndexKeyFragment::Field(field),
271        IndexKeyItem::Expression(IndexExpression::Lower(_)) => IndexKeyFragment::Lower(field),
272        IndexKeyItem::Expression(IndexExpression::Upper(_)) => IndexKeyFragment::Upper(field),
273        IndexKeyItem::Expression(IndexExpression::Trim(_)) => IndexKeyFragment::Trim(field),
274        IndexKeyItem::Expression(IndexExpression::LowerTrim(_)) => {
275            IndexKeyFragment::LowerTrim(field)
276        }
277        IndexKeyItem::Expression(IndexExpression::Date(_)) => IndexKeyFragment::Date(field),
278        IndexKeyItem::Expression(IndexExpression::Year(_)) => IndexKeyFragment::Year(field),
279        IndexKeyItem::Expression(IndexExpression::Month(_)) => IndexKeyFragment::Month(field),
280        IndexKeyItem::Expression(IndexExpression::Day(_)) => IndexKeyFragment::Day(field),
281    })
282}
283
284fn lower_scalar_relation(
285    schema: &Schema,
286    entity: &Entity,
287    field: &Field,
288) -> Result<RelationFragment, FragmentLoweringError> {
289    let target_path = field
290        .value()
291        .item()
292        .relation()
293        .ok_or_else(|| FragmentLoweringError::InvalidReference(field.ident().to_string()))?;
294    let target = schema
295        .cast_node::<Entity>(target_path)
296        .map_err(|_| FragmentLoweringError::InvalidReference(target_path.to_string()))?;
297    RelationFragment::try_new(
298        RelationSourceKey::try_new(field.source_key())?,
299        SchemaName::try_new(field.ident())?,
300        vec![entity_field_source_key(entity, field.ident())?],
301        EntitySourceKey::try_new(target.source_key())?,
302        target
303            .primary_key()
304            .fields()
305            .iter()
306            .map(|field| entity_field_source_key(target, field))
307            .collect::<Result<Vec<_>, _>>()?,
308        RelationDeleteAction::Restrict,
309    )
310    .map_err(Into::into)
311}
312
313fn lower_composite_relation(
314    schema: &Schema,
315    entity: &Entity,
316    relation: &RelationEdge,
317) -> Result<RelationFragment, FragmentLoweringError> {
318    let target = schema
319        .cast_node::<Entity>(relation.target())
320        .map_err(|_| FragmentLoweringError::InvalidReference(relation.target().to_string()))?;
321    RelationFragment::try_new(
322        RelationSourceKey::try_new(relation.source_key())?,
323        SchemaName::try_new(relation.ident())?,
324        relation
325            .local_fields()
326            .iter()
327            .map(|field| entity_field_source_key(entity, field))
328            .collect::<Result<Vec<_>, _>>()?,
329        EntitySourceKey::try_new(target.source_key())?,
330        target
331            .primary_key()
332            .fields()
333            .iter()
334            .map(|field| entity_field_source_key(target, field))
335            .collect::<Result<Vec<_>, _>>()?,
336        RelationDeleteAction::Restrict,
337    )
338    .map_err(Into::into)
339}
340
341fn lower_constraint(
342    schema: &Schema,
343    constraint: &CheckConstraint,
344) -> Result<ConstraintFragment, FragmentLoweringError> {
345    Ok(ConstraintFragment::new(
346        ConstraintSourceKey::try_new(constraint.source_key())?,
347        SchemaName::try_new(constraint.name())?,
348        constraint.source_expression(schema)?,
349    ))
350}
351
352fn lower_field_rules(
353    schema: &Schema,
354    field: &Field,
355) -> Result<Vec<ConstraintFragment>, FragmentLoweringError> {
356    if field.value().cardinality() == Cardinality::Many {
357        let rules = source_rules_for_item(schema, field.value().item())?;
358        if !rules.is_empty() {
359            return Err(FragmentLoweringError::InvalidReference(format!(
360                "durable rules require one scalar persisted field: {}",
361                field.ident()
362            )));
363        }
364        return Ok(Vec::new());
365    }
366
367    let field_source = FieldSourceKey::try_new(field.source_key())?;
368    source_rules_for_item(schema, field.value().item())?
369        .into_iter()
370        .map(|(rule, primitive, item)| {
371            let source = ConstraintSourceKey::for_field_rule(
372                &field_source,
373                &RuleSourceKey::try_new(rule.source_key())?,
374            );
375            let name =
376                SchemaName::try_new(format!("__icydb_{}", source.as_str().replace(':', "_")))?;
377            let expression = lower_source_rule(&field_source, primitive, item, rule)?;
378            Ok(ConstraintFragment::new(source, name, expression))
379        })
380        .collect()
381}
382
383fn source_rules_for_item<'schema>(
384    schema: &'schema Schema,
385    mut item: &'schema Item,
386) -> Result<Vec<(&'schema SourceRule, Primitive, &'schema Item)>, FragmentLoweringError> {
387    let mut pending_rules = Vec::new();
388    let mut visited = BTreeSet::new();
389    loop {
390        let ItemTarget::Is(path) = item.target() else {
391            return Ok(Vec::new());
392        };
393        let Some(node) = schema.get_node(path) else {
394            if !pending_rules.is_empty() {
395                return Err(FragmentLoweringError::InvalidReference(format!(
396                    "durable-rule target '{path}' is missing"
397                )));
398            }
399            return Ok(Vec::new());
400        };
401        let SchemaNode::Newtype(newtype) = node else {
402            let mut nested = BTreeSet::new();
403            if !pending_rules.is_empty()
404                || schema_node_contains_source_rules(schema, path, node, &mut nested)
405            {
406                return Err(FragmentLoweringError::InvalidReference(format!(
407                    "durable rules nested below structural field type '{path}' are unsupported"
408                )));
409            }
410            return Ok(Vec::new());
411        };
412        if !visited.insert(newtype.source_key()) {
413            return Err(FragmentLoweringError::InvalidReference(format!(
414                "durable-rule newtype cycle at {path}"
415            )));
416        }
417        pending_rules.extend(newtype.ty().rules());
418        let ItemTarget::Primitive(primitive) = newtype.item().target() else {
419            item = newtype.item();
420            continue;
421        };
422        return Ok(pending_rules
423            .into_iter()
424            .map(|rule| (rule, *primitive, newtype.item()))
425            .collect());
426    }
427}
428
429fn schema_node_contains_source_rules(
430    schema: &Schema,
431    path: &str,
432    node: &SchemaNode,
433    visited: &mut BTreeSet<String>,
434) -> bool {
435    if !visited.insert(path.to_string()) {
436        return false;
437    }
438
439    match node {
440        SchemaNode::Newtype(newtype) => {
441            !newtype.ty().rules().is_empty()
442                || item_contains_source_rules(schema, newtype.item(), visited)
443        }
444        SchemaNode::Record(record) => {
445            !record.ty().rules().is_empty()
446                || record
447                    .fields()
448                    .fields()
449                    .iter()
450                    .any(|field| item_contains_source_rules(schema, field.value().item(), visited))
451        }
452        SchemaNode::Enum(r#enum) => {
453            !r#enum.ty().rules().is_empty()
454                || r#enum.variants().iter().any(|variant| {
455                    variant.value().is_some_and(|value| {
456                        item_contains_source_rules(schema, value.item(), visited)
457                    })
458                })
459        }
460        SchemaNode::List(list) => {
461            !list.ty().rules().is_empty()
462                || item_contains_source_rules(schema, list.item(), visited)
463        }
464        SchemaNode::Map(map) => {
465            !map.ty().rules().is_empty()
466                || item_contains_source_rules(schema, map.key(), visited)
467                || item_contains_source_rules(schema, map.value().item(), visited)
468        }
469        SchemaNode::Set(set) => {
470            !set.ty().rules().is_empty() || item_contains_source_rules(schema, set.item(), visited)
471        }
472        SchemaNode::Tuple(tuple) => {
473            !tuple.ty().rules().is_empty()
474                || tuple
475                    .values()
476                    .iter()
477                    .any(|value| item_contains_source_rules(schema, value.item(), visited))
478        }
479        SchemaNode::Canister(_)
480        | SchemaNode::Entity(_)
481        | SchemaNode::Normalizer(_)
482        | SchemaNode::Store(_)
483        | SchemaNode::Validator(_) => false,
484    }
485}
486
487fn item_contains_source_rules(
488    schema: &Schema,
489    item: &Item,
490    visited: &mut BTreeSet<String>,
491) -> bool {
492    let ItemTarget::Is(path) = item.target() else {
493        return false;
494    };
495    schema
496        .get_node(path)
497        .is_some_and(|node| schema_node_contains_source_rules(schema, path, node, visited))
498}
499
500fn lower_source_rule(
501    field: &FieldSourceKey,
502    primitive: Primitive,
503    item: &Item,
504    rule: &SourceRule,
505) -> Result<SourceCheckExpr, FragmentLoweringError> {
506    let args = rule.args().0;
507    let literal = |index: usize| {
508        args.get(index)
509            .and_then(|arg| lower_scalar_default(primitive, item, arg))
510            .ok_or_else(|| {
511                FragmentLoweringError::InvalidReference(format!(
512                    "rule '{}' has an invalid operand",
513                    rule.source_key()
514                ))
515            })
516    };
517    let length_bound = |index: usize| {
518        args.get(index)
519            .and_then(|arg| match arg {
520                Arg::Number(value) => arg_u128(value),
521                _ => None,
522            })
523            .map(ScalarLiteral::Nat)
524            .ok_or_else(|| {
525                FragmentLoweringError::InvalidReference(format!(
526                    "rule '{}' has an invalid length bound",
527                    rule.source_key()
528                ))
529            })
530    };
531
532    let instructions = match rule.kind() {
533        SourceRuleKind::NumericMinimum => vec![
534            SourceCheckInstruction::Field(field.clone()),
535            SourceCheckInstruction::Literal(literal(0)?),
536            SourceCheckInstruction::GreaterThanOrEqual,
537        ],
538        SourceRuleKind::NumericRange => vec![
539            SourceCheckInstruction::Field(field.clone()),
540            SourceCheckInstruction::Literal(literal(0)?),
541            SourceCheckInstruction::GreaterThanOrEqual,
542            SourceCheckInstruction::Field(field.clone()),
543            SourceCheckInstruction::Literal(literal(1)?),
544            SourceCheckInstruction::LessThanOrEqual,
545            SourceCheckInstruction::And,
546        ],
547        SourceRuleKind::LengthRange => vec![
548            SourceCheckInstruction::Field(field.clone()),
549            SourceCheckInstruction::Length,
550            SourceCheckInstruction::Literal(length_bound(0)?),
551            SourceCheckInstruction::GreaterThanOrEqual,
552            SourceCheckInstruction::Field(field.clone()),
553            SourceCheckInstruction::Length,
554            SourceCheckInstruction::Literal(length_bound(1)?),
555            SourceCheckInstruction::LessThanOrEqual,
556            SourceCheckInstruction::And,
557        ],
558    };
559    SourceCheckExpr::try_new(instructions).map_err(Into::into)
560}
561
562fn entity_field_source_key(
563    entity: &Entity,
564    field_name: &str,
565) -> Result<FieldSourceKey, FragmentLoweringError> {
566    let field = entity
567        .fields()
568        .get(field_name)
569        .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
570    FieldSourceKey::try_new(field.source_key()).map_err(Into::into)
571}
572
573// -----------------------------------------------------------------------------
574// Reachable named-type closure
575// -----------------------------------------------------------------------------
576
577fn lower_reachable_types(
578    schema: &Schema,
579    mut pending: Vec<String>,
580) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
581    let mut lowered = BTreeMap::new();
582    while let Some(path) = pending.pop() {
583        let node = schema
584            .get_node(path.as_str())
585            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
586        let source_key = named_type_source_key(node)
587            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
588        if lowered.contains_key(source_key) {
589            continue;
590        }
591        let fragment = lower_named_type(schema, node, &mut pending)?;
592        lowered.insert(source_key.to_string(), fragment);
593    }
594    Ok(lowered.into_values().collect())
595}
596
597const fn named_type_source_key(node: &crate::node::SchemaNode) -> Option<&str> {
598    match node {
599        crate::node::SchemaNode::Enum(node) => Some(node.source_key()),
600        crate::node::SchemaNode::List(node) => Some(node.source_key()),
601        crate::node::SchemaNode::Map(node) => Some(node.source_key()),
602        crate::node::SchemaNode::Newtype(node) => Some(node.source_key()),
603        crate::node::SchemaNode::Record(node) => Some(node.source_key()),
604        crate::node::SchemaNode::Set(node) => Some(node.source_key()),
605        crate::node::SchemaNode::Tuple(node) => Some(node.source_key()),
606        crate::node::SchemaNode::Canister(_)
607        | crate::node::SchemaNode::Entity(_)
608        | crate::node::SchemaNode::Normalizer(_)
609        | crate::node::SchemaNode::Store(_)
610        | crate::node::SchemaNode::Validator(_) => None,
611    }
612}
613
614fn lower_named_type(
615    schema: &Schema,
616    node: &crate::node::SchemaNode,
617    pending: &mut Vec<String>,
618) -> Result<NamedTypeFragment, FragmentLoweringError> {
619    match node {
620        crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
621        crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
622        crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::Newtype {
623            source_key: TypeSourceKey::try_new(newtype.source_key())?,
624            name: SchemaName::try_new(newtype.def().ident())?,
625            inner: lower_item_type(schema, newtype.item(), pending)?,
626        }),
627        crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
628        crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
629        crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
630        crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
631        crate::node::SchemaNode::Canister(_)
632        | crate::node::SchemaNode::Entity(_)
633        | crate::node::SchemaNode::Normalizer(_)
634        | crate::node::SchemaNode::Store(_)
635        | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
636            "non-type graph node".to_string(),
637        )),
638    }
639}
640
641fn lower_record(
642    schema: &Schema,
643    record: &Record,
644    pending: &mut Vec<String>,
645) -> Result<NamedTypeFragment, FragmentLoweringError> {
646    let fields = record
647        .fields()
648        .fields()
649        .iter()
650        .map(|field| {
651            Ok(RecordFieldFragment::new(
652                FieldSourceKey::try_new(field.source_key())?,
653                SchemaName::try_new(field.ident())?,
654                lower_value_type(schema, field.value(), pending)?,
655                field.value().cardinality() == Cardinality::Opt,
656            ))
657        })
658        .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
659    Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
660        TypeSourceKey::try_new(record.source_key())?,
661        SchemaName::try_new(record.def().ident())?,
662        fields,
663    )?))
664}
665
666fn lower_enum(
667    schema: &Schema,
668    r#enum: &Enum,
669    pending: &mut Vec<String>,
670) -> Result<NamedTypeFragment, FragmentLoweringError> {
671    let variants = r#enum
672        .variants()
673        .iter()
674        .map(|variant| {
675            let source = TypeSourceKey::try_new(variant.source_key())?;
676            let name = SchemaName::try_new(variant.ident())?;
677            match variant.value() {
678                Some(value) if value.cardinality() == Cardinality::Opt => {
679                    Err(FragmentLoweringError::UnsupportedCardinality(format!(
680                        "{}::{}",
681                        r#enum.def().path(),
682                        variant.ident()
683                    )))
684                }
685                Some(value) => Ok(EnumVariantFragment::with_payload(
686                    source,
687                    name,
688                    lower_value_type(schema, value, pending)?,
689                )),
690                None => Ok(EnumVariantFragment::new(source, name)),
691            }
692        })
693        .collect::<Result<Vec<_>, _>>()?;
694    Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
695        TypeSourceKey::try_new(r#enum.source_key())?,
696        SchemaName::try_new(r#enum.def().ident())?,
697        variants,
698    )?))
699}
700
701fn lower_list(
702    schema: &Schema,
703    list: &List,
704    pending: &mut Vec<String>,
705) -> Result<NamedTypeFragment, FragmentLoweringError> {
706    Ok(NamedTypeFragment::List {
707        source_key: TypeSourceKey::try_new(list.source_key())?,
708        name: SchemaName::try_new(list.def().ident())?,
709        item: lower_item_type(schema, list.item(), pending)?,
710    })
711}
712
713fn lower_set(
714    schema: &Schema,
715    set: &Set,
716    pending: &mut Vec<String>,
717) -> Result<NamedTypeFragment, FragmentLoweringError> {
718    Ok(NamedTypeFragment::Set {
719        source_key: TypeSourceKey::try_new(set.source_key())?,
720        name: SchemaName::try_new(set.def().ident())?,
721        item: lower_item_type(schema, set.item(), pending)?,
722    })
723}
724
725fn lower_map(
726    schema: &Schema,
727    map: &Map,
728    pending: &mut Vec<String>,
729) -> Result<NamedTypeFragment, FragmentLoweringError> {
730    if map.value().cardinality() == Cardinality::Opt {
731        return Err(FragmentLoweringError::UnsupportedCardinality(
732            map.def().path(),
733        ));
734    }
735    Ok(NamedTypeFragment::Map {
736        source_key: TypeSourceKey::try_new(map.source_key())?,
737        name: SchemaName::try_new(map.def().ident())?,
738        key: lower_item_type(schema, map.key(), pending)?,
739        value: lower_value_type(schema, map.value(), pending)?,
740    })
741}
742
743fn lower_tuple(
744    schema: &Schema,
745    tuple: &Tuple,
746    pending: &mut Vec<String>,
747) -> Result<NamedTypeFragment, FragmentLoweringError> {
748    let members = tuple
749        .values()
750        .iter()
751        .map(|value| {
752            Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
753                lower_value_type(schema, value, pending)?,
754                value.cardinality() == Cardinality::Opt,
755            ))
756        })
757        .collect::<Result<Vec<_>, _>>()?;
758    Ok(NamedTypeFragment::Tuple {
759        source_key: TypeSourceKey::try_new(tuple.source_key())?,
760        name: SchemaName::try_new(tuple.def().ident())?,
761        members,
762    })
763}
764
765// -----------------------------------------------------------------------------
766// Exact field contracts
767// -----------------------------------------------------------------------------
768
769fn lower_value_type(
770    schema: &Schema,
771    value: &Value,
772    pending: &mut Vec<String>,
773) -> Result<FieldType, FragmentLoweringError> {
774    let item = lower_item_type(schema, value.item(), pending)?;
775    Ok(if value.cardinality() == Cardinality::Many {
776        FieldType::List(Box::new(item))
777    } else {
778        item
779    })
780}
781
782fn lower_item_type(
783    schema: &Schema,
784    item: &Item,
785    pending: &mut Vec<String>,
786) -> Result<FieldType, FragmentLoweringError> {
787    match item.target() {
788        ItemTarget::Is(path) => {
789            pending.push((*path).to_string());
790            Ok(FieldType::Named(TypeSourceKey::try_new(
791                type_source_key_for_path(schema, path)?,
792            )?))
793        }
794        ItemTarget::Primitive(primitive) => {
795            Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
796        }
797    }
798}
799
800fn type_source_key_for_path<'schema>(
801    schema: &'schema Schema,
802    path: &str,
803) -> Result<&'schema str, FragmentLoweringError> {
804    let source = schema
805        .get_node(path)
806        .and_then(named_type_source_key)
807        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
808    Ok(source)
809}
810
811fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
812    match primitive {
813        Primitive::Account => ScalarType::Account,
814        Primitive::Blob => ScalarType::Blob {
815            max_len: item.max_len(),
816        },
817        Primitive::Bool => ScalarType::Bool,
818        Primitive::Date => ScalarType::Date,
819        Primitive::Decimal => ScalarType::Decimal {
820            scale: item.scale().unwrap_or(0),
821        },
822        Primitive::Duration => ScalarType::Duration,
823        Primitive::Float32 => ScalarType::Float32,
824        Primitive::Float64 => ScalarType::Float64,
825        Primitive::Int8 => ScalarType::Int8,
826        Primitive::Int16 => ScalarType::Int16,
827        Primitive::Int32 => ScalarType::Int32,
828        Primitive::Int64 => ScalarType::Int64,
829        Primitive::Int128 => ScalarType::Int128,
830        Primitive::IntBig => ScalarType::IntBig {
831            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
832        },
833        Primitive::Nat8 => ScalarType::Nat8,
834        Primitive::Nat16 => ScalarType::Nat16,
835        Primitive::Nat32 => ScalarType::Nat32,
836        Primitive::Nat64 => ScalarType::Nat64,
837        Primitive::Nat128 => ScalarType::Nat128,
838        Primitive::NatBig => ScalarType::NatBig {
839            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
840        },
841        Primitive::Principal => ScalarType::Principal,
842        Primitive::Subaccount => ScalarType::Subaccount,
843        Primitive::Text => ScalarType::Text {
844            max_len: item.max_len(),
845        },
846        Primitive::Timestamp => ScalarType::Timestamp,
847        Primitive::Ulid => ScalarType::Ulid,
848        Primitive::Unit => ScalarType::Unit,
849    }
850}
851
852// -----------------------------------------------------------------------------
853// Authored database defaults
854// -----------------------------------------------------------------------------
855
856fn lower_default(
857    schema: &Schema,
858    field: &Field,
859    default: &Arg,
860) -> Result<ScalarLiteral, FragmentLoweringError> {
861    if let ItemTarget::Is(path) = field.value().item().target() {
862        let Arg::ConstPath(default_path) = default else {
863            return Err(FragmentLoweringError::InvalidDefault(
864                field.ident().to_string(),
865            ));
866        };
867        let variant = default_path.rsplit("::").next().unwrap_or(default_path);
868        return schema
869            .enum_unit_literal(path, variant)
870            .map_err(FragmentLoweringError::from);
871    }
872    let ItemTarget::Primitive(primitive) = field.value().item().target() else {
873        return Err(FragmentLoweringError::InvalidDefault(
874            field.ident().to_string(),
875        ));
876    };
877    lower_scalar_default(*primitive, field.value().item(), default)
878        .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.ident().to_string()))
879}
880
881fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
882    if default_constructor_is_zero(default) {
883        return zero_scalar_literal(primitive, item);
884    }
885    match (primitive, default) {
886        (Primitive::Account, Arg::String(value)) => {
887            Account::from_str(value).ok().map(ScalarLiteral::Account)
888        }
889        (Primitive::Blob, Arg::String(value)) => Blob::try_new(value.as_bytes().to_vec())
890            .ok()
891            .map(ScalarLiteral::Blob),
892        (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
893        (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
894        (Primitive::Date, Arg::Number(value)) => arg_i128(value)
895            .and_then(|value| i32::try_from(value).ok())
896            .map(Date::from_days_since_epoch)
897            .map(ScalarLiteral::Date),
898        (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
899            .ok()
900            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
901            .map(ScalarLiteral::Decimal),
902        (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
903            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
904            .map(ScalarLiteral::Decimal),
905        (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
906            .ok()
907            .map(ScalarLiteral::Duration),
908        (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
909            .and_then(|value| u64::try_from(value).ok())
910            .map(Duration::from_millis)
911            .map(ScalarLiteral::Duration),
912        (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
913            Float32::try_new(*value).map(ScalarLiteral::Float32)
914        }
915        (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
916            Float64::try_new(*value).map(ScalarLiteral::Float64)
917        }
918        (
919            Primitive::Int8
920            | Primitive::Int16
921            | Primitive::Int32
922            | Primitive::Int64
923            | Primitive::Int128,
924            Arg::Number(value),
925        ) => arg_i128(value).map(ScalarLiteral::Int),
926        (Primitive::IntBig, Arg::Number(value)) => arg_i128(value)
927            .map(|value| value.to_string())
928            .and_then(|value| IntBig::from_str(value.as_str()).ok())
929            .map(ScalarLiteral::IntBig),
930        (Primitive::IntBig, Arg::String(value)) => {
931            IntBig::from_str(value).ok().map(ScalarLiteral::IntBig)
932        }
933        (
934            Primitive::Nat8
935            | Primitive::Nat16
936            | Primitive::Nat32
937            | Primitive::Nat64
938            | Primitive::Nat128,
939            Arg::Number(value),
940        ) => arg_u128(value).map(ScalarLiteral::Nat),
941        (Primitive::NatBig, Arg::Number(value)) => arg_u128(value)
942            .map(|value| value.to_string())
943            .and_then(|value| NatBig::from_str(value.as_str()).ok())
944            .map(ScalarLiteral::NatBig),
945        (Primitive::NatBig, Arg::String(value)) => {
946            NatBig::from_str(value).ok().map(ScalarLiteral::NatBig)
947        }
948        (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
949            .ok()
950            .map(ScalarLiteral::Principal),
951        (Primitive::Subaccount, Arg::String(value)) => parse_subaccount(value)
952            .map(Subaccount::from_array)
953            .map(ScalarLiteral::Subaccount),
954        (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
955        (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
956            .ok()
957            .map(ScalarLiteral::Timestamp),
958        (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
959            .and_then(|value| i64::try_from(value).ok())
960            .map(Timestamp::from_millis)
961            .map(ScalarLiteral::Timestamp),
962        (Primitive::Ulid, Arg::String(value)) => {
963            Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
964        }
965        (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
966            Some(ScalarLiteral::Unit(Unit))
967        }
968        _ => None,
969    }
970}
971
972fn default_constructor_is_zero(default: &Arg) -> bool {
973    let Arg::FuncPath(path) = default else {
974        return false;
975    };
976    path.ends_with("::default")
977        || path.ends_with("::new")
978        || path.ends_with("::EPOCH")
979        || path.ends_with("::nil")
980}
981
982fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
983    match primitive {
984        Primitive::Blob => Blob::try_new(Vec::new()).ok().map(ScalarLiteral::Blob),
985        Primitive::Bool => Some(ScalarLiteral::Bool(false)),
986        Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
987        Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
988            .map(ScalarLiteral::Decimal),
989        Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
990        Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
991        Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
992        Primitive::Int8
993        | Primitive::Int16
994        | Primitive::Int32
995        | Primitive::Int64
996        | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
997        Primitive::IntBig => IntBig::from_str("0").ok().map(ScalarLiteral::IntBig),
998        Primitive::Nat8
999        | Primitive::Nat16
1000        | Primitive::Nat32
1001        | Primitive::Nat64
1002        | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1003        Primitive::NatBig => NatBig::from_str("0").ok().map(ScalarLiteral::NatBig),
1004        Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1005        Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1006        Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1007        Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1008        Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1009    }
1010}
1011
1012// -----------------------------------------------------------------------------
1013// Literal conversion helpers
1014// -----------------------------------------------------------------------------
1015
1016fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1017    match value.scale().cmp(&scale) {
1018        std::cmp::Ordering::Equal => Some(value),
1019        std::cmp::Ordering::Less => value
1020            .scale_to_integer(scale)
1021            .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1022        std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1023    }
1024}
1025
1026fn arg_i128(value: &ArgNumber) -> Option<i128> {
1027    match value {
1028        ArgNumber::Int8(value) => Some(i128::from(*value)),
1029        ArgNumber::Int16(value) => Some(i128::from(*value)),
1030        ArgNumber::Int32(value) => Some(i128::from(*value)),
1031        ArgNumber::Int64(value) => Some(i128::from(*value)),
1032        ArgNumber::Int128(value) => Some(*value),
1033        ArgNumber::Nat8(value) => Some(i128::from(*value)),
1034        ArgNumber::Nat16(value) => Some(i128::from(*value)),
1035        ArgNumber::Nat32(value) => Some(i128::from(*value)),
1036        ArgNumber::Nat64(value) => Some(i128::from(*value)),
1037        ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1038        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1039    }
1040}
1041
1042fn arg_u128(value: &ArgNumber) -> Option<u128> {
1043    match value {
1044        ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1045        ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1046        ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1047        ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1048        ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1049        ArgNumber::Nat8(value) => Some(u128::from(*value)),
1050        ArgNumber::Nat16(value) => Some(u128::from(*value)),
1051        ArgNumber::Nat32(value) => Some(u128::from(*value)),
1052        ArgNumber::Nat64(value) => Some(u128::from(*value)),
1053        ArgNumber::Nat128(value) => Some(*value),
1054        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1055    }
1056}
1057
1058fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1059    match value {
1060        ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1061        ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1062        _ => arg_i128(value).and_then(Decimal::from_i128),
1063    }
1064}
1065
1066fn parse_subaccount(value: &str) -> Option<[u8; 32]> {
1067    if value.len() != 64 {
1068        return None;
1069    }
1070    let mut bytes = [0; 32];
1071    for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
1072        let text = std::str::from_utf8(chunk).ok()?;
1073        bytes[index] = u8::from_str_radix(text, 16).ok()?;
1074    }
1075    Some(bytes)
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use icydb_schema::{
1081        ConstraintSourceKey, FieldSourceKey, FieldType, NamedTypeFragment, RuleSourceKey,
1082        ScalarType, SourceCheckInstruction,
1083    };
1084
1085    use super::{FragmentLoweringError, Schema, source_rules_for_item};
1086    use crate::{
1087        node::{
1088            Arg, ArgNumber, Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item,
1089            ItemTarget, Newtype, PrimaryKey, PrimaryKeySource, Record, SchemaNode, SourceRule,
1090            SourceRuleKind, Store, StoreHeapConfig, Type, Value,
1091        },
1092        types::{Cardinality, Primitive},
1093    };
1094
1095    static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1096    static NUMERIC_RULE_ARGS: [Arg; 2] = [
1097        Arg::Number(ArgNumber::Int32(0)),
1098        Arg::Number(ArgNumber::Int32(360)),
1099    ];
1100    static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1101        "rule/degrees/range",
1102        SourceRuleKind::NumericRange,
1103        Args(&NUMERIC_RULE_ARGS),
1104    )];
1105    static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1106    static LENGTH_RULE_ARGS: [Arg; 2] = [
1107        Arg::Number(ArgNumber::Int32(2)),
1108        Arg::Number(ArgNumber::Int32(40)),
1109    ];
1110    static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1111        "rule/label/length",
1112        SourceRuleKind::LengthRange,
1113        Args(&LENGTH_RULE_ARGS),
1114    )];
1115    static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1116    static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1117        "field/nested/degrees",
1118        "degrees",
1119        Value::new(
1120            Cardinality::One,
1121            Item::new(
1122                ItemTarget::Is("test::Degrees"),
1123                None,
1124                None,
1125                None,
1126                None,
1127                &[],
1128                &[],
1129                false,
1130            ),
1131        ),
1132        None,
1133        None,
1134        None,
1135    )];
1136    static STATUS_VARIANTS: [EnumVariant; 2] = [
1137        EnumVariant::new("variant/status/active", "Active", None),
1138        EnumVariant::new(
1139            "variant/status/retries",
1140            "Retries",
1141            Some(Value::new(
1142                Cardinality::Many,
1143                Item::new(
1144                    ItemTarget::Primitive(Primitive::Nat16),
1145                    None,
1146                    None,
1147                    None,
1148                    None,
1149                    &[],
1150                    &[],
1151                    false,
1152                ),
1153            )),
1154        ),
1155    ];
1156
1157    #[test]
1158    fn durable_rules_nested_below_structural_fields_fail_closed() {
1159        let mut schema = Schema::new();
1160        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1161            Def::new("test", "Degrees"),
1162            "type/degrees",
1163            Item::new(
1164                ItemTarget::Primitive(Primitive::Nat16),
1165                None,
1166                None,
1167                None,
1168                None,
1169                &[],
1170                &[],
1171                false,
1172            ),
1173            None,
1174            NUMERIC_RULE_TYPE.clone(),
1175        )));
1176        schema.insert_node(SchemaNode::Record(Record::new(
1177            Def::new("test", "Nested"),
1178            "type/nested",
1179            FieldList::new(&NESTED_RULE_FIELDS),
1180            EMPTY_TYPE.clone(),
1181        )));
1182
1183        let outer = Item::new(
1184            ItemTarget::Is("test::Nested"),
1185            None,
1186            None,
1187            None,
1188            None,
1189            &[],
1190            &[],
1191            false,
1192        );
1193        let error = source_rules_for_item(&schema, &outer)
1194            .expect_err("nested durable rule must not disappear during fragment lowering");
1195        assert!(
1196            matches!(error, FragmentLoweringError::InvalidReference(message) if message.contains("nested below structural field type"))
1197        );
1198    }
1199
1200    static ENTITY_FIELDS: [Field; 5] = [
1201        Field::new(
1202            "field/task/id",
1203            "id",
1204            Value::new(
1205                Cardinality::One,
1206                Item::new(
1207                    ItemTarget::Primitive(Primitive::Nat64),
1208                    None,
1209                    None,
1210                    None,
1211                    None,
1212                    &[],
1213                    &[],
1214                    false,
1215                ),
1216            ),
1217            None,
1218            None,
1219            None,
1220        ),
1221        Field::new(
1222            "field/task/tags",
1223            "tags",
1224            Value::new(
1225                Cardinality::Many,
1226                Item::new(
1227                    ItemTarget::Primitive(Primitive::Text),
1228                    None,
1229                    None,
1230                    Some(32),
1231                    None,
1232                    &[],
1233                    &[],
1234                    false,
1235                ),
1236            ),
1237            None,
1238            None,
1239            None,
1240        ),
1241        Field::new(
1242            "field/task/status",
1243            "status",
1244            Value::new(
1245                Cardinality::One,
1246                Item::new(
1247                    ItemTarget::Is("test::Status"),
1248                    None,
1249                    None,
1250                    None,
1251                    None,
1252                    &[],
1253                    &[],
1254                    false,
1255                ),
1256            ),
1257            Some(crate::node::Arg::ConstPath("test::Status::Active")),
1258            None,
1259            None,
1260        ),
1261        Field::new(
1262            "field/task/degrees",
1263            "degrees",
1264            Value::new(
1265                Cardinality::One,
1266                Item::new(
1267                    ItemTarget::Is("test::Degrees"),
1268                    None,
1269                    None,
1270                    None,
1271                    None,
1272                    &[],
1273                    &[],
1274                    false,
1275                ),
1276            ),
1277            None,
1278            None,
1279            None,
1280        ),
1281        Field::new(
1282            "field/task/label",
1283            "label",
1284            Value::new(
1285                Cardinality::One,
1286                Item::new(
1287                    ItemTarget::Is("test::Label"),
1288                    None,
1289                    None,
1290                    None,
1291                    None,
1292                    &[],
1293                    &[],
1294                    false,
1295                ),
1296            ),
1297            None,
1298            None,
1299            None,
1300        ),
1301    ];
1302
1303    #[test]
1304    #[expect(
1305        clippy::too_many_lines,
1306        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
1307    )]
1308    fn sealed_canister_graph_emits_store_free_database_closure() {
1309        let mut schema = Schema::new();
1310        schema.insert_node(SchemaNode::Canister(Canister::new(
1311            Def::new("test", "Canister"),
1312            "test",
1313            0,
1314            10,
1315            9,
1316            8,
1317        )));
1318        schema.insert_node(SchemaNode::Store(Store::new_heap(
1319            Def::new("test", "Store"),
1320            "Store",
1321            "store",
1322            "test::Canister",
1323            StoreHeapConfig::new(),
1324        )));
1325        schema.insert_node(SchemaNode::Enum(Enum::new(
1326            Def::new("test", "Status"),
1327            "type/status",
1328            &STATUS_VARIANTS,
1329            EMPTY_TYPE.clone(),
1330        )));
1331        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1332            Def::new("test", "Degrees"),
1333            "type/degrees",
1334            Item::new(
1335                ItemTarget::Primitive(Primitive::Nat16),
1336                None,
1337                None,
1338                None,
1339                None,
1340                &[],
1341                &[],
1342                false,
1343            ),
1344            None,
1345            NUMERIC_RULE_TYPE.clone(),
1346        )));
1347        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1348            Def::new("test", "Label"),
1349            "type/label",
1350            Item::new(
1351                ItemTarget::Primitive(Primitive::Text),
1352                None,
1353                None,
1354                None,
1355                None,
1356                &[],
1357                &[],
1358                false,
1359            ),
1360            None,
1361            LENGTH_RULE_TYPE.clone(),
1362        )));
1363        schema.insert_node(SchemaNode::Entity(Entity::new(
1364            Def::new("test", "Task"),
1365            "entity/task",
1366            "test::Store",
1367            1,
1368            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1369            None,
1370            &[],
1371            &[],
1372            &[],
1373            FieldList::new(&ENTITY_FIELDS),
1374            EMPTY_TYPE.clone(),
1375        )));
1376        schema.seal().expect("fixture graph should seal");
1377
1378        let fragment = schema
1379            .schema_fragment_for_canister("test::Canister")
1380            .expect("sealed database closure should lower");
1381
1382        assert_eq!(fragment.entities().len(), 1);
1383        assert_eq!(fragment.types().len(), 3);
1384        let fields = fragment.entities()[0].fields();
1385        assert!(matches!(
1386            fields
1387                .iter()
1388                .find(|field| field.name().as_str() == "tags")
1389                .map(icydb_schema::FieldFragment::field_type),
1390            Some(FieldType::List(item))
1391                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
1392        ));
1393        assert!(matches!(
1394            fields
1395                .iter()
1396                .find(|field| field.name().as_str() == "status")
1397                .map(icydb_schema::FieldFragment::insert_policy),
1398            Some(icydb_schema::FieldInsertPolicy::Default(
1399                icydb_schema::ScalarLiteral::EnumUnit { .. }
1400            ))
1401        ));
1402        let NamedTypeFragment::Enum(status) = fragment
1403            .types()
1404            .iter()
1405            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
1406            .expect("reachable status type should remain an enum")
1407        else {
1408            panic!("reachable status type should remain an enum")
1409        };
1410        assert!(matches!(
1411            status
1412                .variants()
1413                .iter()
1414                .find(|variant| variant.name().as_str() == "Retries")
1415                .and_then(|variant| variant.payload()),
1416            Some(FieldType::List(item))
1417                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
1418        ));
1419
1420        let constraints = fragment.entities()[0].constraints();
1421        assert_eq!(constraints.len(), 2);
1422        let degrees_source = ConstraintSourceKey::for_field_rule(
1423            &FieldSourceKey::try_new("field/task/degrees").expect("field source"),
1424            &RuleSourceKey::try_new("rule/degrees/range").expect("rule source"),
1425        );
1426        let degrees = constraints
1427            .iter()
1428            .find(|constraint| constraint.source_key() == &degrees_source)
1429            .expect("numeric rule should become one field-owned constraint");
1430        assert!(matches!(
1431            degrees.expression().instructions(),
1432            [
1433                SourceCheckInstruction::Field(_),
1434                SourceCheckInstruction::Literal(_),
1435                SourceCheckInstruction::GreaterThanOrEqual,
1436                SourceCheckInstruction::Field(_),
1437                SourceCheckInstruction::Literal(_),
1438                SourceCheckInstruction::LessThanOrEqual,
1439                SourceCheckInstruction::And,
1440            ]
1441        ));
1442        let label = constraints
1443            .iter()
1444            .find(|constraint| constraint.source_key() != &degrees_source)
1445            .expect("length rule should become one field-owned constraint");
1446        assert_eq!(
1447            label
1448                .expression()
1449                .instructions()
1450                .iter()
1451                .filter(|instruction| matches!(instruction, SourceCheckInstruction::Length))
1452                .count(),
1453            2,
1454        );
1455    }
1456}