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, DEFAULT_BIG_INT_MAX_BYTES, Date, Decimal,
16    DeclaredEntityVersion, Duration, EntityFragment, EntitySourceKey, EnumTypeFragment,
17    EnumVariantFragment, FieldFragment, FieldInsertPolicy, FieldManagementPolicy, FieldSourceKey,
18    FieldType, Float32, Float64, IndexFragment, IndexKeyFragment, IntBig,
19    MAX_PROPOSAL_LITERAL_BYTES, NamedTypeFragment, NatBig, Principal, RecordFieldFragment,
20    RecordTypeFragment, RelationDeleteAction, RelationFragment, RelationPathStepFragment,
21    RelationSourceFragment, RuleSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
22    SchemaFragment, SchemaName, SourceRuleOperation as ProposalSourceRuleOperation, Subaccount,
23    TargetedRuleFragment, 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::IntBig => rule_integer_text(value)
886            .and_then(|value| IntBig::from_str(value).ok())
887            .map(ScalarLiteral::IntBig),
888        Primitive::Nat8
889        | Primitive::Nat16
890        | Primitive::Nat32
891        | Primitive::Nat64
892        | Primitive::Nat128 => rule_integer_u128(value).map(ScalarLiteral::Nat),
893        Primitive::NatBig => rule_integer_text(value)
894            .and_then(|value| NatBig::from_str(value).ok())
895            .map(ScalarLiteral::NatBig),
896        Primitive::U256 => rule_integer_text(value)
897            .and_then(|value| U256::from_str(value).ok())
898            .map(ScalarLiteral::U256),
899        Primitive::Account
900        | Primitive::Blob
901        | Primitive::Bool
902        | Primitive::Date
903        | Primitive::Duration
904        | Primitive::Principal
905        | Primitive::Subaccount
906        | Primitive::Text
907        | Primitive::Timestamp
908        | Primitive::Ulid
909        | Primitive::Unit => None,
910    }
911}
912
913const fn rule_integer_text(value: &RuleNumber) -> Option<&str> {
914    let RuleNumber::Integer(value) = value else {
915        return None;
916    };
917    Some(value)
918}
919
920fn rule_integer_i128(value: &RuleNumber) -> Option<i128> {
921    rule_integer_text(value)?.parse().ok()
922}
923
924fn rule_integer_u128(value: &RuleNumber) -> Option<u128> {
925    rule_integer_text(value)?.parse().ok()
926}
927
928fn rule_decimal(value: &RuleNumber) -> Option<Decimal> {
929    match value {
930        RuleNumber::Integer(value) | RuleNumber::Decimal(value) => Decimal::from_str(value).ok(),
931        RuleNumber::Float32(_) | RuleNumber::Float64(_) => None,
932    }
933}
934
935fn exact_decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
936    let value = value.normalize();
937    value
938        .scale_to_integer(scale)
939        .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale))
940}
941
942#[derive(Clone, Copy)]
943enum RuleValueShape<'schema> {
944    Collection,
945    Scalar(Primitive, &'schema Item),
946}
947
948fn resolve_rule_value_shape<'schema>(
949    schema: &'schema Schema,
950    mut target: &'schema SchemaNode,
951) -> Result<RuleValueShape<'schema>, FragmentLoweringError> {
952    let mut visited = BTreeSet::new();
953    loop {
954        let source = named_type_name(target)
955            .ok_or_else(|| FragmentLoweringError::InvalidReference("non-type rule".to_string()))?;
956        if !visited.insert(source) {
957            return Err(FragmentLoweringError::InvalidReference(format!(
958                "durable-rule target cycle at '{source}'"
959            )));
960        }
961        match target {
962            SchemaNode::List(_) | SchemaNode::Map(_) | SchemaNode::Set(_) => {
963                return Ok(RuleValueShape::Collection);
964            }
965            SchemaNode::Newtype(newtype) => match newtype.item().target() {
966                ItemTarget::Primitive(primitive) => {
967                    return Ok(RuleValueShape::Scalar(*primitive, newtype.item()));
968                }
969                ItemTarget::Is(path) => {
970                    target = schema
971                        .get_node(path)
972                        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
973                }
974            },
975            SchemaNode::Record(_) | SchemaNode::Enum(_) | SchemaNode::Tuple(_) => {
976                return Err(FragmentLoweringError::InvalidReference(format!(
977                    "durable-rule target '{source}' has no supported scalar or collection value"
978                )));
979            }
980            SchemaNode::Canister(_)
981            | SchemaNode::Entity(_)
982            | SchemaNode::Normalizer(_)
983            | SchemaNode::Store(_)
984            | SchemaNode::Validator(_) => {
985                return Err(FragmentLoweringError::InvalidReference(
986                    "non-type durable-rule target".to_string(),
987                ));
988            }
989        }
990    }
991}
992
993fn invalid_rule_target(rule: &SourceRule) -> FragmentLoweringError {
994    FragmentLoweringError::InvalidReference(format!(
995        "durable rule '{}' does not match its nominal target",
996        rule.name()
997    ))
998}
999
1000fn entity_field_source_key(
1001    entity: &Entity,
1002    field_name: &str,
1003) -> Result<FieldSourceKey, FragmentLoweringError> {
1004    let field = entity
1005        .fields()
1006        .get(field_name)
1007        .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
1008    FieldSourceKey::try_new(field.name()).map_err(Into::into)
1009}
1010
1011// -----------------------------------------------------------------------------
1012// Reachable named-type closure
1013// -----------------------------------------------------------------------------
1014
1015fn lower_reachable_types(
1016    schema: &Schema,
1017    mut pending: Vec<String>,
1018) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
1019    let mut lowered = BTreeMap::new();
1020    while let Some(path) = pending.pop() {
1021        let node = schema
1022            .get_node(path.as_str())
1023            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1024        let source_key = named_type_name(node)
1025            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1026        if lowered.contains_key(source_key) {
1027            continue;
1028        }
1029        let fragment = lower_named_type(schema, node, &mut pending)?;
1030        lowered.insert(source_key.to_string(), fragment);
1031    }
1032    Ok(lowered.into_values().collect())
1033}
1034
1035const fn named_type_name(node: &crate::node::SchemaNode) -> Option<&str> {
1036    match node {
1037        crate::node::SchemaNode::Enum(node) => Some(node.name()),
1038        crate::node::SchemaNode::List(node) => Some(node.name()),
1039        crate::node::SchemaNode::Map(node) => Some(node.name()),
1040        crate::node::SchemaNode::Newtype(node) => Some(node.name()),
1041        crate::node::SchemaNode::Record(node) => Some(node.name()),
1042        crate::node::SchemaNode::Set(node) => Some(node.name()),
1043        crate::node::SchemaNode::Tuple(node) => Some(node.name()),
1044        crate::node::SchemaNode::Canister(_)
1045        | crate::node::SchemaNode::Entity(_)
1046        | crate::node::SchemaNode::Normalizer(_)
1047        | crate::node::SchemaNode::Store(_)
1048        | crate::node::SchemaNode::Validator(_) => None,
1049    }
1050}
1051
1052fn lower_named_type(
1053    schema: &Schema,
1054    node: &crate::node::SchemaNode,
1055    pending: &mut Vec<String>,
1056) -> Result<NamedTypeFragment, FragmentLoweringError> {
1057    match node {
1058        crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
1059        crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
1060        crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::newtype(
1061            SchemaName::try_new(newtype.name())?,
1062            lower_item_type(schema, newtype.item(), pending)?,
1063        )),
1064        crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
1065        crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
1066        crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
1067        crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
1068        crate::node::SchemaNode::Canister(_)
1069        | crate::node::SchemaNode::Entity(_)
1070        | crate::node::SchemaNode::Normalizer(_)
1071        | crate::node::SchemaNode::Store(_)
1072        | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
1073            "non-type graph node".to_string(),
1074        )),
1075    }
1076}
1077
1078fn lower_record(
1079    schema: &Schema,
1080    record: &Record,
1081    pending: &mut Vec<String>,
1082) -> Result<NamedTypeFragment, FragmentLoweringError> {
1083    let fields = record
1084        .fields()
1085        .fields()
1086        .iter()
1087        .map(|field| {
1088            Ok(RecordFieldFragment::new(
1089                SchemaName::try_new(field.name())?,
1090                lower_value_type(schema, field.value(), pending)?,
1091                field.value().cardinality() == Cardinality::Opt,
1092            ))
1093        })
1094        .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
1095    Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
1096        SchemaName::try_new(record.name())?,
1097        fields,
1098    )?))
1099}
1100
1101fn lower_enum(
1102    schema: &Schema,
1103    r#enum: &Enum,
1104    pending: &mut Vec<String>,
1105) -> Result<NamedTypeFragment, FragmentLoweringError> {
1106    let variants = r#enum
1107        .variants()
1108        .iter()
1109        .map(|variant| {
1110            let name = SchemaName::try_new(variant.name())?;
1111            match variant.value() {
1112                Some(value) if value.cardinality() == Cardinality::Opt => {
1113                    Err(FragmentLoweringError::UnsupportedCardinality(format!(
1114                        "{}::{}",
1115                        r#enum.def().path(),
1116                        variant.name()
1117                    )))
1118                }
1119                Some(value) => Ok(EnumVariantFragment::with_payload(
1120                    name,
1121                    lower_value_type(schema, value, pending)?,
1122                )),
1123                None => Ok(EnumVariantFragment::new(name)),
1124            }
1125        })
1126        .collect::<Result<Vec<_>, _>>()?;
1127    Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
1128        SchemaName::try_new(r#enum.name())?,
1129        variants,
1130    )?))
1131}
1132
1133fn lower_list(
1134    schema: &Schema,
1135    list: &List,
1136    pending: &mut Vec<String>,
1137) -> Result<NamedTypeFragment, FragmentLoweringError> {
1138    Ok(NamedTypeFragment::list(
1139        SchemaName::try_new(list.name())?,
1140        lower_item_type(schema, list.item(), pending)?,
1141    ))
1142}
1143
1144fn lower_set(
1145    schema: &Schema,
1146    set: &Set,
1147    pending: &mut Vec<String>,
1148) -> Result<NamedTypeFragment, FragmentLoweringError> {
1149    Ok(NamedTypeFragment::set(
1150        SchemaName::try_new(set.name())?,
1151        lower_item_type(schema, set.item(), pending)?,
1152    ))
1153}
1154
1155fn lower_map(
1156    schema: &Schema,
1157    map: &Map,
1158    pending: &mut Vec<String>,
1159) -> Result<NamedTypeFragment, FragmentLoweringError> {
1160    if map.value().cardinality() == Cardinality::Opt {
1161        return Err(FragmentLoweringError::UnsupportedCardinality(
1162            map.def().path(),
1163        ));
1164    }
1165    Ok(NamedTypeFragment::map(
1166        SchemaName::try_new(map.name())?,
1167        lower_item_type(schema, map.key(), pending)?,
1168        lower_value_type(schema, map.value(), pending)?,
1169    ))
1170}
1171
1172fn lower_tuple(
1173    schema: &Schema,
1174    tuple: &Tuple,
1175    pending: &mut Vec<String>,
1176) -> Result<NamedTypeFragment, FragmentLoweringError> {
1177    let members = tuple
1178        .values()
1179        .iter()
1180        .map(|value| {
1181            Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
1182                lower_value_type(schema, value, pending)?,
1183                value.cardinality() == Cardinality::Opt,
1184            ))
1185        })
1186        .collect::<Result<Vec<_>, _>>()?;
1187    Ok(NamedTypeFragment::tuple(
1188        SchemaName::try_new(tuple.name())?,
1189        members,
1190    ))
1191}
1192
1193// -----------------------------------------------------------------------------
1194// Exact field contracts
1195// -----------------------------------------------------------------------------
1196
1197fn lower_value_type(
1198    schema: &Schema,
1199    value: &Value,
1200    pending: &mut Vec<String>,
1201) -> Result<FieldType, FragmentLoweringError> {
1202    let item = lower_item_type(schema, value.item(), pending)?;
1203    Ok(if value.cardinality() == Cardinality::Many {
1204        FieldType::List(Box::new(item))
1205    } else {
1206        item
1207    })
1208}
1209
1210fn lower_item_type(
1211    schema: &Schema,
1212    item: &Item,
1213    pending: &mut Vec<String>,
1214) -> Result<FieldType, FragmentLoweringError> {
1215    match item.target() {
1216        ItemTarget::Is(path) => {
1217            pending.push((*path).to_string());
1218            Ok(FieldType::Named(TypeSourceKey::try_new(
1219                type_source_key_for_path(schema, path)?,
1220            )?))
1221        }
1222        ItemTarget::Primitive(primitive) => {
1223            Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
1224        }
1225    }
1226}
1227
1228fn type_source_key_for_path<'schema>(
1229    schema: &'schema Schema,
1230    path: &str,
1231) -> Result<&'schema str, FragmentLoweringError> {
1232    let source = schema
1233        .get_node(path)
1234        .and_then(named_type_name)
1235        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
1236    Ok(source)
1237}
1238
1239fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
1240    match primitive {
1241        Primitive::Account => ScalarType::Account,
1242        Primitive::Blob => ScalarType::Blob {
1243            max_len: item.max_len(),
1244        },
1245        Primitive::Bool => ScalarType::Bool,
1246        Primitive::Date => ScalarType::Date,
1247        Primitive::Decimal => ScalarType::Decimal {
1248            scale: item.scale().unwrap_or(0),
1249        },
1250        Primitive::Duration => ScalarType::Duration,
1251        Primitive::Float32 => ScalarType::Float32,
1252        Primitive::Float64 => ScalarType::Float64,
1253        Primitive::Int8 => ScalarType::Int8,
1254        Primitive::Int16 => ScalarType::Int16,
1255        Primitive::Int32 => ScalarType::Int32,
1256        Primitive::Int64 => ScalarType::Int64,
1257        Primitive::Int128 => ScalarType::Int128,
1258        Primitive::IntBig => ScalarType::IntBig {
1259            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
1260        },
1261        Primitive::Nat8 => ScalarType::Nat8,
1262        Primitive::Nat16 => ScalarType::Nat16,
1263        Primitive::Nat32 => ScalarType::Nat32,
1264        Primitive::Nat64 => ScalarType::Nat64,
1265        Primitive::Nat128 => ScalarType::Nat128,
1266        Primitive::NatBig => ScalarType::NatBig {
1267            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
1268        },
1269        Primitive::U256 => ScalarType::U256,
1270        Primitive::Principal => ScalarType::Principal,
1271        Primitive::Subaccount => ScalarType::Subaccount,
1272        Primitive::Text => ScalarType::Text {
1273            max_len: item.max_len(),
1274        },
1275        Primitive::Timestamp => ScalarType::Timestamp,
1276        Primitive::Ulid => ScalarType::Ulid,
1277        Primitive::Unit => ScalarType::Unit,
1278    }
1279}
1280
1281// -----------------------------------------------------------------------------
1282// Authored database defaults
1283// -----------------------------------------------------------------------------
1284
1285fn lower_default(
1286    schema: &Schema,
1287    field: &Field,
1288    default: &Arg,
1289) -> Result<ScalarLiteral, FragmentLoweringError> {
1290    if let ItemTarget::Is(path) = field.value().item().target() {
1291        let Arg::ConstPath(default_path) = default else {
1292            return Err(FragmentLoweringError::InvalidDefault(
1293                field.name().to_string(),
1294            ));
1295        };
1296        let variant = default_path.rsplit("::").next().unwrap_or(default_path);
1297        return schema
1298            .enum_unit_literal(path, variant)
1299            .map_err(FragmentLoweringError::from);
1300    }
1301    let ItemTarget::Primitive(primitive) = field.value().item().target() else {
1302        return Err(FragmentLoweringError::InvalidDefault(
1303            field.name().to_string(),
1304        ));
1305    };
1306    lower_scalar_default(*primitive, field.value().item(), default)
1307        .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.name().to_string()))
1308}
1309
1310fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
1311    if default_constructor_is_zero(default) {
1312        return zero_scalar_literal(primitive, item);
1313    }
1314    match (primitive, default) {
1315        (Primitive::Account, Arg::String(value)) => {
1316            Account::from_str(value).ok().map(ScalarLiteral::Account)
1317        }
1318        (Primitive::Blob, Arg::String(value)) => lower_blob_default(value),
1319        (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
1320        (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
1321        (Primitive::Date, Arg::Number(value)) => arg_i128(value)
1322            .and_then(|value| i32::try_from(value).ok())
1323            .and_then(Date::try_from_days_since_epoch)
1324            .map(ScalarLiteral::Date),
1325        (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
1326            .ok()
1327            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1328            .map(ScalarLiteral::Decimal),
1329        (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
1330            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1331            .map(ScalarLiteral::Decimal),
1332        (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
1333            .ok()
1334            .map(ScalarLiteral::Duration),
1335        (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
1336            .and_then(|value| u64::try_from(value).ok())
1337            .map(Duration::from_millis)
1338            .map(ScalarLiteral::Duration),
1339        (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
1340            Float32::try_new(*value).map(ScalarLiteral::Float32)
1341        }
1342        (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
1343            Float64::try_new(*value).map(ScalarLiteral::Float64)
1344        }
1345        (
1346            Primitive::Int8
1347            | Primitive::Int16
1348            | Primitive::Int32
1349            | Primitive::Int64
1350            | Primitive::Int128,
1351            Arg::Number(value),
1352        ) => arg_i128(value).map(ScalarLiteral::Int),
1353        (Primitive::IntBig, Arg::Number(value)) => arg_i128(value)
1354            .map(|value| value.to_string())
1355            .and_then(|value| IntBig::from_str(value.as_str()).ok())
1356            .map(ScalarLiteral::IntBig),
1357        (Primitive::IntBig, Arg::String(value)) => {
1358            IntBig::from_str(value).ok().map(ScalarLiteral::IntBig)
1359        }
1360        (
1361            Primitive::Nat8
1362            | Primitive::Nat16
1363            | Primitive::Nat32
1364            | Primitive::Nat64
1365            | Primitive::Nat128,
1366            Arg::Number(value),
1367        ) => arg_u128(value).map(ScalarLiteral::Nat),
1368        (Primitive::NatBig, Arg::Number(value)) => arg_u128(value)
1369            .map(|value| value.to_string())
1370            .and_then(|value| NatBig::from_str(value.as_str()).ok())
1371            .map(ScalarLiteral::NatBig),
1372        (Primitive::NatBig, Arg::String(value)) => {
1373            NatBig::from_str(value).ok().map(ScalarLiteral::NatBig)
1374        }
1375        (Primitive::U256, Arg::Number(value)) => {
1376            arg_u128(value).map(U256::from).map(ScalarLiteral::U256)
1377        }
1378        (Primitive::U256, Arg::String(value)) => {
1379            U256::from_str(value).ok().map(ScalarLiteral::U256)
1380        }
1381        (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
1382            .ok()
1383            .map(ScalarLiteral::Principal),
1384        (Primitive::Subaccount, Arg::String(value)) => parse_subaccount(value)
1385            .map(Subaccount::from_array)
1386            .map(ScalarLiteral::Subaccount),
1387        (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
1388        (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
1389            .ok()
1390            .map(ScalarLiteral::Timestamp),
1391        (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
1392            .and_then(|value| i64::try_from(value).ok())
1393            .map(Timestamp::from_millis)
1394            .map(ScalarLiteral::Timestamp),
1395        (Primitive::Ulid, Arg::String(value)) => {
1396            Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
1397        }
1398        (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
1399            Some(ScalarLiteral::Unit(Unit))
1400        }
1401        _ => None,
1402    }
1403}
1404
1405fn default_constructor_is_zero(default: &Arg) -> bool {
1406    let Arg::FuncPath(path) = default else {
1407        return false;
1408    };
1409    path.ends_with("::default")
1410        || path.ends_with("::new")
1411        || path.ends_with("::EPOCH")
1412        || path.ends_with("::nil")
1413}
1414
1415fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
1416    match primitive {
1417        Primitive::Blob => Some(ScalarLiteral::Blob(Blob::default())),
1418        Primitive::Bool => Some(ScalarLiteral::Bool(false)),
1419        Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
1420        Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
1421            .map(ScalarLiteral::Decimal),
1422        Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
1423        Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
1424        Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
1425        Primitive::Int8
1426        | Primitive::Int16
1427        | Primitive::Int32
1428        | Primitive::Int64
1429        | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
1430        Primitive::IntBig => IntBig::from_str("0").ok().map(ScalarLiteral::IntBig),
1431        Primitive::Nat8
1432        | Primitive::Nat16
1433        | Primitive::Nat32
1434        | Primitive::Nat64
1435        | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1436        Primitive::NatBig => NatBig::from_str("0").ok().map(ScalarLiteral::NatBig),
1437        Primitive::U256 => Some(ScalarLiteral::U256(U256::ZERO)),
1438        Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1439        Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1440        Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1441        Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1442        Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1443    }
1444}
1445
1446fn lower_blob_default(value: &str) -> Option<ScalarLiteral> {
1447    if value.len() > MAX_PROPOSAL_LITERAL_BYTES {
1448        return None;
1449    }
1450    Some(ScalarLiteral::Blob(Blob::from(value.as_bytes())))
1451}
1452
1453// -----------------------------------------------------------------------------
1454// Literal conversion helpers
1455// -----------------------------------------------------------------------------
1456
1457fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1458    match value.scale().cmp(&scale) {
1459        std::cmp::Ordering::Equal => Some(value),
1460        std::cmp::Ordering::Less => value
1461            .scale_to_integer(scale)
1462            .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1463        std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1464    }
1465}
1466
1467fn arg_i128(value: &ArgNumber) -> Option<i128> {
1468    match value {
1469        ArgNumber::Int8(value) => Some(i128::from(*value)),
1470        ArgNumber::Int16(value) => Some(i128::from(*value)),
1471        ArgNumber::Int32(value) => Some(i128::from(*value)),
1472        ArgNumber::Int64(value) => Some(i128::from(*value)),
1473        ArgNumber::Int128(value) => Some(*value),
1474        ArgNumber::Nat8(value) => Some(i128::from(*value)),
1475        ArgNumber::Nat16(value) => Some(i128::from(*value)),
1476        ArgNumber::Nat32(value) => Some(i128::from(*value)),
1477        ArgNumber::Nat64(value) => Some(i128::from(*value)),
1478        ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1479        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1480    }
1481}
1482
1483fn arg_u128(value: &ArgNumber) -> Option<u128> {
1484    match value {
1485        ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1486        ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1487        ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1488        ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1489        ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1490        ArgNumber::Nat8(value) => Some(u128::from(*value)),
1491        ArgNumber::Nat16(value) => Some(u128::from(*value)),
1492        ArgNumber::Nat32(value) => Some(u128::from(*value)),
1493        ArgNumber::Nat64(value) => Some(u128::from(*value)),
1494        ArgNumber::Nat128(value) => Some(*value),
1495        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1496    }
1497}
1498
1499fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1500    match value {
1501        ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1502        ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1503        _ => arg_i128(value).and_then(Decimal::from_i128),
1504    }
1505}
1506
1507fn parse_subaccount(value: &str) -> Option<[u8; 32]> {
1508    if value.len() != 64 {
1509        return None;
1510    }
1511    let mut bytes = [0; 32];
1512    for (index, chunk) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
1513        let text = std::str::from_utf8(chunk).ok()?;
1514        bytes[index] = u8::from_str_radix(text, 16).ok()?;
1515    }
1516    Some(bytes)
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use icydb_schema::{
1522        ConstraintFragmentKind, ConstraintSourceKey, Decimal, FieldSourceKey, FieldType,
1523        MAX_PROPOSAL_LITERAL_BYTES, NamedTypeFragment, RuleSourceKey, ScalarLiteral, ScalarType,
1524        SourceRuleOperation, TypeSourceKey,
1525    };
1526
1527    use super::{
1528        Schema, lower_blob_default, lower_field_rules, lower_nested_relations, lower_scalar_default,
1529    };
1530    use crate::{
1531        node::{
1532            Arg, Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item,
1533            ItemTarget, Newtype, Normalizer, PrimaryKey, PrimaryKeySource, Record, RuleNumber,
1534            SchemaNode, SourceRule, SourceRuleAuthoringOperation, Store, StoreHeapConfig, Type,
1535            TypeNormalizer, TypeValidator, Validator, Value,
1536        },
1537        types::{Cardinality, Primitive},
1538    };
1539
1540    #[test]
1541    fn blob_default_lowering_enforces_the_proposal_literal_bound() {
1542        let maximum = "a".repeat(MAX_PROPOSAL_LITERAL_BYTES);
1543        let oversized = "a".repeat(MAX_PROPOSAL_LITERAL_BYTES + 1);
1544
1545        assert_eq!(
1546            lower_blob_default(&maximum).and_then(|literal| match literal {
1547                ScalarLiteral::Blob(value) => Some(value.len()),
1548                _ => None,
1549            }),
1550            Some(MAX_PROPOSAL_LITERAL_BYTES),
1551        );
1552        assert_eq!(lower_blob_default(&oversized), None);
1553    }
1554
1555    #[test]
1556    fn duration_default_lowering_rejects_suffixed_overflow() {
1557        let item = Item::new(
1558            ItemTarget::Primitive(Primitive::Duration),
1559            None,
1560            None,
1561            None,
1562            None,
1563            &[],
1564            &[],
1565            false,
1566        );
1567
1568        assert_eq!(
1569            lower_scalar_default(
1570                Primitive::Duration,
1571                &item,
1572                &Arg::String("18446744073709551615ms"),
1573            ),
1574            Some(ScalarLiteral::Duration(icydb_schema::Duration::MAX)),
1575        );
1576        assert_eq!(
1577            lower_scalar_default(
1578                Primitive::Duration,
1579                &item,
1580                &Arg::String("18446744073709552s"),
1581            ),
1582            None,
1583        );
1584    }
1585
1586    static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1587    static APPLICATION_FIELDS: [Field; 1] = [Field::new(
1588        "id",
1589        Value::new(
1590            Cardinality::One,
1591            Item::new(
1592                ItemTarget::Primitive(Primitive::Nat64),
1593                None,
1594                None,
1595                None,
1596                None,
1597                &[],
1598                &[],
1599                false,
1600            ),
1601        ),
1602        None,
1603        None,
1604        None,
1605    )];
1606    static APPLICATION_NORMALIZERS_A: [TypeNormalizer; 1] =
1607        [TypeNormalizer::new("test::NormalizeA", Args(&[]))];
1608    static APPLICATION_NORMALIZERS_B: [TypeNormalizer; 1] =
1609        [TypeNormalizer::new("test::NormalizeB", Args(&[]))];
1610    static APPLICATION_VALIDATORS_A: [TypeValidator; 1] =
1611        [TypeValidator::new("test::ValidateA", Args(&[]))];
1612    static APPLICATION_VALIDATORS_B: [TypeValidator; 1] =
1613        [TypeValidator::new("test::ValidateB", Args(&[]))];
1614    static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1615        "range",
1616        SourceRuleAuthoringOperation::NumericRangeInclusive {
1617            min: RuleNumber::Integer("0"),
1618            max: RuleNumber::Integer("360"),
1619        },
1620    )];
1621    static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1622    static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1623        "length",
1624        SourceRuleAuthoringOperation::LengthRangeInclusive {
1625            min: RuleNumber::Integer("2"),
1626            max: RuleNumber::Integer("40"),
1627        },
1628    )];
1629    static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1630    static NAT_EXACT_RULES: [SourceRule; 2] = [
1631        SourceRule::new(
1632            "maximum",
1633            SourceRuleAuthoringOperation::NumericMaximumInclusive {
1634                value: RuleNumber::Integer("100"),
1635            },
1636        ),
1637        SourceRule::new(
1638            "step",
1639            SourceRuleAuthoringOperation::MultipleOf {
1640                divisor: RuleNumber::Integer("5"),
1641            },
1642        ),
1643    ];
1644    static NAT_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &NAT_EXACT_RULES);
1645    static DECIMAL_EXACT_RULES: [SourceRule; 1] = [SourceRule::new(
1646        "step",
1647        SourceRuleAuthoringOperation::MultipleOf {
1648            divisor: RuleNumber::Decimal("0.25"),
1649        },
1650    )];
1651    static DECIMAL_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &DECIMAL_EXACT_RULES);
1652    static INEXACT_DECIMAL_RULES: [SourceRule; 1] = [SourceRule::new(
1653        "step",
1654        SourceRuleAuthoringOperation::MultipleOf {
1655            divisor: RuleNumber::Decimal("0.251"),
1656        },
1657    )];
1658    static INEXACT_DECIMAL_RULE_TYPE: Type = Type::new(&[], &[], &INEXACT_DECIMAL_RULES);
1659    static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1660        "degrees",
1661        Value::new(
1662            Cardinality::One,
1663            Item::new(
1664                ItemTarget::Is("test::Degrees"),
1665                None,
1666                None,
1667                None,
1668                None,
1669                &[],
1670                &[],
1671                false,
1672            ),
1673        ),
1674        None,
1675        None,
1676        None,
1677    )];
1678    static STATUS_VARIANTS: [EnumVariant; 2] = [
1679        EnumVariant::new("Active", None),
1680        EnumVariant::new(
1681            "Retries",
1682            Some(Value::new(
1683                Cardinality::Many,
1684                Item::new(
1685                    ItemTarget::Primitive(Primitive::Nat16),
1686                    None,
1687                    None,
1688                    None,
1689                    None,
1690                    &[],
1691                    &[],
1692                    false,
1693                ),
1694            )),
1695        ),
1696    ];
1697    static RECURSIVE_RECORD_FIELDS: [Field; 1] = [Field::new(
1698        "next",
1699        Value::new(
1700            Cardinality::Opt,
1701            Item::new(
1702                ItemTarget::Is("test::Recursive"),
1703                None,
1704                None,
1705                None,
1706                None,
1707                &[],
1708                &[],
1709                false,
1710            ),
1711        ),
1712        None,
1713        None,
1714        None,
1715    )];
1716    static RECURSIVE_ENTITY_FIELDS: [Field; 1] = [Field::new(
1717        "root",
1718        Value::new(
1719            Cardinality::One,
1720            Item::new(
1721                ItemTarget::Is("test::Recursive"),
1722                None,
1723                None,
1724                None,
1725                None,
1726                &[],
1727                &[],
1728                false,
1729            ),
1730        ),
1731        None,
1732        None,
1733        None,
1734    )];
1735
1736    #[test]
1737    fn recursive_structural_type_without_a_finite_relation_path_is_ignored() {
1738        let mut schema = Schema::new();
1739        schema.insert_node(SchemaNode::Record(Record::new(
1740            Def::new("test", "Recursive"),
1741            "Recursive",
1742            FieldList::new(&RECURSIVE_RECORD_FIELDS),
1743            EMPTY_TYPE.clone(),
1744        )));
1745        let entity = Entity::new(
1746            Def::new("test", "RecursiveOwner"),
1747            "test::Store",
1748            1,
1749            PrimaryKey::new(&["root"], PrimaryKeySource::External),
1750            &[],
1751            &[],
1752            &[],
1753            FieldList::new(&RECURSIVE_ENTITY_FIELDS),
1754            EMPTY_TYPE.clone(),
1755        );
1756
1757        assert!(
1758            lower_nested_relations(&schema, &entity, &RECURSIVE_ENTITY_FIELDS[0])
1759                .expect("recursive structural types without finite relations should lower")
1760                .is_empty(),
1761        );
1762    }
1763
1764    fn application_behavior_fragment(
1765        normalizers: &'static [TypeNormalizer],
1766        validators: &'static [TypeValidator],
1767        normalizer_name: &'static str,
1768        validator_name: &'static str,
1769    ) -> icydb_schema::SchemaFragment {
1770        let mut schema = Schema::new();
1771        schema.insert_node(SchemaNode::Canister(Canister::new(
1772            Def::new("test", "Canister"),
1773            "test",
1774            None,
1775        )));
1776        schema.insert_node(SchemaNode::Store(Store::new_heap(
1777            Def::new("test", "Store"),
1778            "test::Canister",
1779            StoreHeapConfig::new(),
1780        )));
1781        schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1782            "test",
1783            normalizer_name,
1784        ))));
1785        schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1786            "test",
1787            validator_name,
1788        ))));
1789        schema.insert_node(SchemaNode::Entity(Entity::new(
1790            Def::new("test", "ApplicationOnly"),
1791            "test::Store",
1792            1,
1793            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1794            &[],
1795            &[],
1796            &[],
1797            FieldList::new(&APPLICATION_FIELDS),
1798            Type::new(normalizers, validators, &[]),
1799        )));
1800        schema.seal().expect("application-only fixture should seal");
1801        schema
1802            .schema_fragment_for_canister("test::Canister")
1803            .expect("application-only fixture should lower")
1804    }
1805
1806    #[test]
1807    fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1808        let before = application_behavior_fragment(
1809            &APPLICATION_NORMALIZERS_A,
1810            &APPLICATION_VALIDATORS_A,
1811            "NormalizeA",
1812            "ValidateA",
1813        );
1814        let after = application_behavior_fragment(
1815            &APPLICATION_NORMALIZERS_B,
1816            &APPLICATION_VALIDATORS_B,
1817            "NormalizeB",
1818            "ValidateB",
1819        );
1820
1821        assert_eq!(before, after);
1822    }
1823
1824    #[test]
1825    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1826        let mut schema = Schema::new();
1827        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1828            Def::new("test", "Degrees"),
1829            "Degrees",
1830            Item::new(
1831                ItemTarget::Primitive(Primitive::Nat16),
1832                None,
1833                None,
1834                None,
1835                None,
1836                &[],
1837                &[],
1838                false,
1839            ),
1840            None,
1841            NUMERIC_RULE_TYPE.clone(),
1842        )));
1843        schema.insert_node(SchemaNode::Record(Record::new(
1844            Def::new("test", "Nested"),
1845            "Nested",
1846            FieldList::new(&NESTED_RULE_FIELDS),
1847            EMPTY_TYPE.clone(),
1848        )));
1849
1850        let outer = Field::new(
1851            "nested",
1852            Value::new(
1853                Cardinality::One,
1854                Item::new(
1855                    ItemTarget::Is("test::Nested"),
1856                    None,
1857                    None,
1858                    None,
1859                    None,
1860                    &[],
1861                    &[],
1862                    false,
1863                ),
1864            ),
1865            None,
1866            None,
1867            None,
1868        );
1869        let constraints =
1870            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1871        assert_eq!(constraints.len(), 1);
1872        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1873            panic!("nested durable rule should use the targeted-rule contract")
1874        };
1875        assert_eq!(rule.root().as_str(), "nested");
1876        assert_eq!(rule.target_type().as_str(), "Degrees");
1877        assert!(matches!(
1878            rule.operation(),
1879            SourceRuleOperation::NumericRangeInclusive { .. }
1880        ));
1881    }
1882
1883    #[test]
1884    fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1885        let mut schema = Schema::new();
1886        for (name, primitive, scale, rules) in [
1887            (
1888                "Counter",
1889                Primitive::Nat64,
1890                None,
1891                NAT_EXACT_RULE_TYPE.clone(),
1892            ),
1893            (
1894                "PriceStep",
1895                Primitive::Decimal,
1896                Some(2),
1897                DECIMAL_EXACT_RULE_TYPE.clone(),
1898            ),
1899        ] {
1900            schema.insert_node(SchemaNode::Newtype(Newtype::new(
1901                Def::new("test", name),
1902                name,
1903                Item::new(
1904                    ItemTarget::Primitive(primitive),
1905                    None,
1906                    scale,
1907                    None,
1908                    None,
1909                    &[],
1910                    &[],
1911                    false,
1912                ),
1913                None,
1914                rules,
1915            )));
1916        }
1917
1918        let field = |name| {
1919            Field::new(
1920                name,
1921                Value::new(
1922                    Cardinality::One,
1923                    Item::new(
1924                        ItemTarget::Is(if name == "counter" {
1925                            "test::Counter"
1926                        } else {
1927                            "test::PriceStep"
1928                        }),
1929                        None,
1930                        None,
1931                        None,
1932                        None,
1933                        &[],
1934                        &[],
1935                        false,
1936                    ),
1937                ),
1938                None,
1939                None,
1940                None,
1941            )
1942        };
1943        let counter = lower_field_rules(&schema, &field("counter"))
1944            .expect("exact integer rules should lower");
1945        assert!(matches!(
1946            counter[0].kind(),
1947            ConstraintFragmentKind::TargetedRule(rule)
1948                if matches!(
1949                    rule.operation(),
1950                    SourceRuleOperation::NumericMaximumInclusive {
1951                        value: ScalarLiteral::Nat(100)
1952                    }
1953                )
1954        ));
1955        assert!(matches!(
1956            counter[1].kind(),
1957            ConstraintFragmentKind::TargetedRule(rule)
1958                if matches!(
1959                    rule.operation(),
1960                    SourceRuleOperation::MultipleOf {
1961                        divisor: ScalarLiteral::Nat(5)
1962                    }
1963                )
1964        ));
1965
1966        let decimal = lower_field_rules(&schema, &field("price"))
1967            .expect("exact decimal multiple should lower");
1968        assert!(matches!(
1969            decimal[0].kind(),
1970            ConstraintFragmentKind::TargetedRule(rule)
1971                if matches!(
1972                    rule.operation(),
1973                    SourceRuleOperation::MultipleOf {
1974                        divisor: ScalarLiteral::Decimal(value)
1975                    } if *value == Decimal::new(25, 2)
1976                )
1977        ));
1978    }
1979
1980    #[test]
1981    fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
1982        let mut schema = Schema::new();
1983        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1984            Def::new("test", "InexactStep"),
1985            "InexactStep",
1986            Item::new(
1987                ItemTarget::Primitive(Primitive::Decimal),
1988                None,
1989                Some(2),
1990                None,
1991                None,
1992                &[],
1993                &[],
1994                false,
1995            ),
1996            None,
1997            INEXACT_DECIMAL_RULE_TYPE.clone(),
1998        )));
1999        let field = Field::new(
2000            "price",
2001            Value::new(
2002                Cardinality::One,
2003                Item::new(
2004                    ItemTarget::Is("test::InexactStep"),
2005                    None,
2006                    None,
2007                    None,
2008                    None,
2009                    &[],
2010                    &[],
2011                    false,
2012                ),
2013            ),
2014            None,
2015            None,
2016            None,
2017        );
2018        assert!(lower_field_rules(&schema, &field).is_err());
2019    }
2020
2021    static ENTITY_FIELDS: [Field; 5] = [
2022        Field::new(
2023            "id",
2024            Value::new(
2025                Cardinality::One,
2026                Item::new(
2027                    ItemTarget::Primitive(Primitive::Nat64),
2028                    None,
2029                    None,
2030                    None,
2031                    None,
2032                    &[],
2033                    &[],
2034                    false,
2035                ),
2036            ),
2037            None,
2038            None,
2039            None,
2040        ),
2041        Field::new(
2042            "tags",
2043            Value::new(
2044                Cardinality::Many,
2045                Item::new(
2046                    ItemTarget::Primitive(Primitive::Text),
2047                    None,
2048                    None,
2049                    Some(32),
2050                    None,
2051                    &[],
2052                    &[],
2053                    false,
2054                ),
2055            ),
2056            None,
2057            None,
2058            None,
2059        ),
2060        Field::new(
2061            "status",
2062            Value::new(
2063                Cardinality::One,
2064                Item::new(
2065                    ItemTarget::Is("test::Status"),
2066                    None,
2067                    None,
2068                    None,
2069                    None,
2070                    &[],
2071                    &[],
2072                    false,
2073                ),
2074            ),
2075            Some(crate::node::Arg::ConstPath("test::Status::Active")),
2076            None,
2077            None,
2078        ),
2079        Field::new(
2080            "degrees",
2081            Value::new(
2082                Cardinality::One,
2083                Item::new(
2084                    ItemTarget::Is("test::Degrees"),
2085                    None,
2086                    None,
2087                    None,
2088                    None,
2089                    &[],
2090                    &[],
2091                    false,
2092                ),
2093            ),
2094            None,
2095            None,
2096            None,
2097        ),
2098        Field::new(
2099            "label",
2100            Value::new(
2101                Cardinality::One,
2102                Item::new(
2103                    ItemTarget::Is("test::Label"),
2104                    None,
2105                    None,
2106                    None,
2107                    None,
2108                    &[],
2109                    &[],
2110                    false,
2111                ),
2112            ),
2113            None,
2114            None,
2115            None,
2116        ),
2117    ];
2118
2119    #[test]
2120    #[expect(
2121        clippy::too_many_lines,
2122        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
2123    )]
2124    fn sealed_canister_graph_emits_store_free_database_closure() {
2125        let mut schema = Schema::new();
2126        schema.insert_node(SchemaNode::Canister(Canister::new(
2127            Def::new("test", "Canister"),
2128            "test",
2129            None,
2130        )));
2131        schema.insert_node(SchemaNode::Store(Store::new_heap(
2132            Def::new("test", "Store"),
2133            "test::Canister",
2134            StoreHeapConfig::new(),
2135        )));
2136        schema.insert_node(SchemaNode::Enum(Enum::new(
2137            Def::new("test", "Status"),
2138            "Status",
2139            &STATUS_VARIANTS,
2140            EMPTY_TYPE.clone(),
2141        )));
2142        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2143            Def::new("test", "Degrees"),
2144            "Degrees",
2145            Item::new(
2146                ItemTarget::Primitive(Primitive::Nat16),
2147                None,
2148                None,
2149                None,
2150                None,
2151                &[],
2152                &[],
2153                false,
2154            ),
2155            None,
2156            NUMERIC_RULE_TYPE.clone(),
2157        )));
2158        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2159            Def::new("test", "Label"),
2160            "Label",
2161            Item::new(
2162                ItemTarget::Primitive(Primitive::Text),
2163                None,
2164                None,
2165                None,
2166                None,
2167                &[],
2168                &[],
2169                false,
2170            ),
2171            None,
2172            LENGTH_RULE_TYPE.clone(),
2173        )));
2174        schema.insert_node(SchemaNode::Entity(Entity::new(
2175            Def::new("test", "Task"),
2176            "test::Store",
2177            1,
2178            PrimaryKey::new(&["id"], PrimaryKeySource::External),
2179            &[],
2180            &[],
2181            &[],
2182            FieldList::new(&ENTITY_FIELDS),
2183            EMPTY_TYPE.clone(),
2184        )));
2185        schema.seal().expect("fixture graph should seal");
2186
2187        let fragment = schema
2188            .schema_fragment_for_canister("test::Canister")
2189            .expect("sealed database closure should lower");
2190
2191        assert_eq!(fragment.entities().len(), 1);
2192        assert_eq!(fragment.types().len(), 3);
2193        let fields = fragment.entities()[0].fields();
2194        assert!(matches!(
2195            fields
2196                .iter()
2197                .find(|field| field.name().as_str() == "tags")
2198                .map(icydb_schema::FieldFragment::field_type),
2199            Some(FieldType::List(item))
2200                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
2201        ));
2202        assert!(matches!(
2203            fields
2204                .iter()
2205                .find(|field| field.name().as_str() == "status")
2206                .map(icydb_schema::FieldFragment::insert_policy),
2207            Some(icydb_schema::FieldInsertPolicy::Default(
2208                icydb_schema::ScalarLiteral::EnumUnit { .. }
2209            ))
2210        ));
2211        let NamedTypeFragment::Enum(status) = fragment
2212            .types()
2213            .iter()
2214            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
2215            .expect("reachable status type should remain an enum")
2216        else {
2217            panic!("reachable status type should remain an enum")
2218        };
2219        assert!(matches!(
2220            status
2221                .variants()
2222                .iter()
2223                .find(|variant| variant.name().as_str() == "Retries")
2224                .and_then(|variant| variant.payload()),
2225            Some(FieldType::List(item))
2226                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
2227        ));
2228
2229        let constraints = fragment.entities()[0].constraints();
2230        assert_eq!(constraints.len(), 2);
2231        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
2232            &FieldSourceKey::try_new("degrees").expect("field name"),
2233            &TypeSourceKey::try_new("Degrees").expect("type name"),
2234            &RuleSourceKey::try_new("range").expect("rule name"),
2235        );
2236        let degrees = constraints
2237            .iter()
2238            .find(|constraint| constraint.source_key() == &degrees_source)
2239            .expect("numeric rule should become one field-owned constraint");
2240        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
2241            panic!("numeric rule should use the targeted-rule contract")
2242        };
2243        assert_eq!(degrees.root().as_str(), "degrees");
2244        assert_eq!(degrees.target_type().as_str(), "Degrees");
2245        assert!(matches!(
2246            degrees.operation(),
2247            SourceRuleOperation::NumericRangeInclusive { .. }
2248        ));
2249        let label = constraints
2250            .iter()
2251            .find(|constraint| constraint.source_key() != &degrees_source)
2252            .expect("length rule should become one field-owned constraint");
2253        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
2254            panic!("length rule should use the targeted-rule contract")
2255        };
2256        assert_eq!(label.target_type().as_str(), "Label");
2257        assert!(matches!(
2258            label.operation(),
2259            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
2260        ));
2261    }
2262}