Skip to main content

icydb_core/db/schema/
describe.rs

1//! Module: db::schema::describe
2//! Responsibility: deterministic entity-schema introspection DTOs for runtime consumers.
3//! Does not own: query planning, execution routing, or relation enforcement semantics.
4//! Boundary: projects generated or accepted schema metadata into stable describe surfaces.
5
6use crate::{
7    db::{
8        data::decode_admitted_value_from_accepted_field_contract,
9        relation::{
10            RelationFieldCardinality, RelationFieldMetadata, relation_field_metadata_for_model_iter,
11        },
12        schema::{
13            AcceptedFieldKind, AcceptedFieldPersistenceContract, AcceptedInsertOmissionPolicy,
14            AcceptedRowLayoutRuntimeContract, AcceptedSchemaSnapshot, AcceptedValueCatalogHandle,
15            PersistedIndexKeyItemSnapshot, PersistedIndexKeySnapshot, PersistedNestedLeafSnapshot,
16            SchemaHistoricalFill,
17            composite_catalog::{AcceptedCompositeElement, AcceptedCompositeShape},
18            field_type_from_persisted_kind, output_value_from_runtime,
19            runtime::AcceptedRowLayoutRuntimeField,
20        },
21    },
22    error::InternalError,
23    model::{
24        entity::EntityModel,
25        field::{
26            CompositeCodec, CompositeElementModel, CompositeShapeModel, FieldDatabaseDefault,
27            FieldKind, FieldModel,
28        },
29    },
30    value::{OutputValue, render_output_value_text},
31};
32use std::fmt::Write;
33
34use candid::CandidType;
35use serde::Deserialize;
36use sha2::{Digest, Sha256};
37
38const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
39const MAX_SCHEMA_VALUE_RENDER_CHARS: usize = 128;
40
41#[cfg_attr(
42    doc,
43    doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
44)]
45#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
46pub struct EntitySchemaDescription {
47    pub(crate) entity_path: String,
48    pub(crate) entity_name: String,
49    pub(crate) primary_key: String,
50    pub(crate) primary_key_fields: Vec<String>,
51    pub(crate) fields: Vec<EntityFieldDescription>,
52    pub(crate) indexes: Vec<EntityIndexDescription>,
53    pub(crate) relations: Vec<EntityRelationDescription>,
54    pub(crate) row_layout_current: u32,
55    pub(crate) row_layout_history_floor: u32,
56}
57
58#[cfg_attr(
59    doc,
60    doc = "EntitySchemaCheckDescription\n\nGenerated-vs-accepted schema description payload for one entity."
61)]
62#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
63pub struct EntitySchemaCheckDescription {
64    pub(crate) generated: EntitySchemaDescription,
65    pub(crate) accepted: EntitySchemaDescription,
66}
67
68impl EntitySchemaCheckDescription {
69    /// Construct one generated-vs-accepted schema check payload.
70    #[must_use]
71    pub const fn new(
72        generated: EntitySchemaDescription,
73        accepted: EntitySchemaDescription,
74    ) -> Self {
75        Self {
76            generated,
77            accepted,
78        }
79    }
80
81    /// Borrow the generated schema proposal description.
82    #[must_use]
83    pub const fn generated(&self) -> &EntitySchemaDescription {
84        &self.generated
85    }
86
87    /// Borrow the accepted live-schema description.
88    #[must_use]
89    pub const fn accepted(&self) -> &EntitySchemaDescription {
90        &self.accepted
91    }
92}
93
94impl EntitySchemaDescription {
95    /// Construct one entity schema description payload.
96    #[expect(
97        clippy::too_many_arguments,
98        reason = "schema description construction keeps identity, collections, and layout explicit"
99    )]
100    #[must_use]
101    pub const fn new(
102        entity_path: String,
103        entity_name: String,
104        primary_key: String,
105        primary_key_fields: Vec<String>,
106        fields: Vec<EntityFieldDescription>,
107        indexes: Vec<EntityIndexDescription>,
108        relations: Vec<EntityRelationDescription>,
109        row_layout_current: u32,
110        row_layout_history_floor: u32,
111    ) -> Self {
112        Self {
113            entity_path,
114            entity_name,
115            primary_key,
116            primary_key_fields,
117            fields,
118            indexes,
119            relations,
120            row_layout_current,
121            row_layout_history_floor,
122        }
123    }
124
125    /// Borrow the entity module path.
126    #[must_use]
127    pub const fn entity_path(&self) -> &str {
128        self.entity_path.as_str()
129    }
130
131    /// Borrow the entity display name.
132    #[must_use]
133    pub const fn entity_name(&self) -> &str {
134        self.entity_name.as_str()
135    }
136
137    /// Borrow the rendered primary-key field list.
138    #[must_use]
139    pub const fn primary_key(&self) -> &str {
140        self.primary_key.as_str()
141    }
142
143    /// Borrow ordered primary-key field names.
144    #[must_use]
145    pub const fn primary_key_fields(&self) -> &[String] {
146        self.primary_key_fields.as_slice()
147    }
148
149    /// Borrow field description entries.
150    #[must_use]
151    pub const fn fields(&self) -> &[EntityFieldDescription] {
152        self.fields.as_slice()
153    }
154
155    /// Borrow index description entries.
156    #[must_use]
157    pub const fn indexes(&self) -> &[EntityIndexDescription] {
158        self.indexes.as_slice()
159    }
160
161    /// Borrow relation description entries.
162    #[must_use]
163    pub const fn relations(&self) -> &[EntityRelationDescription] {
164        self.relations.as_slice()
165    }
166
167    /// Return the current accepted physical row-layout identity.
168    #[must_use]
169    pub const fn row_layout_current(&self) -> u32 {
170        self.row_layout_current
171    }
172
173    /// Return the oldest admitted physical row-layout identity.
174    #[must_use]
175    pub const fn row_layout_history_floor(&self) -> u32 {
176        self.row_layout_history_floor
177    }
178}
179
180#[cfg_attr(
181    doc,
182    doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
183)]
184#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
185pub struct EntityFieldDescription {
186    pub(crate) name: String,
187    pub(crate) slot: u16,
188    pub(crate) kind: String,
189    pub(crate) nullable: bool,
190    pub(crate) primary_key: bool,
191    pub(crate) queryable: bool,
192    pub(crate) origin: String,
193    pub(crate) insert_omission: Option<String>,
194    pub(crate) insert_default: Option<String>,
195    pub(crate) insert_default_bytes: Option<u32>,
196    pub(crate) insert_default_hash: Option<String>,
197    pub(crate) introduced_in_layout: Option<u32>,
198    pub(crate) historical_fill: Option<String>,
199    pub(crate) historical_fill_bytes: Option<u32>,
200    pub(crate) historical_fill_hash: Option<String>,
201}
202
203///
204/// EntityFieldTemporalFacts
205///
206/// One internally assembled projection of the independent accepted insert and
207/// historical-absence contracts. Nested rows carry an explicitly empty bundle.
208///
209
210struct EntityFieldTemporalFacts {
211    insert_omission: Option<String>,
212    insert_default: Option<String>,
213    insert_default_bytes: Option<u32>,
214    insert_default_hash: Option<String>,
215    introduced_in_layout: Option<u32>,
216    historical_fill: Option<String>,
217    historical_fill_bytes: Option<u32>,
218    historical_fill_hash: Option<String>,
219}
220
221impl EntityFieldTemporalFacts {
222    const fn nested() -> Self {
223        Self {
224            insert_omission: None,
225            insert_default: None,
226            insert_default_bytes: None,
227            insert_default_hash: None,
228            introduced_in_layout: None,
229            historical_fill: None,
230            historical_fill_bytes: None,
231            historical_fill_hash: None,
232        }
233    }
234
235    fn generated(field: &FieldModel) -> Self {
236        let insert_omission = if field.insert_generation().is_some() {
237            "generated"
238        } else if field.write_management().is_some() {
239            "managed"
240        } else {
241            match field.database_default() {
242                FieldDatabaseDefault::EncodedSlotPayload(_)
243                | FieldDatabaseDefault::AuthoredEnumUnit { .. } => "default",
244                FieldDatabaseDefault::None if field.nullable() => "null",
245                FieldDatabaseDefault::None => "required",
246            }
247        };
248        let (insert_default, insert_default_bytes, insert_default_hash) =
249            generated_insert_default_facts(field.database_default());
250
251        Self {
252            insert_omission: Some(insert_omission.to_string()),
253            insert_default,
254            insert_default_bytes,
255            insert_default_hash,
256            introduced_in_layout: Some(1),
257            historical_fill: Some("reject".to_string()),
258            historical_fill_bytes: None,
259            historical_fill_hash: None,
260        }
261    }
262}
263
264impl EntityFieldDescription {
265    /// Construct one field description entry.
266    #[expect(
267        clippy::too_many_arguments,
268        reason = "schema description construction keeps every temporal field fact explicit"
269    )]
270    #[must_use]
271    pub fn new(
272        name: String,
273        slot: Option<u16>,
274        kind: String,
275        nullable: bool,
276        primary_key: bool,
277        queryable: bool,
278        origin: String,
279        insert_omission: Option<String>,
280        insert_default: Option<String>,
281        insert_default_bytes: Option<u32>,
282        insert_default_hash: Option<String>,
283        introduced_in_layout: Option<u32>,
284        historical_fill: Option<String>,
285        historical_fill_bytes: Option<u32>,
286        historical_fill_hash: Option<String>,
287    ) -> Self {
288        Self::new_with_temporal_facts(
289            name,
290            slot,
291            primary_key,
292            DescribeFieldMetadata::new(kind, nullable, queryable, origin),
293            EntityFieldTemporalFacts {
294                insert_omission,
295                insert_default,
296                insert_default_bytes,
297                insert_default_hash,
298                introduced_in_layout,
299                historical_fill,
300                historical_fill_bytes,
301                historical_fill_hash,
302            },
303        )
304    }
305
306    fn new_with_temporal_facts(
307        name: String,
308        slot: Option<u16>,
309        primary_key: bool,
310        metadata: DescribeFieldMetadata,
311        temporal: EntityFieldTemporalFacts,
312    ) -> Self {
313        let slot = match slot {
314            Some(slot) => slot,
315            None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
316        };
317
318        Self {
319            name,
320            slot,
321            kind: metadata.kind,
322            nullable: metadata.nullable,
323            primary_key,
324            queryable: metadata.queryable,
325            origin: metadata.origin,
326            insert_omission: temporal.insert_omission,
327            insert_default: temporal.insert_default,
328            insert_default_bytes: temporal.insert_default_bytes,
329            insert_default_hash: temporal.insert_default_hash,
330            introduced_in_layout: temporal.introduced_in_layout,
331            historical_fill: temporal.historical_fill,
332            historical_fill_bytes: temporal.historical_fill_bytes,
333            historical_fill_hash: temporal.historical_fill_hash,
334        }
335    }
336
337    /// Borrow the field name.
338    #[must_use]
339    pub const fn name(&self) -> &str {
340        self.name.as_str()
341    }
342
343    /// Return the physical row slot for top-level fields.
344    #[must_use]
345    pub const fn slot(&self) -> Option<u16> {
346        if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
347            None
348        } else {
349            Some(self.slot)
350        }
351    }
352
353    /// Borrow the rendered field kind label.
354    #[must_use]
355    pub const fn kind(&self) -> &str {
356        self.kind.as_str()
357    }
358
359    /// Return whether this field permits explicit `NULL`.
360    #[must_use]
361    pub const fn nullable(&self) -> bool {
362        self.nullable
363    }
364
365    /// Return whether this field is the primary key.
366    #[must_use]
367    pub const fn primary_key(&self) -> bool {
368        self.primary_key
369    }
370
371    /// Return whether this field is queryable.
372    #[must_use]
373    pub const fn queryable(&self) -> bool {
374        self.queryable
375    }
376
377    /// Borrow the accepted/generated field origin label.
378    #[must_use]
379    pub const fn origin(&self) -> &str {
380        self.origin.as_str()
381    }
382
383    /// Borrow the accepted insert-omission policy label for a top-level field.
384    #[must_use]
385    pub fn insert_omission(&self) -> Option<&str> {
386        self.insert_omission.as_deref()
387    }
388
389    /// Borrow the bounded canonical accepted insert-default rendering.
390    #[must_use]
391    pub fn insert_default(&self) -> Option<&str> {
392        self.insert_default.as_deref()
393    }
394
395    /// Return the accepted insert-default payload byte count.
396    #[must_use]
397    pub const fn insert_default_bytes(&self) -> Option<u32> {
398        self.insert_default_bytes
399    }
400
401    /// Borrow the stable accepted insert-default payload hash.
402    #[must_use]
403    pub fn insert_default_hash(&self) -> Option<&str> {
404        self.insert_default_hash.as_deref()
405    }
406
407    /// Return the row layout that first physically contained this field.
408    #[must_use]
409    pub const fn introduced_in_layout(&self) -> Option<u32> {
410        self.introduced_in_layout
411    }
412
413    /// Borrow the accepted frozen historical-absence rendering.
414    #[must_use]
415    pub fn historical_fill(&self) -> Option<&str> {
416        self.historical_fill.as_deref()
417    }
418
419    /// Return the historical-fill payload byte count when one is stored.
420    #[must_use]
421    pub const fn historical_fill_bytes(&self) -> Option<u32> {
422        self.historical_fill_bytes
423    }
424
425    /// Borrow the stable historical-fill payload hash.
426    #[must_use]
427    pub fn historical_fill_hash(&self) -> Option<&str> {
428        self.historical_fill_hash.as_deref()
429    }
430}
431
432#[cfg_attr(
433    doc,
434    doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
435)]
436#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
437pub struct EntityIndexDescription {
438    pub(crate) name: String,
439    pub(crate) unique: bool,
440    pub(crate) fields: Vec<String>,
441    pub(crate) origin: String,
442}
443
444impl EntityIndexDescription {
445    /// Construct one index description entry.
446    #[must_use]
447    pub const fn new(name: String, unique: bool, fields: Vec<String>, origin: String) -> Self {
448        Self {
449            name,
450            unique,
451            fields,
452            origin,
453        }
454    }
455
456    /// Borrow the index name.
457    #[must_use]
458    pub const fn name(&self) -> &str {
459        self.name.as_str()
460    }
461
462    /// Return whether the index enforces uniqueness.
463    #[must_use]
464    pub const fn unique(&self) -> bool {
465        self.unique
466    }
467
468    /// Borrow ordered index field names.
469    #[must_use]
470    pub const fn fields(&self) -> &[String] {
471        self.fields.as_slice()
472    }
473
474    /// Borrow the accepted index origin label.
475    #[must_use]
476    pub const fn origin(&self) -> &str {
477        self.origin.as_str()
478    }
479}
480
481#[cfg_attr(
482    doc,
483    doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
484)]
485#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
486pub struct EntityRelationDescription {
487    pub(crate) field: String,
488    pub(crate) target_path: String,
489    pub(crate) target_entity_name: String,
490    pub(crate) target_store_path: String,
491    pub(crate) cardinality: EntityRelationCardinality,
492}
493
494impl EntityRelationDescription {
495    /// Construct one relation description entry.
496    #[must_use]
497    pub const fn new(
498        field: String,
499        target_path: String,
500        target_entity_name: String,
501        target_store_path: String,
502        cardinality: EntityRelationCardinality,
503    ) -> Self {
504        Self {
505            field,
506            target_path,
507            target_entity_name,
508            target_store_path,
509            cardinality,
510        }
511    }
512
513    /// Borrow the source relation field name.
514    #[must_use]
515    pub const fn field(&self) -> &str {
516        self.field.as_str()
517    }
518
519    /// Borrow the relation target path.
520    #[must_use]
521    pub const fn target_path(&self) -> &str {
522        self.target_path.as_str()
523    }
524
525    /// Borrow the relation target entity name.
526    #[must_use]
527    pub const fn target_entity_name(&self) -> &str {
528        self.target_entity_name.as_str()
529    }
530
531    /// Borrow the relation target store path.
532    #[must_use]
533    pub const fn target_store_path(&self) -> &str {
534        self.target_store_path.as_str()
535    }
536
537    /// Return relation cardinality.
538    #[must_use]
539    pub const fn cardinality(&self) -> EntityRelationCardinality {
540        self.cardinality
541    }
542}
543
544#[cfg_attr(
545    doc,
546    doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
547)]
548#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
549pub enum EntityRelationCardinality {
550    Single,
551    List,
552    Set,
553}
554
555#[cfg_attr(
556    doc,
557    doc = "Build one stable entity-schema description from one runtime `EntityModel`."
558)]
559#[must_use]
560pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
561    let fields = describe_entity_fields(model);
562    let primary_key_fields = primary_key_field_names_from_model(model);
563    let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
564
565    describe_entity_model_from_description_rows(
566        model.path,
567        model.entity_name,
568        primary_key.as_str(),
569        primary_key_fields,
570        fields,
571        describe_entity_indexes_from_model(model),
572        describe_entity_relations_from_model(model),
573        1,
574        1,
575    )
576}
577
578#[cfg_attr(
579    doc,
580    doc = "Build one entity-schema description using accepted persisted schema slot metadata."
581)]
582pub(in crate::db) fn describe_entity_model_with_persisted_schema(
583    model: &EntityModel,
584    schema: &AcceptedSchemaSnapshot,
585    value_catalog: &AcceptedValueCatalogHandle,
586) -> Result<EntitySchemaDescription, InternalError> {
587    let row_layout = AcceptedRowLayoutRuntimeContract::from_accepted_schema(schema)?;
588    let fields = describe_entity_fields_with_runtime_contract(schema, &row_layout, value_catalog)?;
589    let primary_key_fields = schema.primary_key_field_names();
590    let primary_key_fields = if primary_key_fields.is_empty() {
591        vec![model.primary_key.name.to_string()]
592    } else {
593        primary_key_fields
594            .into_iter()
595            .map(str::to_string)
596            .collect::<Vec<_>>()
597    };
598    let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
599
600    Ok(describe_entity_model_from_description_rows(
601        schema.entity_path(),
602        schema.entity_name(),
603        primary_key.as_str(),
604        primary_key_fields,
605        fields,
606        describe_entity_indexes_with_persisted_schema(schema),
607        describe_entity_relations_with_persisted_schema(schema),
608        row_layout.current_layout_version().get(),
609        row_layout.history_floor().get(),
610    ))
611}
612
613// Assemble the common DESCRIBE payload once field rows have already been built.
614// Callers project relation descriptions from the same authority as their field
615// and index rows, so accepted DESCRIBE output does not fall back to generated
616// relation metadata.
617#[expect(
618    clippy::too_many_arguments,
619    reason = "one final schema DTO assembly keeps every already-owned section explicit"
620)]
621fn describe_entity_model_from_description_rows(
622    entity_path: &str,
623    entity_name: &str,
624    primary_key: &str,
625    primary_key_fields: Vec<String>,
626    fields: Vec<EntityFieldDescription>,
627    indexes: Vec<EntityIndexDescription>,
628    relations: Vec<EntityRelationDescription>,
629    row_layout_current: u32,
630    row_layout_history_floor: u32,
631) -> EntitySchemaDescription {
632    EntitySchemaDescription::new(
633        entity_path.to_string(),
634        entity_name.to_string(),
635        primary_key.to_string(),
636        primary_key_fields,
637        fields,
638        indexes,
639        relations,
640        row_layout_current,
641        row_layout_history_floor,
642    )
643}
644
645fn describe_entity_relations_from_model(model: &EntityModel) -> Vec<EntityRelationDescription> {
646    relation_field_metadata_for_model_iter(model)
647        .map(relation_description_from_metadata)
648        .collect()
649}
650
651fn primary_key_field_names_from_model(model: &EntityModel) -> Vec<String> {
652    model
653        .primary_key_model()
654        .fields()
655        .iter()
656        .map(|field| field.name.to_string())
657        .collect()
658}
659
660fn render_primary_key_fields(fields: &[String]) -> String {
661    fields.join(", ")
662}
663
664fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
665    let mut indexes = Vec::with_capacity(model.indexes.len());
666    for index in model.indexes {
667        indexes.push(EntityIndexDescription::new(
668            index.name().to_string(),
669            index.is_unique(),
670            index
671                .fields()
672                .iter()
673                .map(|field| (*field).to_string())
674                .collect(),
675            "generated".to_string(),
676        ));
677    }
678
679    indexes
680}
681
682fn describe_entity_indexes_with_persisted_schema(
683    schema: &AcceptedSchemaSnapshot,
684) -> Vec<EntityIndexDescription> {
685    schema
686        .persisted_snapshot()
687        .indexes()
688        .iter()
689        .map(|index| {
690            EntityIndexDescription::new(
691                index.name().to_string(),
692                index.unique(),
693                describe_persisted_index_fields(index.key()),
694                if index.generated() {
695                    "generated".to_string()
696                } else {
697                    "ddl".to_string()
698                },
699            )
700        })
701        .collect()
702}
703
704fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
705    match key {
706        PersistedIndexKeySnapshot::FieldPath(paths) => paths
707            .iter()
708            .map(|field_path| field_path.path().join("."))
709            .collect(),
710        PersistedIndexKeySnapshot::Items(items) => items
711            .iter()
712            .map(|item| match item {
713                PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
714                PersistedIndexKeyItemSnapshot::Expression(expression) => {
715                    expression.canonical_text().to_string()
716                }
717            })
718            .collect(),
719    }
720}
721
722// Build the stable field-description subset once from one runtime model so
723// metadata surfaces that only need columns do not rebuild indexes and
724// relations through the heavier DESCRIBE payload path.
725#[must_use]
726pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
727    describe_entity_fields_with_slot_lookup(model, |slot, _field| {
728        Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
729    })
730}
731
732#[cfg_attr(
733    doc,
734    doc = "Build field descriptors using accepted persisted schema slot metadata."
735)]
736pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
737    schema: &AcceptedSchemaSnapshot,
738    value_catalog: &AcceptedValueCatalogHandle,
739) -> Result<Vec<EntityFieldDescription>, InternalError> {
740    let row_layout = AcceptedRowLayoutRuntimeContract::from_accepted_schema(schema)?;
741    describe_entity_fields_with_runtime_contract(schema, &row_layout, value_catalog)
742}
743
744fn describe_entity_fields_with_runtime_contract(
745    schema: &AcceptedSchemaSnapshot,
746    row_layout: &AcceptedRowLayoutRuntimeContract<'_>,
747    value_catalog: &AcceptedValueCatalogHandle,
748) -> Result<Vec<EntityFieldDescription>, InternalError> {
749    let snapshot = schema.persisted_snapshot();
750    if snapshot.fields().len() != row_layout.fields().len() {
751        return Err(InternalError::store_invariant());
752    }
753    let mut fields = Vec::with_capacity(snapshot.fields().len());
754
755    // Accepted-schema describe surfaces must follow the stored schema payload,
756    // not the generated model's current field order.
757    for (field, runtime_field) in snapshot.fields().iter().zip(row_layout.fields()) {
758        if field.id() != runtime_field.field_id() {
759            return Err(InternalError::store_invariant());
760        }
761        let primary_key = snapshot.primary_key_field_ids().contains(&field.id());
762        let slot = Some(runtime_field.slot().get());
763        let metadata = DescribeFieldMetadata::new(
764            summarize_persisted_field_kind(field.kind(), value_catalog)?,
765            field.nullable(),
766            field_type_from_persisted_kind(field.kind())
767                .value_kind()
768                .is_queryable(),
769            field_origin_label(field.generated()),
770        );
771        let temporal = accepted_field_temporal_facts(runtime_field, value_catalog)?;
772
773        push_described_field_row(
774            &mut fields,
775            field.name(),
776            slot,
777            primary_key,
778            None,
779            metadata,
780            temporal,
781        );
782
783        if !field.nested_leaves().is_empty() {
784            describe_persisted_nested_leaves(
785                &mut fields,
786                field.nested_leaves(),
787                field_origin_label(field.generated()),
788                value_catalog,
789            )?;
790        }
791    }
792
793    Ok(fields)
794}
795
796// Build model-only field descriptors with an injected top-level slot lookup.
797// Accepted-schema introspection has a separate catalog-backed entrypoint above.
798fn describe_entity_fields_with_slot_lookup(
799    model: &EntityModel,
800    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
801) -> Vec<EntityFieldDescription> {
802    let mut fields = Vec::with_capacity(model.fields.len());
803    let primary_key_fields = primary_key_field_names_from_model(model);
804
805    for (slot, field) in model.fields.iter().enumerate() {
806        let primary_key = primary_key_fields
807            .iter()
808            .any(|primary_key_field| primary_key_field == field.name);
809        describe_field_recursive(
810            &mut fields,
811            field.name,
812            slot_for_field(slot, field),
813            field,
814            primary_key,
815            None,
816            None,
817        );
818    }
819
820    fields
821}
822
823///
824/// DescribeFieldMetadata
825///
826/// Field-description metadata selected before one field row is rendered.
827///
828
829struct DescribeFieldMetadata {
830    kind: String,
831    nullable: bool,
832    queryable: bool,
833    origin: String,
834}
835
836impl DescribeFieldMetadata {
837    // Build one metadata bundle from already-rendered field facts.
838    const fn new(kind: String, nullable: bool, queryable: bool, origin: String) -> Self {
839        Self {
840            kind,
841            nullable,
842            queryable,
843            origin,
844        }
845    }
846}
847
848// Add one generated field and any generated composite-record leaves so
849// DESCRIBE/SHOW COLUMNS expose the same nested rows SQL can project and filter.
850fn describe_field_recursive(
851    fields: &mut Vec<EntityFieldDescription>,
852    name: &str,
853    slot: Option<u16>,
854    field: &FieldModel,
855    primary_key: bool,
856    tree_prefix: Option<&'static str>,
857    metadata_override: Option<DescribeFieldMetadata>,
858) {
859    let temporal = if slot.is_some() {
860        EntityFieldTemporalFacts::generated(field)
861    } else {
862        EntityFieldTemporalFacts::nested()
863    };
864    let metadata = metadata_override.unwrap_or_else(|| {
865        DescribeFieldMetadata::new(
866            summarize_field_kind(&field.kind),
867            field.nullable(),
868            field.kind.value_kind().is_queryable(),
869            "generated".to_string(),
870        )
871    });
872
873    push_described_field_row(
874        fields,
875        name,
876        slot,
877        primary_key,
878        tree_prefix,
879        metadata,
880        temporal,
881    );
882    describe_generated_nested_fields(fields, field.nested_fields());
883}
884
885// Add one already-resolved field row to the stable describe DTO list. The
886// caller owns where metadata came from: generated model or accepted schema.
887fn push_described_field_row(
888    fields: &mut Vec<EntityFieldDescription>,
889    name: &str,
890    slot: Option<u16>,
891    primary_key: bool,
892    tree_prefix: Option<&'static str>,
893    metadata: DescribeFieldMetadata,
894    temporal: EntityFieldTemporalFacts,
895) {
896    // Nested field rows keep a compact tree marker so table-oriented describe
897    // output scans as a hierarchy without assigning nested leaves row slots.
898    let display_name = if let Some(prefix) = tree_prefix {
899        format!("{prefix}{name}")
900    } else {
901        name.to_string()
902    };
903
904    fields.push(EntityFieldDescription::new_with_temporal_facts(
905        display_name,
906        slot,
907        primary_key,
908        metadata,
909        temporal,
910    ));
911}
912
913// Render generated nested field metadata recursively for model-only
914// introspection. Accepted introspection consumes persisted catalog-owned leaves.
915fn describe_generated_nested_fields(
916    fields: &mut Vec<EntityFieldDescription>,
917    nested_fields: &[FieldModel],
918) {
919    for (index, nested) in nested_fields.iter().enumerate() {
920        let prefix = if index + 1 == nested_fields.len() {
921            "└─ "
922        } else {
923            "├─ "
924        };
925        describe_field_recursive(
926            fields,
927            nested.name(),
928            None,
929            nested,
930            false,
931            Some(prefix),
932            None,
933        );
934    }
935}
936
937// Render accepted nested leaf descriptors. Nested leaves do not own physical
938// row slots, so they always appear with the no-slot sentinel in the Candid DTO.
939fn describe_persisted_nested_leaves(
940    fields: &mut Vec<EntityFieldDescription>,
941    nested_leaves: &[PersistedNestedLeafSnapshot],
942    origin: String,
943    value_catalog: &AcceptedValueCatalogHandle,
944) -> Result<(), InternalError> {
945    for (index, leaf) in nested_leaves.iter().enumerate() {
946        let prefix = if index + 1 == nested_leaves.len() {
947            "└─ "
948        } else {
949            "├─ "
950        };
951        let name = leaf.path().last().map_or("", String::as_str);
952        let metadata = DescribeFieldMetadata::new(
953            summarize_persisted_field_kind(leaf.kind(), value_catalog)?,
954            leaf.nullable(),
955            field_type_from_persisted_kind(leaf.kind())
956                .value_kind()
957                .is_queryable(),
958            origin.clone(),
959        );
960
961        push_described_field_row(
962            fields,
963            name,
964            None,
965            false,
966            Some(prefix),
967            metadata,
968            EntityFieldTemporalFacts::nested(),
969        );
970    }
971
972    Ok(())
973}
974
975fn field_origin_label(generated: bool) -> String {
976    if generated {
977        "generated".to_string()
978    } else {
979        "ddl".to_string()
980    }
981}
982
983fn describe_entity_relations_with_persisted_schema(
984    schema: &AcceptedSchemaSnapshot,
985) -> Vec<EntityRelationDescription> {
986    schema
987        .persisted_snapshot()
988        .fields()
989        .iter()
990        .filter_map(relation_description_from_persisted_field)
991        .collect()
992}
993
994fn relation_description_from_persisted_field(
995    field: &crate::db::schema::PersistedFieldSnapshot,
996) -> Option<EntityRelationDescription> {
997    let relation = persisted_relation_description_metadata(field.kind())?;
998
999    Some(EntityRelationDescription::new(
1000        field.name().to_string(),
1001        relation.target_path.to_string(),
1002        relation.target_entity_name.to_string(),
1003        relation.target_store_path.to_string(),
1004        relation.cardinality,
1005    ))
1006}
1007
1008struct PersistedRelationDescriptionMetadata<'a> {
1009    target_path: &'a str,
1010    target_entity_name: &'a str,
1011    target_store_path: &'a str,
1012    cardinality: EntityRelationCardinality,
1013}
1014
1015fn persisted_relation_description_metadata(
1016    kind: &AcceptedFieldKind,
1017) -> Option<PersistedRelationDescriptionMetadata<'_>> {
1018    const fn from_relation_kind(
1019        kind: &AcceptedFieldKind,
1020        cardinality: EntityRelationCardinality,
1021    ) -> Option<PersistedRelationDescriptionMetadata<'_>> {
1022        let AcceptedFieldKind::Relation {
1023            target_path,
1024            target_entity_name,
1025            target_store_path,
1026            ..
1027        } = kind
1028        else {
1029            return None;
1030        };
1031
1032        Some(PersistedRelationDescriptionMetadata {
1033            target_path: target_path.as_str(),
1034            target_entity_name: target_entity_name.as_str(),
1035            target_store_path: target_store_path.as_str(),
1036            cardinality,
1037        })
1038    }
1039
1040    match kind {
1041        AcceptedFieldKind::Relation { .. } => {
1042            from_relation_kind(kind, EntityRelationCardinality::Single)
1043        }
1044        AcceptedFieldKind::List(inner) => {
1045            from_relation_kind(inner, EntityRelationCardinality::List)
1046        }
1047        AcceptedFieldKind::Set(inner) => from_relation_kind(inner, EntityRelationCardinality::Set),
1048        _ => None,
1049    }
1050}
1051
1052// Project relation-owned metadata into the stable describe DTO surface.
1053fn relation_description_from_metadata(
1054    metadata: RelationFieldMetadata,
1055) -> EntityRelationDescription {
1056    let cardinality = match metadata.cardinality() {
1057        RelationFieldCardinality::Single => EntityRelationCardinality::Single,
1058        RelationFieldCardinality::List => EntityRelationCardinality::List,
1059        RelationFieldCardinality::Set => EntityRelationCardinality::Set,
1060    };
1061
1062    EntityRelationDescription::new(
1063        metadata.field_name().to_string(),
1064        metadata.target_path().to_string(),
1065        metadata.target_entity_name().to_string(),
1066        metadata.target_store_path().to_string(),
1067        cardinality,
1068    )
1069}
1070
1071#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
1072fn summarize_field_kind(kind: &FieldKind) -> String {
1073    let mut out = String::new();
1074    write_field_kind_summary(&mut out, kind);
1075
1076    out
1077}
1078
1079// Stream one stable field-kind label directly into the output buffer so
1080// describe/sql surfaces do not retain a large recursive `format!` family.
1081fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
1082    if let Some(name) = kind.describe_kind_name() {
1083        out.push_str(name);
1084        return;
1085    }
1086
1087    match kind {
1088        FieldKind::Blob { max_len } => {
1089            write_length_bounded_field_kind_summary(out, "blob", *max_len);
1090        }
1091        FieldKind::Decimal { scale } => {
1092            let _ = write!(out, "decimal(scale={scale})");
1093        }
1094        FieldKind::IntBig { max_bytes } => {
1095            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1096        }
1097        FieldKind::Enum { path, .. } => {
1098            out.push_str("enum(");
1099            out.push_str(path);
1100            out.push(')');
1101        }
1102        FieldKind::Text { max_len } => {
1103            write_length_bounded_field_kind_summary(out, "text", *max_len);
1104        }
1105        FieldKind::Relation {
1106            target_entity_name,
1107            key_kind,
1108            ..
1109        } => {
1110            out.push_str("relation(target=");
1111            out.push_str(target_entity_name);
1112            out.push_str(", key=");
1113            write_field_kind_summary(out, key_kind);
1114            out.push(')');
1115        }
1116        FieldKind::List(inner) => {
1117            out.push_str("list<");
1118            write_field_kind_summary(out, inner);
1119            out.push('>');
1120        }
1121        FieldKind::Set(inner) => {
1122            out.push_str("set<");
1123            write_field_kind_summary(out, inner);
1124            out.push('>');
1125        }
1126        FieldKind::Map { key, value } => {
1127            out.push_str("map<");
1128            write_field_kind_summary(out, key);
1129            out.push_str(", ");
1130            write_field_kind_summary(out, value);
1131            out.push('>');
1132        }
1133        FieldKind::Composite { path, codec, shape } => {
1134            out.push_str("composite(path=");
1135            out.push_str(path);
1136            out.push_str(", codec=");
1137            write_composite_codec_summary(out, *codec);
1138            out.push_str(", shape=");
1139            write_generated_composite_shape_summary(out, shape);
1140            out.push(')');
1141        }
1142        FieldKind::Account
1143        | FieldKind::Bool
1144        | FieldKind::Date
1145        | FieldKind::Duration
1146        | FieldKind::Float32
1147        | FieldKind::Float64
1148        | FieldKind::Int8
1149        | FieldKind::Int16
1150        | FieldKind::Int32
1151        | FieldKind::Int64
1152        | FieldKind::Int128
1153        | FieldKind::Principal
1154        | FieldKind::Subaccount
1155        | FieldKind::Timestamp
1156        | FieldKind::Nat8
1157        | FieldKind::Nat16
1158        | FieldKind::Nat32
1159        | FieldKind::Nat64
1160        | FieldKind::Nat128
1161        | FieldKind::Ulid
1162        | FieldKind::Unit => unreachable!("schema describe invariant"),
1163        FieldKind::NatBig { max_bytes } => {
1164            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1165        }
1166    }
1167}
1168
1169fn write_composite_codec_summary(out: &mut String, codec: CompositeCodec) {
1170    match codec {
1171        CompositeCodec::StructuralV1 => out.push_str("structural_v1"),
1172    }
1173}
1174
1175fn write_generated_composite_shape_summary(out: &mut String, shape: &CompositeShapeModel) {
1176    match shape {
1177        CompositeShapeModel::Record(fields) => {
1178            out.push_str("record{");
1179            for (index, field) in fields.iter().enumerate() {
1180                if index > 0 {
1181                    out.push_str(", ");
1182                }
1183                out.push_str(field.name());
1184                out.push(':');
1185                write_field_kind_summary(out, &field.kind());
1186                write_composite_nullability_summary(out, field.nullable());
1187            }
1188            out.push('}');
1189        }
1190        CompositeShapeModel::Tuple(elements) => {
1191            out.push_str("tuple<");
1192            for (index, element) in elements.iter().enumerate() {
1193                if index > 0 {
1194                    out.push_str(", ");
1195                }
1196                write_generated_composite_element_summary(out, element);
1197            }
1198            out.push('>');
1199        }
1200        CompositeShapeModel::Newtype(inner) => {
1201            out.push_str("newtype<");
1202            write_generated_composite_element_summary(out, inner);
1203            out.push('>');
1204        }
1205    }
1206}
1207
1208fn write_generated_composite_element_summary(out: &mut String, element: &CompositeElementModel) {
1209    write_field_kind_summary(out, &element.kind());
1210    write_composite_nullability_summary(out, element.nullable());
1211}
1212
1213fn write_accepted_composite_shape_summary(
1214    out: &mut String,
1215    shape: &AcceptedCompositeShape,
1216    value_catalog: &AcceptedValueCatalogHandle,
1217) -> Result<(), InternalError> {
1218    match shape {
1219        AcceptedCompositeShape::Record(fields) => {
1220            out.push_str("record{");
1221            for (index, field) in fields.iter().enumerate() {
1222                if index > 0 {
1223                    out.push_str(", ");
1224                }
1225                out.push_str(field.name());
1226                out.push(':');
1227                write_accepted_composite_element_summary(out, field.contract(), value_catalog)?;
1228            }
1229            out.push('}');
1230        }
1231        AcceptedCompositeShape::Tuple(elements) => {
1232            out.push_str("tuple<");
1233            for (index, element) in elements.iter().enumerate() {
1234                if index > 0 {
1235                    out.push_str(", ");
1236                }
1237                write_accepted_composite_element_summary(out, element, value_catalog)?;
1238            }
1239            out.push('>');
1240        }
1241        AcceptedCompositeShape::Newtype(inner) => {
1242            out.push_str("newtype<");
1243            write_accepted_composite_element_summary(out, inner, value_catalog)?;
1244            out.push('>');
1245        }
1246    }
1247
1248    Ok(())
1249}
1250
1251fn write_accepted_composite_element_summary(
1252    out: &mut String,
1253    element: &AcceptedCompositeElement,
1254    value_catalog: &AcceptedValueCatalogHandle,
1255) -> Result<(), InternalError> {
1256    write_persisted_field_kind_summary(out, element.kind(), value_catalog)?;
1257    write_composite_nullability_summary(out, element.nullable());
1258    Ok(())
1259}
1260
1261fn write_composite_nullability_summary(out: &mut String, nullable: bool) {
1262    if nullable {
1263        out.push('?');
1264    }
1265}
1266
1267trait DescribeKindName {
1268    fn describe_kind_name(&self) -> Option<&'static str>;
1269}
1270
1271impl DescribeKindName for FieldKind {
1272    fn describe_kind_name(&self) -> Option<&'static str> {
1273        Some(match self {
1274            Self::Account => "account",
1275            Self::Bool => "bool",
1276            Self::Date => "date",
1277            Self::Duration => "duration",
1278            Self::Float32 => "float32",
1279            Self::Float64 => "float64",
1280            Self::Int8 => "int8",
1281            Self::Int16 => "int16",
1282            Self::Int32 => "int32",
1283            Self::Int64 => "int64",
1284            Self::Int128 => "int128",
1285            Self::Principal => "principal",
1286            Self::Subaccount => "subaccount",
1287            Self::Timestamp => "timestamp",
1288            Self::Nat8 => "nat8",
1289            Self::Nat16 => "nat16",
1290            Self::Nat32 => "nat32",
1291            Self::Nat64 => "nat64",
1292            Self::Nat128 => "nat128",
1293            Self::Ulid => "ulid",
1294            Self::Unit => "unit",
1295            Self::Blob { .. }
1296            | Self::Decimal { .. }
1297            | Self::Enum { .. }
1298            | Self::IntBig { .. }
1299            | Self::NatBig { .. }
1300            | Self::Text { .. }
1301            | Self::Relation { .. }
1302            | Self::List(_)
1303            | Self::Set(_)
1304            | Self::Map { .. }
1305            | Self::Composite { .. } => return None,
1306        })
1307    }
1308}
1309
1310// Write the common text/blob describe label. Both generated and accepted schema
1311// summaries use this path so bounded and explicitly unbounded contracts stay
1312// visibly identical across `DESCRIBE` and `SHOW COLUMNS`.
1313fn write_length_bounded_field_kind_summary(
1314    out: &mut String,
1315    kind_name: &str,
1316    max_len: Option<u32>,
1317) {
1318    out.push_str(kind_name);
1319    if let Some(max_len) = max_len {
1320        out.push_str("(max_len=");
1321        out.push_str(&max_len.to_string());
1322        out.push(')');
1323    } else {
1324        out.push_str("(unbounded)");
1325    }
1326}
1327
1328fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
1329    out.push_str(kind_name);
1330    out.push_str("(max_bytes=");
1331    out.push_str(&max_bytes.to_string());
1332    out.push(')');
1333}
1334
1335// Project generated proposal metadata without pretending that generated code
1336// owns accepted runtime semantics. Encoded generated defaults can expose their
1337// exact byte identity; accepted introspection below additionally decodes them.
1338fn generated_insert_default_facts(
1339    default: FieldDatabaseDefault,
1340) -> (Option<String>, Option<u32>, Option<String>) {
1341    match default {
1342        FieldDatabaseDefault::None => (None, None, None),
1343        FieldDatabaseDefault::EncodedSlotPayload(payload) => {
1344            let bytes = u32::try_from(payload.len()).ok();
1345            let hash = short_default_payload_fingerprint(payload);
1346            (
1347                Some(format!(
1348                    "slot_payload(bytes={}, sha256={hash})",
1349                    payload.len()
1350                )),
1351                bytes,
1352                Some(hash),
1353            )
1354        }
1355        FieldDatabaseDefault::AuthoredEnumUnit { enum_path, variant } => {
1356            (Some(format!("{enum_path}::{variant}")), None, None)
1357        }
1358    }
1359}
1360
1361///
1362/// RenderedTemporalPayload
1363///
1364/// One accepted temporal payload projected as an inseparable bounded value,
1365/// byte count, and stable diagnostic hash.
1366///
1367
1368struct RenderedTemporalPayload {
1369    value: String,
1370    bytes: u32,
1371    hash: String,
1372}
1373
1374fn accepted_field_temporal_facts(
1375    field: &AcceptedRowLayoutRuntimeField<'_>,
1376    value_catalog: &AcceptedValueCatalogHandle,
1377) -> Result<EntityFieldTemporalFacts, InternalError> {
1378    let write_policy = field.write_policy();
1379    let insert_omission = if write_policy.insert_generation().is_some() {
1380        "generated"
1381    } else if write_policy.write_management().is_some() {
1382        "managed"
1383    } else {
1384        match field.insert_omission_policy() {
1385            AcceptedInsertOmissionPolicy::NullIfMissing => "null",
1386            AcceptedInsertOmissionPolicy::DefaultIfMissing => "default",
1387            AcceptedInsertOmissionPolicy::Required => "required",
1388        }
1389    };
1390    let insert_default = field
1391        .insert_default()
1392        .slot_payload()
1393        .map(|payload| accepted_payload_facts(field, value_catalog, payload))
1394        .transpose()?;
1395    let (insert_default, insert_default_bytes, insert_default_hash) = match insert_default {
1396        Some(payload) => (Some(payload.value), Some(payload.bytes), Some(payload.hash)),
1397        None => (None, None, None),
1398    };
1399    let (historical_fill, historical_fill_bytes, historical_fill_hash) =
1400        match field.historical_fill() {
1401            SchemaHistoricalFill::Reject => (Some("reject".to_string()), None, None),
1402            SchemaHistoricalFill::Null => (Some("null".to_string()), None, None),
1403            SchemaHistoricalFill::SlotPayload(payload) => {
1404                let rendered = accepted_payload_facts(field, value_catalog, payload.as_slice())?;
1405                (
1406                    Some(rendered.value),
1407                    Some(rendered.bytes),
1408                    Some(rendered.hash),
1409                )
1410            }
1411        };
1412
1413    Ok(EntityFieldTemporalFacts {
1414        insert_omission: Some(insert_omission.to_string()),
1415        insert_default,
1416        insert_default_bytes,
1417        insert_default_hash,
1418        introduced_in_layout: Some(field.introduced_in_layout().get()),
1419        historical_fill,
1420        historical_fill_bytes,
1421        historical_fill_hash,
1422    })
1423}
1424
1425fn accepted_payload_facts(
1426    field: &AcceptedRowLayoutRuntimeField<'_>,
1427    value_catalog: &AcceptedValueCatalogHandle,
1428    payload: &[u8],
1429) -> Result<RenderedTemporalPayload, InternalError> {
1430    let persistence = AcceptedFieldPersistenceContract::new(value_catalog, field.decode_contract())
1431        .map_err(|_| InternalError::store_invariant())?;
1432    let admitted = decode_admitted_value_from_accepted_field_contract(persistence, payload)?;
1433    let output = output_value_from_runtime(value_catalog.enum_catalog(), admitted.value())
1434        .map_err(|_| InternalError::store_invariant())?;
1435    let hash = short_default_payload_fingerprint(payload);
1436    let rendered = bounded_schema_value_rendering(&output, payload, hash.as_str());
1437    let bytes = u32::try_from(payload.len()).map_err(|_| InternalError::store_invariant())?;
1438
1439    Ok(RenderedTemporalPayload {
1440        value: rendered,
1441        bytes,
1442        hash,
1443    })
1444}
1445
1446fn bounded_schema_value_rendering(value: &OutputValue, payload: &[u8], hash: &str) -> String {
1447    let rendered = match value {
1448        OutputValue::Text(value) => format!("'{}'", value.escape_default()),
1449        _ => render_output_value_text(value),
1450    };
1451    if rendered.len() <= MAX_SCHEMA_VALUE_RENDER_CHARS {
1452        return rendered;
1453    }
1454
1455    format!(
1456        "{}(bytes={}, sha256={})",
1457        output_value_kind_label(value),
1458        payload.len(),
1459        hash,
1460    )
1461}
1462
1463const fn output_value_kind_label(value: &OutputValue) -> &'static str {
1464    match value {
1465        OutputValue::Account(_) => "account",
1466        OutputValue::Blob(_) => "blob",
1467        OutputValue::Bool(_) => "bool",
1468        OutputValue::Date(_) => "date",
1469        OutputValue::Decimal(_) => "decimal",
1470        OutputValue::Duration(_) => "duration",
1471        OutputValue::Enum(_) => "enum",
1472        OutputValue::Float32(_) => "float32",
1473        OutputValue::Float64(_) => "float64",
1474        OutputValue::Int64(_) => "int64",
1475        OutputValue::Int128(_) => "int128",
1476        OutputValue::IntBig(_) => "int_big",
1477        OutputValue::List(_) => "list",
1478        OutputValue::Map(_) => "map",
1479        OutputValue::Null => "null",
1480        OutputValue::Principal(_) => "principal",
1481        OutputValue::Subaccount(_) => "subaccount",
1482        OutputValue::Text(_) => "text",
1483        OutputValue::Timestamp(_) => "timestamp",
1484        OutputValue::Nat64(_) => "nat64",
1485        OutputValue::Nat128(_) => "nat128",
1486        OutputValue::NatBig(_) => "nat_big",
1487        OutputValue::Ulid(_) => "ulid",
1488        OutputValue::Unit => "unit",
1489    }
1490}
1491
1492fn short_default_payload_fingerprint(payload: &[u8]) -> String {
1493    let digest = Sha256::digest(payload);
1494    let mut out = String::with_capacity(16);
1495    for byte in &digest[..8] {
1496        let _ = write!(out, "{byte:02x}");
1497    }
1498    out
1499}
1500
1501#[cfg_attr(
1502    doc,
1503    doc = "Render one stable field-kind label from accepted persisted schema metadata."
1504)]
1505fn summarize_persisted_field_kind(
1506    kind: &AcceptedFieldKind,
1507    value_catalog: &AcceptedValueCatalogHandle,
1508) -> Result<String, InternalError> {
1509    let mut out = String::new();
1510    write_persisted_field_kind_summary(&mut out, kind, value_catalog)?;
1511
1512    Ok(out)
1513}
1514
1515// Stream the accepted persisted field-kind label in the same public format as
1516// generated `FieldKind` summaries. Top-level live-schema metadata can then
1517// drive DESCRIBE output without converting back into generated static types.
1518fn write_persisted_field_kind_summary(
1519    out: &mut String,
1520    kind: &AcceptedFieldKind,
1521    value_catalog: &AcceptedValueCatalogHandle,
1522) -> Result<(), InternalError> {
1523    if let Some(name) = kind.describe_kind_name() {
1524        out.push_str(name);
1525        return Ok(());
1526    }
1527
1528    match kind {
1529        AcceptedFieldKind::Blob { max_len } => {
1530            write_length_bounded_field_kind_summary(out, "blob", *max_len);
1531        }
1532        AcceptedFieldKind::Decimal { scale } => {
1533            let _ = write!(out, "decimal(scale={scale})");
1534        }
1535        AcceptedFieldKind::IntBig { max_bytes } => {
1536            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1537        }
1538        AcceptedFieldKind::Enum { type_id } => {
1539            let definition = value_catalog
1540                .enum_catalog()
1541                .enum_type(*type_id)
1542                .ok_or_else(InternalError::store_invariant)?;
1543            out.push_str("enum(");
1544            out.push_str(definition.path());
1545            out.push(')');
1546        }
1547        AcceptedFieldKind::Text { max_len } => {
1548            write_length_bounded_field_kind_summary(out, "text", *max_len);
1549        }
1550        AcceptedFieldKind::Relation {
1551            target_entity_name,
1552            key_kind,
1553            ..
1554        } => {
1555            out.push_str("relation(target=");
1556            out.push_str(target_entity_name);
1557            out.push_str(", key=");
1558            write_persisted_field_kind_summary(out, key_kind, value_catalog)?;
1559            out.push(')');
1560        }
1561        AcceptedFieldKind::List(inner) => {
1562            out.push_str("list<");
1563            write_persisted_field_kind_summary(out, inner, value_catalog)?;
1564            out.push('>');
1565        }
1566        AcceptedFieldKind::Set(inner) => {
1567            out.push_str("set<");
1568            write_persisted_field_kind_summary(out, inner, value_catalog)?;
1569            out.push('>');
1570        }
1571        AcceptedFieldKind::Map { key, value } => {
1572            out.push_str("map<");
1573            write_persisted_field_kind_summary(out, key, value_catalog)?;
1574            out.push_str(", ");
1575            write_persisted_field_kind_summary(out, value, value_catalog)?;
1576            out.push('>');
1577        }
1578        AcceptedFieldKind::Composite { type_id } => {
1579            let composite_catalog = value_catalog.composite_catalog();
1580            let definition = composite_catalog
1581                .composite_type(*type_id)
1582                .ok_or_else(InternalError::store_invariant)?;
1583            out.push_str("composite(path=");
1584            out.push_str(definition.path());
1585            out.push_str(", codec=");
1586            write_composite_codec_summary(out, definition.codec());
1587            out.push_str(", shape=");
1588            write_accepted_composite_shape_summary(out, definition.shape(), value_catalog)?;
1589            out.push(')');
1590        }
1591        AcceptedFieldKind::Account
1592        | AcceptedFieldKind::Bool
1593        | AcceptedFieldKind::Date
1594        | AcceptedFieldKind::Duration
1595        | AcceptedFieldKind::Float32
1596        | AcceptedFieldKind::Float64
1597        | AcceptedFieldKind::Int8
1598        | AcceptedFieldKind::Int16
1599        | AcceptedFieldKind::Int32
1600        | AcceptedFieldKind::Int64
1601        | AcceptedFieldKind::Int128
1602        | AcceptedFieldKind::Principal
1603        | AcceptedFieldKind::Subaccount
1604        | AcceptedFieldKind::Timestamp
1605        | AcceptedFieldKind::Nat8
1606        | AcceptedFieldKind::Nat16
1607        | AcceptedFieldKind::Nat32
1608        | AcceptedFieldKind::Nat64
1609        | AcceptedFieldKind::Nat128
1610        | AcceptedFieldKind::Ulid
1611        | AcceptedFieldKind::Unit => unreachable!("schema describe invariant"),
1612        AcceptedFieldKind::NatBig { max_bytes } => {
1613            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1614        }
1615    }
1616
1617    Ok(())
1618}
1619
1620impl DescribeKindName for AcceptedFieldKind {
1621    fn describe_kind_name(&self) -> Option<&'static str> {
1622        Some(match self {
1623            Self::Account => "account",
1624            Self::Bool => "bool",
1625            Self::Date => "date",
1626            Self::Duration => "duration",
1627            Self::Float32 => "float32",
1628            Self::Float64 => "float64",
1629            Self::Int8 => "int8",
1630            Self::Int16 => "int16",
1631            Self::Int32 => "int32",
1632            Self::Int64 => "int64",
1633            Self::Int128 => "int128",
1634            Self::Principal => "principal",
1635            Self::Subaccount => "subaccount",
1636            Self::Timestamp => "timestamp",
1637            Self::Nat8 => "nat8",
1638            Self::Nat16 => "nat16",
1639            Self::Nat32 => "nat32",
1640            Self::Nat64 => "nat64",
1641            Self::Nat128 => "nat128",
1642            Self::Ulid => "ulid",
1643            Self::Unit => "unit",
1644            Self::Blob { .. }
1645            | Self::Decimal { .. }
1646            | Self::Enum { .. }
1647            | Self::IntBig { .. }
1648            | Self::NatBig { .. }
1649            | Self::Text { .. }
1650            | Self::Relation { .. }
1651            | Self::List(_)
1652            | Self::Set(_)
1653            | Self::Map { .. }
1654            | Self::Composite { .. } => return None,
1655        })
1656    }
1657}
1658
1659//
1660// TESTS
1661//
1662
1663#[cfg(test)]
1664mod tests;