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, Date, Decimal, DeclaredEntityVersion, Duration,
16    EntityFragment, EntitySourceKey, EnumTypeFragment, EnumVariantFragment, FieldFragment,
17    FieldInsertPolicy, FieldManagementPolicy, FieldSourceKey, FieldType, Float32, Float64,
18    IndexFragment, IndexKeyFragment, MAX_PROPOSAL_LITERAL_BYTES, NamedTypeFragment, Principal,
19    RecordFieldFragment, RecordTypeFragment, RelationDeleteAction, RelationFragment,
20    RelationPathStepFragment, RelationSourceFragment, RuleSourceKey, ScalarLiteral, ScalarType,
21    SchemaContractError, SchemaFragment, SchemaName,
22    SourceRuleOperation as ProposalSourceRuleOperation, Subaccount, TargetedRuleFragment,
23    Timestamp, TupleElementFragment, TypeSourceKey, U256, Ulid, Unit,
24};
25use thiserror::Error;
26
27use crate::{
28    node::{
29        Arg, ArgNumber, Canister, CheckConstraint, Entity, Enum, Field, FieldWriteManagement,
30        Index, IndexExpression, IndexKeyItem, IndexKeyItemsRef, Item, ItemTarget, List, Map,
31        Record, RelationEdge, RuleNumber, Schema, SchemaNode, Set, SourceRule,
32        SourceRuleAuthoringOperation, Store, Tuple, Value,
33    },
34    types::{Cardinality, Primitive},
35};
36
37/// Failure while projecting one validated host graph into public fragments.
38
39#[derive(Debug, Error)]
40pub enum FragmentLoweringError {
41    /// A selected canister has no registered stores.
42    #[error("schema canister has no registered stores: {0}")]
43    CanisterHasNoStores(String),
44
45    /// The selected canister path is not registered.
46    #[error("schema canister path is not registered: {0}")]
47    CanisterNotFound(String),
48
49    /// The public bounded proposal contract rejected the projection.
50    #[error(transparent)]
51    Contract(#[from] SchemaContractError),
52
53    /// Fragment projection requires the immutable post-validation graph.
54    #[error("schema graph must be sealed before fragment lowering")]
55    GraphNotSealed,
56
57    /// A declared default cannot be represented by the public proposal atom.
58    #[error("schema field default cannot be lowered: {0}")]
59    InvalidDefault(String),
60
61    /// One graph reference no longer resolves to the expected node kind.
62    #[error("schema fragment reference is invalid: {0}")]
63    InvalidReference(String),
64
65    /// One authored value cardinality has no accepted proposal representation.
66    #[error("schema value cardinality is unsupported at {0}")]
67    UnsupportedCardinality(String),
68}
69
70// -----------------------------------------------------------------------------
71// Database closure
72// -----------------------------------------------------------------------------
73
74impl Schema {
75    /// Lower every persisted entity belonging to one canister, plus its exact
76    /// reachable named-type and relation closure, into one store-free fragment.
77    ///
78    /// Store assignment remains a later proposal-composition concern.
79    ///
80    /// # Errors
81    ///
82    /// Returns a typed error when the graph is not sealed, the selected
83    /// canister/store closure is incomplete, or one authored fact cannot be
84    /// represented by the bounded public proposal contract.
85    pub fn schema_fragment_for_canister(
86        &self,
87        canister_path: &str,
88    ) -> Result<SchemaFragment, FragmentLoweringError> {
89        if !self.is_sealed() {
90            return Err(FragmentLoweringError::GraphNotSealed);
91        }
92        self.cast_node::<Canister>(canister_path)
93            .map_err(|_| FragmentLoweringError::CanisterNotFound(canister_path.to_string()))?;
94        let stores = self
95            .filter_nodes::<Store>(|store| store.canister() == canister_path)
96            .map(|(path, _)| path.to_string())
97            .collect::<BTreeSet<_>>();
98        if stores.is_empty() {
99            return Err(FragmentLoweringError::CanisterHasNoStores(
100                canister_path.to_string(),
101            ));
102        }
103
104        let entities = self
105            .get_nodes::<Entity>()
106            .filter(|(_, entity)| stores.contains(entity.store()))
107            .map(|(_, entity)| entity)
108            .collect::<Vec<_>>();
109        let selected_entities = entities
110            .iter()
111            .map(|entity| entity.def().path())
112            .collect::<BTreeSet<_>>();
113        for entity in &entities {
114            ensure_relation_targets_in_database(self, entity, &selected_entities)?;
115        }
116
117        let mut pending_types = Vec::new();
118        let entity_fragments = entities
119            .iter()
120            .map(|entity| lower_entity(self, entity, &mut pending_types))
121            .collect::<Result<Vec<_>, _>>()?;
122        let types = lower_reachable_types(self, pending_types)?;
123
124        SchemaFragment::try_new(entity_fragments, types).map_err(Into::into)
125    }
126}
127
128fn ensure_relation_targets_in_database(
129    schema: &Schema,
130    entity: &Entity,
131    selected_entities: &BTreeSet<String>,
132) -> Result<(), FragmentLoweringError> {
133    let mut targets = entity
134        .relations()
135        .iter()
136        .map(RelationEdge::target)
137        .collect::<Vec<_>>();
138    for field in entity.fields().fields() {
139        collect_relation_targets(
140            schema,
141            field.value().item(),
142            &mut BTreeSet::new(),
143            &mut targets,
144        )?;
145    }
146    for target in targets {
147        schema
148            .cast_node::<Entity>(target)
149            .map_err(|_| FragmentLoweringError::InvalidReference(target.to_string()))?;
150        if !selected_entities.contains(target) {
151            return Err(FragmentLoweringError::InvalidReference(format!(
152                "relation target '{target}' is outside the selected database"
153            )));
154        }
155    }
156    Ok(())
157}
158
159fn collect_relation_targets<'schema>(
160    schema: &'schema Schema,
161    item: &'schema Item,
162    visiting: &mut BTreeSet<String>,
163    targets: &mut Vec<&'schema str>,
164) -> Result<(), FragmentLoweringError> {
165    if let Some(target) = item.relation() {
166        targets.push(target);
167    }
168    let ItemTarget::Is(path) = item.target() else {
169        return Ok(());
170    };
171    if !visiting.insert((*path).to_string()) {
172        return Ok(());
173    }
174    let node = schema
175        .get_node(path)
176        .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?;
177    match node {
178        SchemaNode::Newtype(value) => {
179            collect_relation_targets(schema, value.item(), visiting, targets)?;
180        }
181        SchemaNode::Record(value) => {
182            for field in value.fields().fields() {
183                collect_relation_targets(schema, field.value().item(), visiting, targets)?;
184            }
185        }
186        SchemaNode::Enum(value) => {
187            for variant in value.variants() {
188                if let Some(value) = variant.value() {
189                    collect_relation_targets(schema, value.item(), visiting, targets)?;
190                }
191            }
192        }
193        SchemaNode::List(value) => {
194            collect_relation_targets(schema, value.item(), visiting, targets)?;
195        }
196        SchemaNode::Set(value) => {
197            collect_relation_targets(schema, value.item(), visiting, targets)?;
198        }
199        SchemaNode::Map(value) => {
200            collect_relation_targets(schema, value.key(), visiting, targets)?;
201            collect_relation_targets(schema, value.value().item(), visiting, targets)?;
202        }
203        SchemaNode::Tuple(value) => {
204            for member in value.values() {
205                collect_relation_targets(schema, member.item(), visiting, targets)?;
206            }
207        }
208        SchemaNode::Canister(_)
209        | SchemaNode::Entity(_)
210        | SchemaNode::Normalizer(_)
211        | SchemaNode::Store(_)
212        | SchemaNode::Validator(_) => {}
213    }
214    visiting.remove(*path);
215    Ok(())
216}
217
218fn lower_entity(
219    schema: &Schema,
220    entity: &Entity,
221    pending_types: &mut Vec<String>,
222) -> Result<EntityFragment, FragmentLoweringError> {
223    let fields = entity
224        .fields()
225        .fields()
226        .iter()
227        .map(|field| lower_entity_field(schema, field, pending_types))
228        .collect::<Result<Vec<_>, _>>()?;
229    let primary_key = entity
230        .primary_key()
231        .fields()
232        .iter()
233        .map(|name| entity_field_source_key(entity, name))
234        .collect::<Result<Vec<_>, _>>()?;
235    let indexes = entity
236        .indexes()
237        .iter()
238        .map(|index| lower_index(schema, entity, index))
239        .collect::<Result<Vec<_>, _>>()?;
240    let mut relations = entity
241        .fields()
242        .fields()
243        .iter()
244        .filter(|field| field.value().item().relation().is_some())
245        .map(|field| lower_scalar_relation(schema, entity, field))
246        .collect::<Result<Vec<_>, _>>()?;
247    for field in entity.fields().fields() {
248        relations.extend(lower_nested_relations(schema, entity, field)?);
249    }
250    relations.extend(
251        entity
252            .relations()
253            .iter()
254            .map(|relation| lower_composite_relation(schema, entity, relation))
255            .collect::<Result<Vec<_>, _>>()?,
256    );
257    let mut constraints = entity
258        .constraints()
259        .iter()
260        .map(|constraint| lower_constraint(schema, constraint))
261        .collect::<Result<Vec<_>, _>>()?;
262    for field in entity.fields().fields() {
263        constraints.extend(lower_field_rules(schema, field)?);
264    }
265
266    EntityFragment::try_new(
267        SchemaName::try_new(entity.name())?,
268        DeclaredEntityVersion::try_new(entity.schema_version())?,
269        fields,
270        primary_key,
271        indexes,
272        relations,
273        constraints,
274    )
275    .map_err(Into::into)
276}
277
278struct NestedRelationCandidate<'schema> {
279    path_name: String,
280    steps: Vec<RelationPathStepFragment>,
281    target: &'schema str,
282}
283
284fn lower_nested_relations(
285    schema: &Schema,
286    entity: &Entity,
287    root: &Field,
288) -> Result<Vec<RelationFragment>, FragmentLoweringError> {
289    if root.value().item().relation().is_some() {
290        return Ok(Vec::new());
291    }
292    let mut steps = Vec::new();
293    if root.value().cardinality() == Cardinality::Opt {
294        steps.push(RelationPathStepFragment::OptionalSome);
295    } else if root.value().cardinality() == Cardinality::Many {
296        steps.push(RelationPathStepFragment::ListItems);
297    }
298    let mut candidates = Vec::new();
299    collect_nested_relation_candidates(
300        schema,
301        root.value().item(),
302        root.name().to_string(),
303        steps,
304        &mut BTreeSet::new(),
305        &mut candidates,
306    )?;
307    candidates
308        .into_iter()
309        .map(|candidate| {
310            let target = schema.cast_node::<Entity>(candidate.target).map_err(|_| {
311                FragmentLoweringError::InvalidReference(candidate.target.to_string())
312            })?;
313            RelationFragment::try_new(
314                SchemaName::try_new(candidate.path_name)?,
315                RelationSourceFragment::Nested {
316                    root: entity_field_source_key(entity, root.name())?,
317                    steps: candidate.steps,
318                },
319                EntitySourceKey::try_new(target.name())?,
320                target
321                    .primary_key()
322                    .fields()
323                    .iter()
324                    .map(|field| entity_field_source_key(target, field))
325                    .collect::<Result<Vec<_>, _>>()?,
326                RelationDeleteAction::Restrict,
327            )
328            .map_err(Into::into)
329        })
330        .collect()
331}
332
333#[expect(
334    clippy::too_many_lines,
335    reason = "one bounded recursive walk keeps every admitted structural node and exclusion adjacent"
336)]
337fn collect_nested_relation_candidates<'schema>(
338    schema: &'schema Schema,
339    item: &'schema Item,
340    path_name: String,
341    steps: Vec<RelationPathStepFragment>,
342    visiting: &mut BTreeSet<String>,
343    candidates: &mut Vec<NestedRelationCandidate<'schema>>,
344) -> Result<(), FragmentLoweringError> {
345    if let Some(target) = item.relation() {
346        let ItemTarget::Primitive(_) = item.target() else {
347            return Err(FragmentLoweringError::InvalidReference(path_name));
348        };
349        candidates.push(NestedRelationCandidate {
350            path_name,
351            steps,
352            target,
353        });
354        return Ok(());
355    }
356    let ItemTarget::Is(path) = item.target() else {
357        return Ok(());
358    };
359    if !visiting.insert((*path).to_string()) {
360        // A relation source is one finite path through a value. Stop at a
361        // recursive edge: any finite relation-bearing sibling was already
362        // visited, while a relation reachable only by cycling has no bounded
363        // single-valued source path to lower.
364        return Ok(());
365    }
366    let node = schema
367        .get_node(path)
368        .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?;
369    let type_key = TypeSourceKey::try_new(
370        named_type_name(node)
371            .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?,
372    )?;
373    let mut entered = steps;
374    entered.push(RelationPathStepFragment::EnterNamed {
375        r#type: type_key.clone(),
376    });
377    match node {
378        SchemaNode::Newtype(value) => collect_nested_relation_candidates(
379            schema,
380            value.item(),
381            path_name,
382            entered,
383            visiting,
384            candidates,
385        )?,
386        SchemaNode::Record(value) => {
387            for field in value.fields().fields() {
388                let mut member_steps = entered.clone();
389                member_steps.push(RelationPathStepFragment::RecordMember {
390                    record: type_key.clone(),
391                    field: FieldSourceKey::try_new(field.name())?,
392                });
393                match field.value().cardinality() {
394                    Cardinality::One => {}
395                    Cardinality::Opt => {
396                        member_steps.push(RelationPathStepFragment::OptionalSome);
397                    }
398                    Cardinality::Many => {
399                        member_steps.push(RelationPathStepFragment::ListItems);
400                    }
401                }
402                collect_nested_relation_candidates(
403                    schema,
404                    field.value().item(),
405                    format!("{path_name}.{}", field.name()),
406                    member_steps,
407                    visiting,
408                    candidates,
409                )?;
410            }
411        }
412        SchemaNode::Enum(value) => {
413            for variant in value.variants() {
414                let Some(payload) = variant.value() else {
415                    continue;
416                };
417                let mut variant_steps = entered.clone();
418                variant_steps.push(RelationPathStepFragment::EnumVariantPayload {
419                    r#enum: type_key.clone(),
420                    variant: TypeSourceKey::try_new(variant.name())?,
421                });
422                match payload.cardinality() {
423                    Cardinality::One => {}
424                    Cardinality::Opt => {
425                        variant_steps.push(RelationPathStepFragment::OptionalSome);
426                    }
427                    Cardinality::Many => {
428                        variant_steps.push(RelationPathStepFragment::ListItems);
429                    }
430                }
431                collect_nested_relation_candidates(
432                    schema,
433                    payload.item(),
434                    format!("{path_name}.{}", variant.name()),
435                    variant_steps,
436                    visiting,
437                    candidates,
438                )?;
439            }
440        }
441        SchemaNode::List(list) => {
442            let mut item_steps = entered;
443            item_steps.push(RelationPathStepFragment::ListItems);
444            collect_nested_relation_candidates(
445                schema,
446                list.item(),
447                format!("{path_name}.items"),
448                item_steps,
449                visiting,
450                candidates,
451            )?;
452        }
453        SchemaNode::Set(set) => {
454            let mut item_steps = entered;
455            item_steps.push(RelationPathStepFragment::SetItems);
456            collect_nested_relation_candidates(
457                schema,
458                set.item(),
459                format!("{path_name}.items"),
460                item_steps,
461                visiting,
462                candidates,
463            )?;
464        }
465        SchemaNode::Map(map) => {
466            let mut value_steps = entered;
467            value_steps.push(RelationPathStepFragment::MapValues);
468            match map.value().cardinality() {
469                Cardinality::One => {}
470                Cardinality::Opt => {
471                    value_steps.push(RelationPathStepFragment::OptionalSome);
472                }
473                Cardinality::Many => {
474                    value_steps.push(RelationPathStepFragment::ListItems);
475                }
476            }
477            collect_nested_relation_candidates(
478                schema,
479                map.value().item(),
480                format!("{path_name}.values"),
481                value_steps,
482                visiting,
483                candidates,
484            )?;
485        }
486        SchemaNode::Tuple(_) => {
487            if item_reaches_relation(schema, item, &mut BTreeSet::new())? {
488                return Err(FragmentLoweringError::UnsupportedCardinality(path_name));
489            }
490        }
491        SchemaNode::Canister(_)
492        | SchemaNode::Entity(_)
493        | SchemaNode::Normalizer(_)
494        | SchemaNode::Store(_)
495        | SchemaNode::Validator(_) => {
496            return Err(FragmentLoweringError::InvalidReference((*path).to_string()));
497        }
498    }
499    visiting.remove(*path);
500    Ok(())
501}
502
503fn item_reaches_relation(
504    schema: &Schema,
505    item: &Item,
506    visiting: &mut BTreeSet<String>,
507) -> Result<bool, FragmentLoweringError> {
508    if item.relation().is_some() {
509        return Ok(true);
510    }
511    let mut targets = Vec::new();
512    collect_relation_targets(schema, item, visiting, &mut targets)?;
513    Ok(!targets.is_empty())
514}
515
516fn lower_entity_field(
517    schema: &Schema,
518    field: &Field,
519    pending_types: &mut Vec<String>,
520) -> Result<FieldFragment, FragmentLoweringError> {
521    let field_type = lower_value_type(schema, field.value(), pending_types)?;
522    let nullable = field.value().cardinality() == Cardinality::Opt;
523    let insert_policy = if field.generated().is_some() {
524        FieldInsertPolicy::Generated
525    } else if let Some(default) = field.default() {
526        FieldInsertPolicy::Default(lower_default(schema, field, default)?)
527    } else if nullable {
528        FieldInsertPolicy::Nullable
529    } else {
530        FieldInsertPolicy::Required
531    };
532    let management = match field.write_management() {
533        Some(FieldWriteManagement::CreatedAt) => Some(FieldManagementPolicy::CreatedAt),
534        Some(FieldWriteManagement::UpdatedAt) => Some(FieldManagementPolicy::UpdatedAt),
535        None => None,
536    };
537    Ok(FieldFragment::new(
538        SchemaName::try_new(field.name())?,
539        field_type,
540        nullable,
541        insert_policy,
542        management,
543    ))
544}
545
546fn lower_index(
547    schema: &Schema,
548    entity: &Entity,
549    index: &Index,
550) -> Result<IndexFragment, FragmentLoweringError> {
551    let key = match index.key_items() {
552        IndexKeyItemsRef::Fields(fields) => fields
553            .iter()
554            .map(|field| entity_field_source_key(entity, field).map(IndexKeyFragment::Field))
555            .collect::<Result<Vec<_>, _>>()?,
556        IndexKeyItemsRef::Items(items) => items
557            .iter()
558            .map(|item| lower_index_key(entity, item))
559            .collect::<Result<Vec<_>, _>>()?,
560    };
561    IndexFragment::try_new(
562        SchemaName::try_new(index.name())?,
563        key,
564        index.is_unique(),
565        index.source_predicate(schema)?,
566    )
567    .map_err(Into::into)
568}
569
570fn lower_index_key(
571    entity: &Entity,
572    item: &IndexKeyItem,
573) -> Result<IndexKeyFragment, FragmentLoweringError> {
574    let field = entity_field_source_key(entity, item.field())?;
575    Ok(match item {
576        IndexKeyItem::Field(_) => IndexKeyFragment::Field(field),
577        IndexKeyItem::Expression(IndexExpression::Lower(_)) => IndexKeyFragment::Lower(field),
578        IndexKeyItem::Expression(IndexExpression::Upper(_)) => IndexKeyFragment::Upper(field),
579        IndexKeyItem::Expression(IndexExpression::Trim(_)) => IndexKeyFragment::Trim(field),
580        IndexKeyItem::Expression(IndexExpression::LowerTrim(_)) => {
581            IndexKeyFragment::LowerTrim(field)
582        }
583        IndexKeyItem::Expression(IndexExpression::Date(_)) => IndexKeyFragment::Date(field),
584        IndexKeyItem::Expression(IndexExpression::Year(_)) => IndexKeyFragment::Year(field),
585        IndexKeyItem::Expression(IndexExpression::Month(_)) => IndexKeyFragment::Month(field),
586        IndexKeyItem::Expression(IndexExpression::Day(_)) => IndexKeyFragment::Day(field),
587    })
588}
589
590fn lower_scalar_relation(
591    schema: &Schema,
592    entity: &Entity,
593    field: &Field,
594) -> Result<RelationFragment, FragmentLoweringError> {
595    let target_path = field
596        .value()
597        .item()
598        .relation()
599        .ok_or_else(|| FragmentLoweringError::InvalidReference(field.name().to_string()))?;
600    let target = schema
601        .cast_node::<Entity>(target_path)
602        .map_err(|_| FragmentLoweringError::InvalidReference(target_path.to_string()))?;
603    RelationFragment::try_new(
604        SchemaName::try_new(field.name())?,
605        RelationSourceFragment::direct(vec![entity_field_source_key(entity, field.name())?]),
606        EntitySourceKey::try_new(target.name())?,
607        target
608            .primary_key()
609            .fields()
610            .iter()
611            .map(|field| entity_field_source_key(target, field))
612            .collect::<Result<Vec<_>, _>>()?,
613        RelationDeleteAction::Restrict,
614    )
615    .map_err(Into::into)
616}
617
618fn lower_composite_relation(
619    schema: &Schema,
620    entity: &Entity,
621    relation: &RelationEdge,
622) -> Result<RelationFragment, FragmentLoweringError> {
623    let target = schema
624        .cast_node::<Entity>(relation.target())
625        .map_err(|_| FragmentLoweringError::InvalidReference(relation.target().to_string()))?;
626    RelationFragment::try_new(
627        SchemaName::try_new(relation.name())?,
628        RelationSourceFragment::direct(
629            relation
630                .local_fields()
631                .iter()
632                .map(|field| entity_field_source_key(entity, field))
633                .collect::<Result<Vec<_>, _>>()?,
634        ),
635        EntitySourceKey::try_new(target.name())?,
636        target
637            .primary_key()
638            .fields()
639            .iter()
640            .map(|field| entity_field_source_key(target, field))
641            .collect::<Result<Vec<_>, _>>()?,
642        RelationDeleteAction::Restrict,
643    )
644    .map_err(Into::into)
645}
646
647fn lower_constraint(
648    schema: &Schema,
649    constraint: &CheckConstraint,
650) -> Result<ConstraintFragment, FragmentLoweringError> {
651    Ok(ConstraintFragment::check(
652        SchemaName::try_new(constraint.name())?,
653        constraint.source_expression(schema)?,
654    ))
655}
656
657fn lower_field_rules(
658    schema: &Schema,
659    field: &Field,
660) -> Result<Vec<ConstraintFragment>, FragmentLoweringError> {
661    let field_source = FieldSourceKey::try_new(field.name())?;
662    reachable_source_rules(schema, field.value().item())?
663        .into_iter()
664        .map(|(target_type, rule, target)| {
665            let operation = lower_source_rule_operation(schema, target, rule)?;
666            Ok(ConstraintFragment::targeted_rule(
667                TargetedRuleFragment::new(
668                    field_source.clone(),
669                    target_type,
670                    SchemaName::try_new(rule.name())?,
671                    operation,
672                ),
673            ))
674        })
675        .collect()
676}
677
678type ReachableSourceRule<'schema> = (
679    TypeSourceKey,
680    &'schema SourceRule,
681    &'schema crate::node::SchemaNode,
682);
683
684fn reachable_source_rules<'schema>(
685    schema: &'schema Schema,
686    item: &Item,
687) -> Result<Vec<ReachableSourceRule<'schema>>, FragmentLoweringError> {
688    let mut pending = Vec::new();
689    push_item_reference(item, &mut pending);
690    let mut visited = BTreeSet::new();
691    let mut rules = BTreeMap::new();
692    while let Some(path) = pending.pop() {
693        let node = schema
694            .get_node(path.as_str())
695            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
696        let target_type = TypeSourceKey::try_new(
697            named_type_name(node)
698                .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?,
699        )?;
700        if !visited.insert(target_type.clone()) {
701            continue;
702        }
703        for rule in schema_node_type(node)?.rules() {
704            let key = (target_type.clone(), RuleSourceKey::try_new(rule.name())?);
705            if rules.insert(key, (rule, node)).is_some() {
706                return Err(FragmentLoweringError::InvalidReference(format!(
707                    "duplicate durable rule '{}' on type '{}'",
708                    rule.name(),
709                    target_type
710                )));
711            }
712        }
713        push_schema_node_references(node, &mut pending);
714    }
715    Ok(rules
716        .into_iter()
717        .map(|((target_type, _), (rule, node))| (target_type, rule, node))
718        .collect())
719}
720
721fn schema_node_type(node: &SchemaNode) -> Result<&crate::node::Type, FragmentLoweringError> {
722    match node {
723        SchemaNode::Newtype(node) => Ok(node.ty()),
724        SchemaNode::Record(node) => Ok(node.ty()),
725        SchemaNode::Enum(node) => Ok(node.ty()),
726        SchemaNode::List(node) => Ok(node.ty()),
727        SchemaNode::Map(node) => Ok(node.ty()),
728        SchemaNode::Set(node) => Ok(node.ty()),
729        SchemaNode::Tuple(node) => Ok(node.ty()),
730        SchemaNode::Canister(_)
731        | SchemaNode::Entity(_)
732        | SchemaNode::Normalizer(_)
733        | SchemaNode::Store(_)
734        | SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
735            "durable-rule target is not a named type".to_string(),
736        )),
737    }
738}
739
740fn push_schema_node_references(node: &SchemaNode, pending: &mut Vec<String>) {
741    match node {
742        SchemaNode::Newtype(newtype) => push_item_reference(newtype.item(), pending),
743        SchemaNode::Record(record) => {
744            for field in record.fields().fields() {
745                push_item_reference(field.value().item(), pending);
746            }
747        }
748        SchemaNode::Enum(r#enum) => {
749            for value in r#enum
750                .variants()
751                .iter()
752                .filter_map(crate::node::EnumVariant::value)
753            {
754                push_item_reference(value.item(), pending);
755            }
756        }
757        SchemaNode::List(list) => push_item_reference(list.item(), pending),
758        SchemaNode::Map(map) => {
759            push_item_reference(map.key(), pending);
760            push_item_reference(map.value().item(), pending);
761        }
762        SchemaNode::Set(set) => push_item_reference(set.item(), pending),
763        SchemaNode::Tuple(tuple) => {
764            for value in tuple.values() {
765                push_item_reference(value.item(), pending);
766            }
767        }
768        SchemaNode::Canister(_)
769        | SchemaNode::Entity(_)
770        | SchemaNode::Normalizer(_)
771        | SchemaNode::Store(_)
772        | SchemaNode::Validator(_) => {}
773    }
774}
775
776fn push_item_reference(item: &Item, pending: &mut Vec<String>) {
777    if let ItemTarget::Is(path) = item.target() {
778        pending.push((*path).to_string());
779    }
780}
781
782fn lower_source_rule_operation(
783    schema: &Schema,
784    target: &SchemaNode,
785    rule: &SourceRule,
786) -> Result<ProposalSourceRuleOperation, FragmentLoweringError> {
787    let length_bound = |value: &RuleNumber| {
788        rule_integer_u128(value)
789            .and_then(|value| u64::try_from(value).ok())
790            .ok_or_else(|| {
791                FragmentLoweringError::InvalidReference(format!(
792                    "rule '{}' has an invalid length bound",
793                    rule.name()
794                ))
795            })
796    };
797
798    let operation = match rule.operation() {
799        SourceRuleAuthoringOperation::NumericMinimumInclusive { value } => {
800            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
801            else {
802                return Err(invalid_rule_target(rule));
803            };
804            let value = lower_rule_numeric_literal(primitive, item, value)
805                .ok_or_else(|| invalid_rule_target(rule))?;
806            ProposalSourceRuleOperation::NumericMinimumInclusive { value }
807        }
808        SourceRuleAuthoringOperation::NumericMaximumInclusive { value } => {
809            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
810            else {
811                return Err(invalid_rule_target(rule));
812            };
813            let value = lower_rule_numeric_literal(primitive, item, value)
814                .ok_or_else(|| invalid_rule_target(rule))?;
815            ProposalSourceRuleOperation::NumericMaximumInclusive { value }
816        }
817        SourceRuleAuthoringOperation::NumericRangeInclusive { min, max } => {
818            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
819            else {
820                return Err(invalid_rule_target(rule));
821            };
822            let literal = |value: &RuleNumber| {
823                lower_rule_numeric_literal(primitive, item, value)
824                    .ok_or_else(|| invalid_rule_target(rule))
825            };
826            ProposalSourceRuleOperation::NumericRangeInclusive {
827                min: literal(min)?,
828                max: literal(max)?,
829            }
830        }
831        SourceRuleAuthoringOperation::LengthRangeInclusive { min, max } => {
832            let shape = resolve_rule_value_shape(schema, target)?;
833            if !matches!(
834                shape,
835                RuleValueShape::Collection
836                    | RuleValueShape::Scalar(Primitive::Blob | Primitive::Text, _)
837            ) {
838                return Err(invalid_rule_target(rule));
839            }
840            ProposalSourceRuleOperation::LengthRangeInclusive {
841                min: length_bound(min)?,
842                max: length_bound(max)?,
843            }
844        }
845        SourceRuleAuthoringOperation::MultipleOf { divisor } => {
846            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
847            else {
848                return Err(invalid_rule_target(rule));
849            };
850            let divisor = lower_rule_numeric_literal(primitive, item, divisor)
851                .ok_or_else(|| invalid_rule_target(rule))?;
852            ProposalSourceRuleOperation::MultipleOf { divisor }
853        }
854    };
855    Ok(operation)
856}
857
858fn lower_rule_numeric_literal(
859    primitive: Primitive,
860    item: &Item,
861    value: &RuleNumber,
862) -> Option<ScalarLiteral> {
863    match primitive {
864        Primitive::Decimal => rule_decimal(value)
865            .and_then(|value| exact_decimal_at_scale(value, item.scale().unwrap_or(0)))
866            .map(ScalarLiteral::Decimal),
867        Primitive::Float32 => match value {
868            RuleNumber::Float32(value) => Float32::try_new(*value).map(ScalarLiteral::Float32),
869            RuleNumber::Integer(_) | RuleNumber::Decimal(_) | RuleNumber::Float64(_) => None,
870        },
871        Primitive::Float64 => match value {
872            RuleNumber::Decimal(value) => value
873                .parse::<f64>()
874                .ok()
875                .and_then(Float64::try_new)
876                .map(ScalarLiteral::Float64),
877            RuleNumber::Float64(value) => Float64::try_new(*value).map(ScalarLiteral::Float64),
878            RuleNumber::Integer(_) | RuleNumber::Float32(_) => None,
879        },
880        Primitive::Int8
881        | Primitive::Int16
882        | Primitive::Int32
883        | Primitive::Int64
884        | Primitive::Int128 => rule_integer_i128(value).map(ScalarLiteral::Int),
885        Primitive::Nat8
886        | Primitive::Nat16
887        | Primitive::Nat32
888        | Primitive::Nat64
889        | Primitive::Nat128 => rule_integer_u128(value).map(ScalarLiteral::Nat),
890        Primitive::U256 => rule_integer_text(value)
891            .and_then(|value| U256::from_str(value).ok())
892            .map(ScalarLiteral::U256),
893        Primitive::Account
894        | Primitive::Blob
895        | Primitive::Bool
896        | Primitive::Date
897        | Primitive::Duration
898        | Primitive::Principal
899        | Primitive::Subaccount
900        | Primitive::Text
901        | Primitive::Timestamp
902        | Primitive::Ulid
903        | Primitive::Unit => None,
904    }
905}
906
907const fn rule_integer_text(value: &RuleNumber) -> Option<&str> {
908    let RuleNumber::Integer(value) = value else {
909        return None;
910    };
911    Some(value)
912}
913
914fn rule_integer_i128(value: &RuleNumber) -> Option<i128> {
915    rule_integer_text(value)?.parse().ok()
916}
917
918fn rule_integer_u128(value: &RuleNumber) -> Option<u128> {
919    rule_integer_text(value)?.parse().ok()
920}
921
922fn rule_decimal(value: &RuleNumber) -> Option<Decimal> {
923    match value {
924        RuleNumber::Integer(value) | RuleNumber::Decimal(value) => Decimal::from_str(value).ok(),
925        RuleNumber::Float32(_) | RuleNumber::Float64(_) => None,
926    }
927}
928
929fn exact_decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
930    let value = value.normalize();
931    value
932        .scale_to_integer(scale)
933        .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale))
934}
935
936#[derive(Clone, Copy)]
937enum RuleValueShape<'schema> {
938    Collection,
939    Scalar(Primitive, &'schema Item),
940}
941
942fn resolve_rule_value_shape<'schema>(
943    schema: &'schema Schema,
944    mut target: &'schema SchemaNode,
945) -> Result<RuleValueShape<'schema>, FragmentLoweringError> {
946    let mut visited = BTreeSet::new();
947    loop {
948        let source = named_type_name(target)
949            .ok_or_else(|| FragmentLoweringError::InvalidReference("non-type rule".to_string()))?;
950        if !visited.insert(source) {
951            return Err(FragmentLoweringError::InvalidReference(format!(
952                "durable-rule target cycle at '{source}'"
953            )));
954        }
955        match target {
956            SchemaNode::List(_) | SchemaNode::Map(_) | SchemaNode::Set(_) => {
957                return Ok(RuleValueShape::Collection);
958            }
959            SchemaNode::Newtype(newtype) => match newtype.item().target() {
960                ItemTarget::Primitive(primitive) => {
961                    return Ok(RuleValueShape::Scalar(*primitive, newtype.item()));
962                }
963                ItemTarget::Is(path) => {
964                    target = schema
965                        .get_node(path)
966                        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
967                }
968            },
969            SchemaNode::Record(_) | SchemaNode::Enum(_) | SchemaNode::Tuple(_) => {
970                return Err(FragmentLoweringError::InvalidReference(format!(
971                    "durable-rule target '{source}' has no supported scalar or collection value"
972                )));
973            }
974            SchemaNode::Canister(_)
975            | SchemaNode::Entity(_)
976            | SchemaNode::Normalizer(_)
977            | SchemaNode::Store(_)
978            | SchemaNode::Validator(_) => {
979                return Err(FragmentLoweringError::InvalidReference(
980                    "non-type durable-rule target".to_string(),
981                ));
982            }
983        }
984    }
985}
986
987fn invalid_rule_target(rule: &SourceRule) -> FragmentLoweringError {
988    FragmentLoweringError::InvalidReference(format!(
989        "durable rule '{}' does not match its nominal target",
990        rule.name()
991    ))
992}
993
994fn entity_field_source_key(
995    entity: &Entity,
996    field_name: &str,
997) -> Result<FieldSourceKey, FragmentLoweringError> {
998    let field = entity
999        .fields()
1000        .get(field_name)
1001        .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
1002    FieldSourceKey::try_new(field.name()).map_err(Into::into)
1003}
1004
1005// -----------------------------------------------------------------------------
1006// Reachable named-type closure
1007// -----------------------------------------------------------------------------
1008
1009fn lower_reachable_types(
1010    schema: &Schema,
1011    mut pending: Vec<String>,
1012) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
1013    let mut lowered = BTreeMap::new();
1014    while let Some(path) = pending.pop() {
1015        let node = schema
1016            .get_node(path.as_str())
1017            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1018        let source_key = named_type_name(node)
1019            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1020        if lowered.contains_key(source_key) {
1021            continue;
1022        }
1023        let fragment = lower_named_type(schema, node, &mut pending)?;
1024        lowered.insert(source_key.to_string(), fragment);
1025    }
1026    Ok(lowered.into_values().collect())
1027}
1028
1029const fn named_type_name(node: &crate::node::SchemaNode) -> Option<&str> {
1030    match node {
1031        crate::node::SchemaNode::Enum(node) => Some(node.name()),
1032        crate::node::SchemaNode::List(node) => Some(node.name()),
1033        crate::node::SchemaNode::Map(node) => Some(node.name()),
1034        crate::node::SchemaNode::Newtype(node) => Some(node.name()),
1035        crate::node::SchemaNode::Record(node) => Some(node.name()),
1036        crate::node::SchemaNode::Set(node) => Some(node.name()),
1037        crate::node::SchemaNode::Tuple(node) => Some(node.name()),
1038        crate::node::SchemaNode::Canister(_)
1039        | crate::node::SchemaNode::Entity(_)
1040        | crate::node::SchemaNode::Normalizer(_)
1041        | crate::node::SchemaNode::Store(_)
1042        | crate::node::SchemaNode::Validator(_) => None,
1043    }
1044}
1045
1046fn lower_named_type(
1047    schema: &Schema,
1048    node: &crate::node::SchemaNode,
1049    pending: &mut Vec<String>,
1050) -> Result<NamedTypeFragment, FragmentLoweringError> {
1051    match node {
1052        crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
1053        crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
1054        crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::newtype(
1055            SchemaName::try_new(newtype.name())?,
1056            lower_item_type(schema, newtype.item(), pending)?,
1057        )),
1058        crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
1059        crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
1060        crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
1061        crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
1062        crate::node::SchemaNode::Canister(_)
1063        | crate::node::SchemaNode::Entity(_)
1064        | crate::node::SchemaNode::Normalizer(_)
1065        | crate::node::SchemaNode::Store(_)
1066        | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
1067            "non-type graph node".to_string(),
1068        )),
1069    }
1070}
1071
1072fn lower_record(
1073    schema: &Schema,
1074    record: &Record,
1075    pending: &mut Vec<String>,
1076) -> Result<NamedTypeFragment, FragmentLoweringError> {
1077    let fields = record
1078        .fields()
1079        .fields()
1080        .iter()
1081        .map(|field| {
1082            Ok(RecordFieldFragment::new(
1083                SchemaName::try_new(field.name())?,
1084                lower_value_type(schema, field.value(), pending)?,
1085                field.value().cardinality() == Cardinality::Opt,
1086            ))
1087        })
1088        .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
1089    Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
1090        SchemaName::try_new(record.name())?,
1091        fields,
1092    )?))
1093}
1094
1095fn lower_enum(
1096    schema: &Schema,
1097    r#enum: &Enum,
1098    pending: &mut Vec<String>,
1099) -> Result<NamedTypeFragment, FragmentLoweringError> {
1100    let variants = r#enum
1101        .variants()
1102        .iter()
1103        .map(|variant| {
1104            let name = SchemaName::try_new(variant.name())?;
1105            match variant.value() {
1106                Some(value) if value.cardinality() == Cardinality::Opt => {
1107                    Err(FragmentLoweringError::UnsupportedCardinality(format!(
1108                        "{}::{}",
1109                        r#enum.def().path(),
1110                        variant.name()
1111                    )))
1112                }
1113                Some(value) => Ok(EnumVariantFragment::with_payload(
1114                    name,
1115                    lower_value_type(schema, value, pending)?,
1116                )),
1117                None => Ok(EnumVariantFragment::new(name)),
1118            }
1119        })
1120        .collect::<Result<Vec<_>, _>>()?;
1121    Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
1122        SchemaName::try_new(r#enum.name())?,
1123        variants,
1124    )?))
1125}
1126
1127fn lower_list(
1128    schema: &Schema,
1129    list: &List,
1130    pending: &mut Vec<String>,
1131) -> Result<NamedTypeFragment, FragmentLoweringError> {
1132    Ok(NamedTypeFragment::list(
1133        SchemaName::try_new(list.name())?,
1134        lower_item_type(schema, list.item(), pending)?,
1135    ))
1136}
1137
1138fn lower_set(
1139    schema: &Schema,
1140    set: &Set,
1141    pending: &mut Vec<String>,
1142) -> Result<NamedTypeFragment, FragmentLoweringError> {
1143    Ok(NamedTypeFragment::set(
1144        SchemaName::try_new(set.name())?,
1145        lower_item_type(schema, set.item(), pending)?,
1146    ))
1147}
1148
1149fn lower_map(
1150    schema: &Schema,
1151    map: &Map,
1152    pending: &mut Vec<String>,
1153) -> Result<NamedTypeFragment, FragmentLoweringError> {
1154    if map.value().cardinality() == Cardinality::Opt {
1155        return Err(FragmentLoweringError::UnsupportedCardinality(
1156            map.def().path(),
1157        ));
1158    }
1159    Ok(NamedTypeFragment::map(
1160        SchemaName::try_new(map.name())?,
1161        lower_item_type(schema, map.key(), pending)?,
1162        lower_value_type(schema, map.value(), pending)?,
1163    ))
1164}
1165
1166fn lower_tuple(
1167    schema: &Schema,
1168    tuple: &Tuple,
1169    pending: &mut Vec<String>,
1170) -> Result<NamedTypeFragment, FragmentLoweringError> {
1171    let members = tuple
1172        .values()
1173        .iter()
1174        .map(|value| {
1175            Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
1176                lower_value_type(schema, value, pending)?,
1177                value.cardinality() == Cardinality::Opt,
1178            ))
1179        })
1180        .collect::<Result<Vec<_>, _>>()?;
1181    Ok(NamedTypeFragment::tuple(
1182        SchemaName::try_new(tuple.name())?,
1183        members,
1184    ))
1185}
1186
1187// -----------------------------------------------------------------------------
1188// Exact field contracts
1189// -----------------------------------------------------------------------------
1190
1191fn lower_value_type(
1192    schema: &Schema,
1193    value: &Value,
1194    pending: &mut Vec<String>,
1195) -> Result<FieldType, FragmentLoweringError> {
1196    let item = lower_item_type(schema, value.item(), pending)?;
1197    Ok(if value.cardinality() == Cardinality::Many {
1198        FieldType::List(Box::new(item))
1199    } else {
1200        item
1201    })
1202}
1203
1204fn lower_item_type(
1205    schema: &Schema,
1206    item: &Item,
1207    pending: &mut Vec<String>,
1208) -> Result<FieldType, FragmentLoweringError> {
1209    match item.target() {
1210        ItemTarget::Is(path) => {
1211            pending.push((*path).to_string());
1212            Ok(FieldType::Named(TypeSourceKey::try_new(
1213                type_source_key_for_path(schema, path)?,
1214            )?))
1215        }
1216        ItemTarget::Primitive(primitive) => {
1217            Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
1218        }
1219    }
1220}
1221
1222fn type_source_key_for_path<'schema>(
1223    schema: &'schema Schema,
1224    path: &str,
1225) -> Result<&'schema str, FragmentLoweringError> {
1226    let source = schema
1227        .get_node(path)
1228        .and_then(named_type_name)
1229        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
1230    Ok(source)
1231}
1232
1233fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
1234    match primitive {
1235        Primitive::Account => ScalarType::Account,
1236        Primitive::Blob => ScalarType::Blob {
1237            max_len: item.max_len(),
1238        },
1239        Primitive::Bool => ScalarType::Bool,
1240        Primitive::Date => ScalarType::Date,
1241        Primitive::Decimal => ScalarType::Decimal {
1242            scale: item.scale().unwrap_or(0),
1243        },
1244        Primitive::Duration => ScalarType::Duration,
1245        Primitive::Float32 => ScalarType::Float32,
1246        Primitive::Float64 => ScalarType::Float64,
1247        Primitive::Int8 => ScalarType::Int8,
1248        Primitive::Int16 => ScalarType::Int16,
1249        Primitive::Int32 => ScalarType::Int32,
1250        Primitive::Int64 => ScalarType::Int64,
1251        Primitive::Int128 => ScalarType::Int128,
1252        Primitive::Nat8 => ScalarType::Nat8,
1253        Primitive::Nat16 => ScalarType::Nat16,
1254        Primitive::Nat32 => ScalarType::Nat32,
1255        Primitive::Nat64 => ScalarType::Nat64,
1256        Primitive::Nat128 => ScalarType::Nat128,
1257        Primitive::U256 => ScalarType::U256,
1258        Primitive::Principal => ScalarType::Principal,
1259        Primitive::Subaccount => ScalarType::Subaccount,
1260        Primitive::Text => ScalarType::Text {
1261            max_len: item.max_len(),
1262        },
1263        Primitive::Timestamp => ScalarType::Timestamp,
1264        Primitive::Ulid => ScalarType::Ulid,
1265        Primitive::Unit => ScalarType::Unit,
1266    }
1267}
1268
1269// -----------------------------------------------------------------------------
1270// Authored database defaults
1271// -----------------------------------------------------------------------------
1272
1273fn lower_default(
1274    schema: &Schema,
1275    field: &Field,
1276    default: &Arg,
1277) -> Result<ScalarLiteral, FragmentLoweringError> {
1278    if let ItemTarget::Is(path) = field.value().item().target() {
1279        let Arg::ConstPath(default_path) = default else {
1280            return Err(FragmentLoweringError::InvalidDefault(
1281                field.name().to_string(),
1282            ));
1283        };
1284        let variant = default_path.rsplit("::").next().unwrap_or(default_path);
1285        return schema
1286            .enum_unit_literal(path, variant)
1287            .map_err(FragmentLoweringError::from);
1288    }
1289    let ItemTarget::Primitive(primitive) = field.value().item().target() else {
1290        return Err(FragmentLoweringError::InvalidDefault(
1291            field.name().to_string(),
1292        ));
1293    };
1294    lower_scalar_default(*primitive, field.value().item(), default)
1295        .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.name().to_string()))
1296}
1297
1298fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
1299    if default_constructor_is_zero(default) {
1300        return zero_scalar_literal(primitive, item);
1301    }
1302    match (primitive, default) {
1303        (Primitive::Account, Arg::String(value)) => {
1304            Account::from_str(value).ok().map(ScalarLiteral::Account)
1305        }
1306        (Primitive::Blob, Arg::String(value)) => lower_blob_default(value),
1307        (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
1308        (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
1309        (Primitive::Date, Arg::Number(value)) => arg_i128(value)
1310            .and_then(|value| i32::try_from(value).ok())
1311            .and_then(Date::try_from_days_since_epoch)
1312            .map(ScalarLiteral::Date),
1313        (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
1314            .ok()
1315            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1316            .map(ScalarLiteral::Decimal),
1317        (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
1318            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1319            .map(ScalarLiteral::Decimal),
1320        (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
1321            .ok()
1322            .map(ScalarLiteral::Duration),
1323        (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
1324            .and_then(|value| u64::try_from(value).ok())
1325            .map(Duration::from_millis)
1326            .map(ScalarLiteral::Duration),
1327        (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
1328            Float32::try_new(*value).map(ScalarLiteral::Float32)
1329        }
1330        (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
1331            Float64::try_new(*value).map(ScalarLiteral::Float64)
1332        }
1333        (
1334            Primitive::Int8
1335            | Primitive::Int16
1336            | Primitive::Int32
1337            | Primitive::Int64
1338            | Primitive::Int128,
1339            Arg::Number(value),
1340        ) => arg_i128(value).map(ScalarLiteral::Int),
1341        (
1342            Primitive::Nat8
1343            | Primitive::Nat16
1344            | Primitive::Nat32
1345            | Primitive::Nat64
1346            | Primitive::Nat128,
1347            Arg::Number(value),
1348        ) => arg_u128(value).map(ScalarLiteral::Nat),
1349        (Primitive::U256, Arg::Number(value)) => {
1350            arg_u128(value).map(U256::from).map(ScalarLiteral::U256)
1351        }
1352        (Primitive::U256, Arg::String(value)) => {
1353            U256::from_str(value).ok().map(ScalarLiteral::U256)
1354        }
1355        (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
1356            .ok()
1357            .map(ScalarLiteral::Principal),
1358        (Primitive::Subaccount, Arg::String(value)) => Subaccount::from_hex(value)
1359            .ok()
1360            .map(ScalarLiteral::Subaccount),
1361        (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
1362        (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
1363            .ok()
1364            .map(ScalarLiteral::Timestamp),
1365        (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
1366            .and_then(|value| i64::try_from(value).ok())
1367            .map(Timestamp::from_millis)
1368            .map(ScalarLiteral::Timestamp),
1369        (Primitive::Ulid, Arg::String(value)) => {
1370            Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
1371        }
1372        (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
1373            Some(ScalarLiteral::Unit(Unit))
1374        }
1375        _ => None,
1376    }
1377}
1378
1379fn default_constructor_is_zero(default: &Arg) -> bool {
1380    let Arg::FuncPath(path) = default else {
1381        return false;
1382    };
1383    path.ends_with("::default")
1384        || path.ends_with("::new")
1385        || path.ends_with("::EPOCH")
1386        || path.ends_with("::nil")
1387}
1388
1389fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
1390    match primitive {
1391        Primitive::Blob => Some(ScalarLiteral::Blob(Blob::default())),
1392        Primitive::Bool => Some(ScalarLiteral::Bool(false)),
1393        Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
1394        Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
1395            .map(ScalarLiteral::Decimal),
1396        Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
1397        Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
1398        Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
1399        Primitive::Int8
1400        | Primitive::Int16
1401        | Primitive::Int32
1402        | Primitive::Int64
1403        | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
1404        Primitive::Nat8
1405        | Primitive::Nat16
1406        | Primitive::Nat32
1407        | Primitive::Nat64
1408        | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1409        Primitive::U256 => Some(ScalarLiteral::U256(U256::ZERO)),
1410        Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1411        Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1412        Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1413        Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1414        Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1415    }
1416}
1417
1418fn lower_blob_default(value: &str) -> Option<ScalarLiteral> {
1419    if value.len() > MAX_PROPOSAL_LITERAL_BYTES {
1420        return None;
1421    }
1422    Some(ScalarLiteral::Blob(Blob::from(value.as_bytes())))
1423}
1424
1425// -----------------------------------------------------------------------------
1426// Literal conversion helpers
1427// -----------------------------------------------------------------------------
1428
1429fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1430    match value.scale().cmp(&scale) {
1431        std::cmp::Ordering::Equal => Some(value),
1432        std::cmp::Ordering::Less => value
1433            .scale_to_integer(scale)
1434            .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1435        std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1436    }
1437}
1438
1439fn arg_i128(value: &ArgNumber) -> Option<i128> {
1440    match value {
1441        ArgNumber::Int8(value) => Some(i128::from(*value)),
1442        ArgNumber::Int16(value) => Some(i128::from(*value)),
1443        ArgNumber::Int32(value) => Some(i128::from(*value)),
1444        ArgNumber::Int64(value) => Some(i128::from(*value)),
1445        ArgNumber::Int128(value) => Some(*value),
1446        ArgNumber::Nat8(value) => Some(i128::from(*value)),
1447        ArgNumber::Nat16(value) => Some(i128::from(*value)),
1448        ArgNumber::Nat32(value) => Some(i128::from(*value)),
1449        ArgNumber::Nat64(value) => Some(i128::from(*value)),
1450        ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1451        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1452    }
1453}
1454
1455fn arg_u128(value: &ArgNumber) -> Option<u128> {
1456    match value {
1457        ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1458        ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1459        ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1460        ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1461        ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1462        ArgNumber::Nat8(value) => Some(u128::from(*value)),
1463        ArgNumber::Nat16(value) => Some(u128::from(*value)),
1464        ArgNumber::Nat32(value) => Some(u128::from(*value)),
1465        ArgNumber::Nat64(value) => Some(u128::from(*value)),
1466        ArgNumber::Nat128(value) => Some(*value),
1467        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1468    }
1469}
1470
1471fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1472    match value {
1473        ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1474        ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1475        _ => arg_i128(value).and_then(Decimal::from_i128),
1476    }
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481    use icydb_schema::{
1482        ConstraintFragmentKind, ConstraintSourceKey, Decimal, FieldSourceKey, FieldType,
1483        MAX_PROPOSAL_LITERAL_BYTES, NamedTypeFragment, RuleSourceKey, ScalarLiteral, ScalarType,
1484        SourceRuleOperation, TypeSourceKey,
1485    };
1486
1487    use super::{
1488        Schema, lower_blob_default, lower_field_rules, lower_nested_relations, lower_scalar_default,
1489    };
1490    use crate::{
1491        node::{
1492            Arg, Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item,
1493            ItemTarget, Newtype, Normalizer, PrimaryKey, PrimaryKeySource, Record, RuleNumber,
1494            SchemaNode, SourceRule, SourceRuleAuthoringOperation, Store, StoreHeapConfig, Type,
1495            TypeNormalizer, TypeValidator, Validator, Value,
1496        },
1497        types::{Cardinality, Primitive},
1498    };
1499
1500    #[test]
1501    fn blob_default_lowering_enforces_the_proposal_literal_bound() {
1502        let maximum = "a".repeat(MAX_PROPOSAL_LITERAL_BYTES);
1503        let oversized = "a".repeat(MAX_PROPOSAL_LITERAL_BYTES + 1);
1504
1505        assert_eq!(
1506            lower_blob_default(&maximum).and_then(|literal| match literal {
1507                ScalarLiteral::Blob(value) => Some(value.len()),
1508                _ => None,
1509            }),
1510            Some(MAX_PROPOSAL_LITERAL_BYTES),
1511        );
1512        assert_eq!(lower_blob_default(&oversized), None);
1513    }
1514
1515    #[test]
1516    fn duration_default_lowering_rejects_suffixed_overflow() {
1517        let item = Item::new(
1518            ItemTarget::Primitive(Primitive::Duration),
1519            None,
1520            None,
1521            None,
1522            &[],
1523            &[],
1524            false,
1525        );
1526
1527        assert_eq!(
1528            lower_scalar_default(
1529                Primitive::Duration,
1530                &item,
1531                &Arg::String("18446744073709551615ms"),
1532            ),
1533            Some(ScalarLiteral::Duration(icydb_schema::Duration::MAX)),
1534        );
1535        assert_eq!(
1536            lower_scalar_default(
1537                Primitive::Duration,
1538                &item,
1539                &Arg::String("18446744073709552s"),
1540            ),
1541            None,
1542        );
1543    }
1544
1545    static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1546    static APPLICATION_FIELDS: [Field; 1] = [Field::new(
1547        "id",
1548        Value::new(
1549            Cardinality::One,
1550            Item::new(
1551                ItemTarget::Primitive(Primitive::Nat64),
1552                None,
1553                None,
1554                None,
1555                &[],
1556                &[],
1557                false,
1558            ),
1559        ),
1560        None,
1561        None,
1562        None,
1563    )];
1564    static APPLICATION_NORMALIZERS_A: [TypeNormalizer; 1] =
1565        [TypeNormalizer::new("test::NormalizeA", Args(&[]))];
1566    static APPLICATION_NORMALIZERS_B: [TypeNormalizer; 1] =
1567        [TypeNormalizer::new("test::NormalizeB", Args(&[]))];
1568    static APPLICATION_VALIDATORS_A: [TypeValidator; 1] =
1569        [TypeValidator::new("test::ValidateA", Args(&[]))];
1570    static APPLICATION_VALIDATORS_B: [TypeValidator; 1] =
1571        [TypeValidator::new("test::ValidateB", Args(&[]))];
1572    static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1573        "range",
1574        SourceRuleAuthoringOperation::NumericRangeInclusive {
1575            min: RuleNumber::Integer("0"),
1576            max: RuleNumber::Integer("360"),
1577        },
1578    )];
1579    static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1580    static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1581        "length",
1582        SourceRuleAuthoringOperation::LengthRangeInclusive {
1583            min: RuleNumber::Integer("2"),
1584            max: RuleNumber::Integer("40"),
1585        },
1586    )];
1587    static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1588    static NAT_EXACT_RULES: [SourceRule; 2] = [
1589        SourceRule::new(
1590            "maximum",
1591            SourceRuleAuthoringOperation::NumericMaximumInclusive {
1592                value: RuleNumber::Integer("100"),
1593            },
1594        ),
1595        SourceRule::new(
1596            "step",
1597            SourceRuleAuthoringOperation::MultipleOf {
1598                divisor: RuleNumber::Integer("5"),
1599            },
1600        ),
1601    ];
1602    static NAT_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &NAT_EXACT_RULES);
1603    static DECIMAL_EXACT_RULES: [SourceRule; 1] = [SourceRule::new(
1604        "step",
1605        SourceRuleAuthoringOperation::MultipleOf {
1606            divisor: RuleNumber::Decimal("0.25"),
1607        },
1608    )];
1609    static DECIMAL_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &DECIMAL_EXACT_RULES);
1610    static INEXACT_DECIMAL_RULES: [SourceRule; 1] = [SourceRule::new(
1611        "step",
1612        SourceRuleAuthoringOperation::MultipleOf {
1613            divisor: RuleNumber::Decimal("0.251"),
1614        },
1615    )];
1616    static INEXACT_DECIMAL_RULE_TYPE: Type = Type::new(&[], &[], &INEXACT_DECIMAL_RULES);
1617    static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1618        "degrees",
1619        Value::new(
1620            Cardinality::One,
1621            Item::new(
1622                ItemTarget::Is("test::Degrees"),
1623                None,
1624                None,
1625                None,
1626                &[],
1627                &[],
1628                false,
1629            ),
1630        ),
1631        None,
1632        None,
1633        None,
1634    )];
1635    static STATUS_VARIANTS: [EnumVariant; 2] = [
1636        EnumVariant::new("Active", None),
1637        EnumVariant::new(
1638            "Retries",
1639            Some(Value::new(
1640                Cardinality::Many,
1641                Item::new(
1642                    ItemTarget::Primitive(Primitive::Nat16),
1643                    None,
1644                    None,
1645                    None,
1646                    &[],
1647                    &[],
1648                    false,
1649                ),
1650            )),
1651        ),
1652    ];
1653    static RECURSIVE_RECORD_FIELDS: [Field; 1] = [Field::new(
1654        "next",
1655        Value::new(
1656            Cardinality::Opt,
1657            Item::new(
1658                ItemTarget::Is("test::Recursive"),
1659                None,
1660                None,
1661                None,
1662                &[],
1663                &[],
1664                false,
1665            ),
1666        ),
1667        None,
1668        None,
1669        None,
1670    )];
1671    static RECURSIVE_ENTITY_FIELDS: [Field; 1] = [Field::new(
1672        "root",
1673        Value::new(
1674            Cardinality::One,
1675            Item::new(
1676                ItemTarget::Is("test::Recursive"),
1677                None,
1678                None,
1679                None,
1680                &[],
1681                &[],
1682                false,
1683            ),
1684        ),
1685        None,
1686        None,
1687        None,
1688    )];
1689
1690    #[test]
1691    fn recursive_structural_type_without_a_finite_relation_path_is_ignored() {
1692        let mut schema = Schema::new();
1693        schema.insert_node(SchemaNode::Record(Record::new(
1694            Def::new("test", "Recursive"),
1695            "Recursive",
1696            FieldList::new(&RECURSIVE_RECORD_FIELDS),
1697            EMPTY_TYPE.clone(),
1698        )));
1699        let entity = Entity::new(
1700            Def::new("test", "RecursiveOwner"),
1701            "test::Store",
1702            1,
1703            PrimaryKey::new(&["root"], PrimaryKeySource::External),
1704            &[],
1705            &[],
1706            &[],
1707            FieldList::new(&RECURSIVE_ENTITY_FIELDS),
1708            EMPTY_TYPE.clone(),
1709        );
1710
1711        assert!(
1712            lower_nested_relations(&schema, &entity, &RECURSIVE_ENTITY_FIELDS[0])
1713                .expect("recursive structural types without finite relations should lower")
1714                .is_empty(),
1715        );
1716    }
1717
1718    fn application_behavior_fragment(
1719        normalizers: &'static [TypeNormalizer],
1720        validators: &'static [TypeValidator],
1721        normalizer_name: &'static str,
1722        validator_name: &'static str,
1723    ) -> icydb_schema::SchemaFragment {
1724        let mut schema = Schema::new();
1725        schema.insert_node(SchemaNode::Canister(Canister::new(
1726            Def::new("test", "Canister"),
1727            "test",
1728            None,
1729        )));
1730        schema.insert_node(SchemaNode::Store(Store::new_heap(
1731            Def::new("test", "Store"),
1732            "test::Canister",
1733            StoreHeapConfig::new(),
1734        )));
1735        schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1736            "test",
1737            normalizer_name,
1738        ))));
1739        schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1740            "test",
1741            validator_name,
1742        ))));
1743        schema.insert_node(SchemaNode::Entity(Entity::new(
1744            Def::new("test", "ApplicationOnly"),
1745            "test::Store",
1746            1,
1747            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1748            &[],
1749            &[],
1750            &[],
1751            FieldList::new(&APPLICATION_FIELDS),
1752            Type::new(normalizers, validators, &[]),
1753        )));
1754        schema.seal().expect("application-only fixture should seal");
1755        schema
1756            .schema_fragment_for_canister("test::Canister")
1757            .expect("application-only fixture should lower")
1758    }
1759
1760    #[test]
1761    fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1762        let before = application_behavior_fragment(
1763            &APPLICATION_NORMALIZERS_A,
1764            &APPLICATION_VALIDATORS_A,
1765            "NormalizeA",
1766            "ValidateA",
1767        );
1768        let after = application_behavior_fragment(
1769            &APPLICATION_NORMALIZERS_B,
1770            &APPLICATION_VALIDATORS_B,
1771            "NormalizeB",
1772            "ValidateB",
1773        );
1774
1775        assert_eq!(before, after);
1776    }
1777
1778    #[test]
1779    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1780        let mut schema = Schema::new();
1781        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1782            Def::new("test", "Degrees"),
1783            "Degrees",
1784            Item::new(
1785                ItemTarget::Primitive(Primitive::Nat16),
1786                None,
1787                None,
1788                None,
1789                &[],
1790                &[],
1791                false,
1792            ),
1793            None,
1794            NUMERIC_RULE_TYPE.clone(),
1795        )));
1796        schema.insert_node(SchemaNode::Record(Record::new(
1797            Def::new("test", "Nested"),
1798            "Nested",
1799            FieldList::new(&NESTED_RULE_FIELDS),
1800            EMPTY_TYPE.clone(),
1801        )));
1802
1803        let outer = Field::new(
1804            "nested",
1805            Value::new(
1806                Cardinality::One,
1807                Item::new(
1808                    ItemTarget::Is("test::Nested"),
1809                    None,
1810                    None,
1811                    None,
1812                    &[],
1813                    &[],
1814                    false,
1815                ),
1816            ),
1817            None,
1818            None,
1819            None,
1820        );
1821        let constraints =
1822            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1823        assert_eq!(constraints.len(), 1);
1824        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1825            panic!("nested durable rule should use the targeted-rule contract")
1826        };
1827        assert_eq!(rule.root().as_str(), "nested");
1828        assert_eq!(rule.target_type().as_str(), "Degrees");
1829        assert!(matches!(
1830            rule.operation(),
1831            SourceRuleOperation::NumericRangeInclusive { .. }
1832        ));
1833    }
1834
1835    #[test]
1836    fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1837        let mut schema = Schema::new();
1838        for (name, primitive, scale, rules) in [
1839            (
1840                "Counter",
1841                Primitive::Nat64,
1842                None,
1843                NAT_EXACT_RULE_TYPE.clone(),
1844            ),
1845            (
1846                "PriceStep",
1847                Primitive::Decimal,
1848                Some(2),
1849                DECIMAL_EXACT_RULE_TYPE.clone(),
1850            ),
1851        ] {
1852            schema.insert_node(SchemaNode::Newtype(Newtype::new(
1853                Def::new("test", name),
1854                name,
1855                Item::new(
1856                    ItemTarget::Primitive(primitive),
1857                    None,
1858                    scale,
1859                    None,
1860                    &[],
1861                    &[],
1862                    false,
1863                ),
1864                None,
1865                rules,
1866            )));
1867        }
1868
1869        let field = |name| {
1870            Field::new(
1871                name,
1872                Value::new(
1873                    Cardinality::One,
1874                    Item::new(
1875                        ItemTarget::Is(if name == "counter" {
1876                            "test::Counter"
1877                        } else {
1878                            "test::PriceStep"
1879                        }),
1880                        None,
1881                        None,
1882                        None,
1883                        &[],
1884                        &[],
1885                        false,
1886                    ),
1887                ),
1888                None,
1889                None,
1890                None,
1891            )
1892        };
1893        let counter = lower_field_rules(&schema, &field("counter"))
1894            .expect("exact integer rules should lower");
1895        assert!(matches!(
1896            counter[0].kind(),
1897            ConstraintFragmentKind::TargetedRule(rule)
1898                if matches!(
1899                    rule.operation(),
1900                    SourceRuleOperation::NumericMaximumInclusive {
1901                        value: ScalarLiteral::Nat(100)
1902                    }
1903                )
1904        ));
1905        assert!(matches!(
1906            counter[1].kind(),
1907            ConstraintFragmentKind::TargetedRule(rule)
1908                if matches!(
1909                    rule.operation(),
1910                    SourceRuleOperation::MultipleOf {
1911                        divisor: ScalarLiteral::Nat(5)
1912                    }
1913                )
1914        ));
1915
1916        let decimal = lower_field_rules(&schema, &field("price"))
1917            .expect("exact decimal multiple should lower");
1918        assert!(matches!(
1919            decimal[0].kind(),
1920            ConstraintFragmentKind::TargetedRule(rule)
1921                if matches!(
1922                    rule.operation(),
1923                    SourceRuleOperation::MultipleOf {
1924                        divisor: ScalarLiteral::Decimal(value)
1925                    } if *value == Decimal::new(25, 2)
1926                )
1927        ));
1928    }
1929
1930    #[test]
1931    fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
1932        let mut schema = Schema::new();
1933        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1934            Def::new("test", "InexactStep"),
1935            "InexactStep",
1936            Item::new(
1937                ItemTarget::Primitive(Primitive::Decimal),
1938                None,
1939                Some(2),
1940                None,
1941                &[],
1942                &[],
1943                false,
1944            ),
1945            None,
1946            INEXACT_DECIMAL_RULE_TYPE.clone(),
1947        )));
1948        let field = Field::new(
1949            "price",
1950            Value::new(
1951                Cardinality::One,
1952                Item::new(
1953                    ItemTarget::Is("test::InexactStep"),
1954                    None,
1955                    None,
1956                    None,
1957                    &[],
1958                    &[],
1959                    false,
1960                ),
1961            ),
1962            None,
1963            None,
1964            None,
1965        );
1966        assert!(lower_field_rules(&schema, &field).is_err());
1967    }
1968
1969    static ENTITY_FIELDS: [Field; 5] = [
1970        Field::new(
1971            "id",
1972            Value::new(
1973                Cardinality::One,
1974                Item::new(
1975                    ItemTarget::Primitive(Primitive::Nat64),
1976                    None,
1977                    None,
1978                    None,
1979                    &[],
1980                    &[],
1981                    false,
1982                ),
1983            ),
1984            None,
1985            None,
1986            None,
1987        ),
1988        Field::new(
1989            "tags",
1990            Value::new(
1991                Cardinality::Many,
1992                Item::new(
1993                    ItemTarget::Primitive(Primitive::Text),
1994                    None,
1995                    None,
1996                    Some(32),
1997                    &[],
1998                    &[],
1999                    false,
2000                ),
2001            ),
2002            None,
2003            None,
2004            None,
2005        ),
2006        Field::new(
2007            "status",
2008            Value::new(
2009                Cardinality::One,
2010                Item::new(
2011                    ItemTarget::Is("test::Status"),
2012                    None,
2013                    None,
2014                    None,
2015                    &[],
2016                    &[],
2017                    false,
2018                ),
2019            ),
2020            Some(crate::node::Arg::ConstPath("test::Status::Active")),
2021            None,
2022            None,
2023        ),
2024        Field::new(
2025            "degrees",
2026            Value::new(
2027                Cardinality::One,
2028                Item::new(
2029                    ItemTarget::Is("test::Degrees"),
2030                    None,
2031                    None,
2032                    None,
2033                    &[],
2034                    &[],
2035                    false,
2036                ),
2037            ),
2038            None,
2039            None,
2040            None,
2041        ),
2042        Field::new(
2043            "label",
2044            Value::new(
2045                Cardinality::One,
2046                Item::new(
2047                    ItemTarget::Is("test::Label"),
2048                    None,
2049                    None,
2050                    None,
2051                    &[],
2052                    &[],
2053                    false,
2054                ),
2055            ),
2056            None,
2057            None,
2058            None,
2059        ),
2060    ];
2061
2062    #[test]
2063    #[expect(
2064        clippy::too_many_lines,
2065        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
2066    )]
2067    fn sealed_canister_graph_emits_store_free_database_closure() {
2068        let mut schema = Schema::new();
2069        schema.insert_node(SchemaNode::Canister(Canister::new(
2070            Def::new("test", "Canister"),
2071            "test",
2072            None,
2073        )));
2074        schema.insert_node(SchemaNode::Store(Store::new_heap(
2075            Def::new("test", "Store"),
2076            "test::Canister",
2077            StoreHeapConfig::new(),
2078        )));
2079        schema.insert_node(SchemaNode::Enum(Enum::new(
2080            Def::new("test", "Status"),
2081            "Status",
2082            &STATUS_VARIANTS,
2083            EMPTY_TYPE.clone(),
2084        )));
2085        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2086            Def::new("test", "Degrees"),
2087            "Degrees",
2088            Item::new(
2089                ItemTarget::Primitive(Primitive::Nat16),
2090                None,
2091                None,
2092                None,
2093                &[],
2094                &[],
2095                false,
2096            ),
2097            None,
2098            NUMERIC_RULE_TYPE.clone(),
2099        )));
2100        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2101            Def::new("test", "Label"),
2102            "Label",
2103            Item::new(
2104                ItemTarget::Primitive(Primitive::Text),
2105                None,
2106                None,
2107                None,
2108                &[],
2109                &[],
2110                false,
2111            ),
2112            None,
2113            LENGTH_RULE_TYPE.clone(),
2114        )));
2115        schema.insert_node(SchemaNode::Entity(Entity::new(
2116            Def::new("test", "Task"),
2117            "test::Store",
2118            1,
2119            PrimaryKey::new(&["id"], PrimaryKeySource::External),
2120            &[],
2121            &[],
2122            &[],
2123            FieldList::new(&ENTITY_FIELDS),
2124            EMPTY_TYPE.clone(),
2125        )));
2126        schema.seal().expect("fixture graph should seal");
2127
2128        let fragment = schema
2129            .schema_fragment_for_canister("test::Canister")
2130            .expect("sealed database closure should lower");
2131
2132        assert_eq!(fragment.entities().len(), 1);
2133        assert_eq!(fragment.types().len(), 3);
2134        let fields = fragment.entities()[0].fields();
2135        assert!(matches!(
2136            fields
2137                .iter()
2138                .find(|field| field.name().as_str() == "tags")
2139                .map(icydb_schema::FieldFragment::field_type),
2140            Some(FieldType::List(item))
2141                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
2142        ));
2143        assert!(matches!(
2144            fields
2145                .iter()
2146                .find(|field| field.name().as_str() == "status")
2147                .map(icydb_schema::FieldFragment::insert_policy),
2148            Some(icydb_schema::FieldInsertPolicy::Default(
2149                icydb_schema::ScalarLiteral::EnumUnit { .. }
2150            ))
2151        ));
2152        let NamedTypeFragment::Enum(status) = fragment
2153            .types()
2154            .iter()
2155            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
2156            .expect("reachable status type should remain an enum")
2157        else {
2158            panic!("reachable status type should remain an enum")
2159        };
2160        assert!(matches!(
2161            status
2162                .variants()
2163                .iter()
2164                .find(|variant| variant.name().as_str() == "Retries")
2165                .and_then(|variant| variant.payload()),
2166            Some(FieldType::List(item))
2167                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
2168        ));
2169
2170        let constraints = fragment.entities()[0].constraints();
2171        assert_eq!(constraints.len(), 2);
2172        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
2173            &FieldSourceKey::try_new("degrees").expect("field name"),
2174            &TypeSourceKey::try_new("Degrees").expect("type name"),
2175            &RuleSourceKey::try_new("range").expect("rule name"),
2176        );
2177        let degrees = constraints
2178            .iter()
2179            .find(|constraint| constraint.source_key() == &degrees_source)
2180            .expect("numeric rule should become one field-owned constraint");
2181        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
2182            panic!("numeric rule should use the targeted-rule contract")
2183        };
2184        assert_eq!(degrees.root().as_str(), "degrees");
2185        assert_eq!(degrees.target_type().as_str(), "Degrees");
2186        assert!(matches!(
2187            degrees.operation(),
2188            SourceRuleOperation::NumericRangeInclusive { .. }
2189        ));
2190        let label = constraints
2191            .iter()
2192            .find(|constraint| constraint.source_key() != &degrees_source)
2193            .expect("length rule should become one field-owned constraint");
2194        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
2195            panic!("length rule should use the targeted-rule contract")
2196        };
2197        assert_eq!(label.target_type().as_str(), "Label");
2198        assert!(matches!(
2199            label.operation(),
2200            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
2201        ));
2202    }
2203}