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().chunks_exact(2).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            0,
1775            10,
1776            9,
1777            7,
1778            8,
1779            None,
1780        )));
1781        schema.insert_node(SchemaNode::Store(Store::new_heap(
1782            Def::new("test", "Store"),
1783            "test::Canister",
1784            StoreHeapConfig::new(),
1785        )));
1786        schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1787            "test",
1788            normalizer_name,
1789        ))));
1790        schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1791            "test",
1792            validator_name,
1793        ))));
1794        schema.insert_node(SchemaNode::Entity(Entity::new(
1795            Def::new("test", "ApplicationOnly"),
1796            "test::Store",
1797            1,
1798            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1799            &[],
1800            &[],
1801            &[],
1802            FieldList::new(&APPLICATION_FIELDS),
1803            Type::new(normalizers, validators, &[]),
1804        )));
1805        schema.seal().expect("application-only fixture should seal");
1806        schema
1807            .schema_fragment_for_canister("test::Canister")
1808            .expect("application-only fixture should lower")
1809    }
1810
1811    #[test]
1812    fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1813        let before = application_behavior_fragment(
1814            &APPLICATION_NORMALIZERS_A,
1815            &APPLICATION_VALIDATORS_A,
1816            "NormalizeA",
1817            "ValidateA",
1818        );
1819        let after = application_behavior_fragment(
1820            &APPLICATION_NORMALIZERS_B,
1821            &APPLICATION_VALIDATORS_B,
1822            "NormalizeB",
1823            "ValidateB",
1824        );
1825
1826        assert_eq!(before, after);
1827    }
1828
1829    #[test]
1830    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1831        let mut schema = Schema::new();
1832        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1833            Def::new("test", "Degrees"),
1834            "Degrees",
1835            Item::new(
1836                ItemTarget::Primitive(Primitive::Nat16),
1837                None,
1838                None,
1839                None,
1840                None,
1841                &[],
1842                &[],
1843                false,
1844            ),
1845            None,
1846            NUMERIC_RULE_TYPE.clone(),
1847        )));
1848        schema.insert_node(SchemaNode::Record(Record::new(
1849            Def::new("test", "Nested"),
1850            "Nested",
1851            FieldList::new(&NESTED_RULE_FIELDS),
1852            EMPTY_TYPE.clone(),
1853        )));
1854
1855        let outer = Field::new(
1856            "nested",
1857            Value::new(
1858                Cardinality::One,
1859                Item::new(
1860                    ItemTarget::Is("test::Nested"),
1861                    None,
1862                    None,
1863                    None,
1864                    None,
1865                    &[],
1866                    &[],
1867                    false,
1868                ),
1869            ),
1870            None,
1871            None,
1872            None,
1873        );
1874        let constraints =
1875            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1876        assert_eq!(constraints.len(), 1);
1877        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1878            panic!("nested durable rule should use the targeted-rule contract")
1879        };
1880        assert_eq!(rule.root().as_str(), "nested");
1881        assert_eq!(rule.target_type().as_str(), "Degrees");
1882        assert!(matches!(
1883            rule.operation(),
1884            SourceRuleOperation::NumericRangeInclusive { .. }
1885        ));
1886    }
1887
1888    #[test]
1889    fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1890        let mut schema = Schema::new();
1891        for (name, primitive, scale, rules) in [
1892            (
1893                "Counter",
1894                Primitive::Nat64,
1895                None,
1896                NAT_EXACT_RULE_TYPE.clone(),
1897            ),
1898            (
1899                "PriceStep",
1900                Primitive::Decimal,
1901                Some(2),
1902                DECIMAL_EXACT_RULE_TYPE.clone(),
1903            ),
1904        ] {
1905            schema.insert_node(SchemaNode::Newtype(Newtype::new(
1906                Def::new("test", name),
1907                name,
1908                Item::new(
1909                    ItemTarget::Primitive(primitive),
1910                    None,
1911                    scale,
1912                    None,
1913                    None,
1914                    &[],
1915                    &[],
1916                    false,
1917                ),
1918                None,
1919                rules,
1920            )));
1921        }
1922
1923        let field = |name| {
1924            Field::new(
1925                name,
1926                Value::new(
1927                    Cardinality::One,
1928                    Item::new(
1929                        ItemTarget::Is(if name == "counter" {
1930                            "test::Counter"
1931                        } else {
1932                            "test::PriceStep"
1933                        }),
1934                        None,
1935                        None,
1936                        None,
1937                        None,
1938                        &[],
1939                        &[],
1940                        false,
1941                    ),
1942                ),
1943                None,
1944                None,
1945                None,
1946            )
1947        };
1948        let counter = lower_field_rules(&schema, &field("counter"))
1949            .expect("exact integer rules should lower");
1950        assert!(matches!(
1951            counter[0].kind(),
1952            ConstraintFragmentKind::TargetedRule(rule)
1953                if matches!(
1954                    rule.operation(),
1955                    SourceRuleOperation::NumericMaximumInclusive {
1956                        value: ScalarLiteral::Nat(100)
1957                    }
1958                )
1959        ));
1960        assert!(matches!(
1961            counter[1].kind(),
1962            ConstraintFragmentKind::TargetedRule(rule)
1963                if matches!(
1964                    rule.operation(),
1965                    SourceRuleOperation::MultipleOf {
1966                        divisor: ScalarLiteral::Nat(5)
1967                    }
1968                )
1969        ));
1970
1971        let decimal = lower_field_rules(&schema, &field("price"))
1972            .expect("exact decimal multiple should lower");
1973        assert!(matches!(
1974            decimal[0].kind(),
1975            ConstraintFragmentKind::TargetedRule(rule)
1976                if matches!(
1977                    rule.operation(),
1978                    SourceRuleOperation::MultipleOf {
1979                        divisor: ScalarLiteral::Decimal(value)
1980                    } if *value == Decimal::new(25, 2)
1981                )
1982        ));
1983    }
1984
1985    #[test]
1986    fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
1987        let mut schema = Schema::new();
1988        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1989            Def::new("test", "InexactStep"),
1990            "InexactStep",
1991            Item::new(
1992                ItemTarget::Primitive(Primitive::Decimal),
1993                None,
1994                Some(2),
1995                None,
1996                None,
1997                &[],
1998                &[],
1999                false,
2000            ),
2001            None,
2002            INEXACT_DECIMAL_RULE_TYPE.clone(),
2003        )));
2004        let field = Field::new(
2005            "price",
2006            Value::new(
2007                Cardinality::One,
2008                Item::new(
2009                    ItemTarget::Is("test::InexactStep"),
2010                    None,
2011                    None,
2012                    None,
2013                    None,
2014                    &[],
2015                    &[],
2016                    false,
2017                ),
2018            ),
2019            None,
2020            None,
2021            None,
2022        );
2023        assert!(lower_field_rules(&schema, &field).is_err());
2024    }
2025
2026    static ENTITY_FIELDS: [Field; 5] = [
2027        Field::new(
2028            "id",
2029            Value::new(
2030                Cardinality::One,
2031                Item::new(
2032                    ItemTarget::Primitive(Primitive::Nat64),
2033                    None,
2034                    None,
2035                    None,
2036                    None,
2037                    &[],
2038                    &[],
2039                    false,
2040                ),
2041            ),
2042            None,
2043            None,
2044            None,
2045        ),
2046        Field::new(
2047            "tags",
2048            Value::new(
2049                Cardinality::Many,
2050                Item::new(
2051                    ItemTarget::Primitive(Primitive::Text),
2052                    None,
2053                    None,
2054                    Some(32),
2055                    None,
2056                    &[],
2057                    &[],
2058                    false,
2059                ),
2060            ),
2061            None,
2062            None,
2063            None,
2064        ),
2065        Field::new(
2066            "status",
2067            Value::new(
2068                Cardinality::One,
2069                Item::new(
2070                    ItemTarget::Is("test::Status"),
2071                    None,
2072                    None,
2073                    None,
2074                    None,
2075                    &[],
2076                    &[],
2077                    false,
2078                ),
2079            ),
2080            Some(crate::node::Arg::ConstPath("test::Status::Active")),
2081            None,
2082            None,
2083        ),
2084        Field::new(
2085            "degrees",
2086            Value::new(
2087                Cardinality::One,
2088                Item::new(
2089                    ItemTarget::Is("test::Degrees"),
2090                    None,
2091                    None,
2092                    None,
2093                    None,
2094                    &[],
2095                    &[],
2096                    false,
2097                ),
2098            ),
2099            None,
2100            None,
2101            None,
2102        ),
2103        Field::new(
2104            "label",
2105            Value::new(
2106                Cardinality::One,
2107                Item::new(
2108                    ItemTarget::Is("test::Label"),
2109                    None,
2110                    None,
2111                    None,
2112                    None,
2113                    &[],
2114                    &[],
2115                    false,
2116                ),
2117            ),
2118            None,
2119            None,
2120            None,
2121        ),
2122    ];
2123
2124    #[test]
2125    #[expect(
2126        clippy::too_many_lines,
2127        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
2128    )]
2129    fn sealed_canister_graph_emits_store_free_database_closure() {
2130        let mut schema = Schema::new();
2131        schema.insert_node(SchemaNode::Canister(Canister::new(
2132            Def::new("test", "Canister"),
2133            "test",
2134            0,
2135            10,
2136            9,
2137            7,
2138            8,
2139            None,
2140        )));
2141        schema.insert_node(SchemaNode::Store(Store::new_heap(
2142            Def::new("test", "Store"),
2143            "test::Canister",
2144            StoreHeapConfig::new(),
2145        )));
2146        schema.insert_node(SchemaNode::Enum(Enum::new(
2147            Def::new("test", "Status"),
2148            "Status",
2149            &STATUS_VARIANTS,
2150            EMPTY_TYPE.clone(),
2151        )));
2152        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2153            Def::new("test", "Degrees"),
2154            "Degrees",
2155            Item::new(
2156                ItemTarget::Primitive(Primitive::Nat16),
2157                None,
2158                None,
2159                None,
2160                None,
2161                &[],
2162                &[],
2163                false,
2164            ),
2165            None,
2166            NUMERIC_RULE_TYPE.clone(),
2167        )));
2168        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2169            Def::new("test", "Label"),
2170            "Label",
2171            Item::new(
2172                ItemTarget::Primitive(Primitive::Text),
2173                None,
2174                None,
2175                None,
2176                None,
2177                &[],
2178                &[],
2179                false,
2180            ),
2181            None,
2182            LENGTH_RULE_TYPE.clone(),
2183        )));
2184        schema.insert_node(SchemaNode::Entity(Entity::new(
2185            Def::new("test", "Task"),
2186            "test::Store",
2187            1,
2188            PrimaryKey::new(&["id"], PrimaryKeySource::External),
2189            &[],
2190            &[],
2191            &[],
2192            FieldList::new(&ENTITY_FIELDS),
2193            EMPTY_TYPE.clone(),
2194        )));
2195        schema.seal().expect("fixture graph should seal");
2196
2197        let fragment = schema
2198            .schema_fragment_for_canister("test::Canister")
2199            .expect("sealed database closure should lower");
2200
2201        assert_eq!(fragment.entities().len(), 1);
2202        assert_eq!(fragment.types().len(), 3);
2203        let fields = fragment.entities()[0].fields();
2204        assert!(matches!(
2205            fields
2206                .iter()
2207                .find(|field| field.name().as_str() == "tags")
2208                .map(icydb_schema::FieldFragment::field_type),
2209            Some(FieldType::List(item))
2210                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
2211        ));
2212        assert!(matches!(
2213            fields
2214                .iter()
2215                .find(|field| field.name().as_str() == "status")
2216                .map(icydb_schema::FieldFragment::insert_policy),
2217            Some(icydb_schema::FieldInsertPolicy::Default(
2218                icydb_schema::ScalarLiteral::EnumUnit { .. }
2219            ))
2220        ));
2221        let NamedTypeFragment::Enum(status) = fragment
2222            .types()
2223            .iter()
2224            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
2225            .expect("reachable status type should remain an enum")
2226        else {
2227            panic!("reachable status type should remain an enum")
2228        };
2229        assert!(matches!(
2230            status
2231                .variants()
2232                .iter()
2233                .find(|variant| variant.name().as_str() == "Retries")
2234                .and_then(|variant| variant.payload()),
2235            Some(FieldType::List(item))
2236                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
2237        ));
2238
2239        let constraints = fragment.entities()[0].constraints();
2240        assert_eq!(constraints.len(), 2);
2241        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
2242            &FieldSourceKey::try_new("degrees").expect("field name"),
2243            &TypeSourceKey::try_new("Degrees").expect("type name"),
2244            &RuleSourceKey::try_new("range").expect("rule name"),
2245        );
2246        let degrees = constraints
2247            .iter()
2248            .find(|constraint| constraint.source_key() == &degrees_source)
2249            .expect("numeric rule should become one field-owned constraint");
2250        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
2251            panic!("numeric rule should use the targeted-rule contract")
2252        };
2253        assert_eq!(degrees.root().as_str(), "degrees");
2254        assert_eq!(degrees.target_type().as_str(), "Degrees");
2255        assert!(matches!(
2256            degrees.operation(),
2257            SourceRuleOperation::NumericRangeInclusive { .. }
2258        ));
2259        let label = constraints
2260            .iter()
2261            .find(|constraint| constraint.source_key() != &degrees_source)
2262            .expect("length rule should become one field-owned constraint");
2263        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
2264            panic!("length rule should use the targeted-rule contract")
2265        };
2266        assert_eq!(label.target_type().as_str(), "Label");
2267        assert!(matches!(
2268            label.operation(),
2269            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
2270        ));
2271    }
2272}