Skip to main content

riffdb_application/
portability.rs

1//! Compiler and adapter validation for foundational portability values.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use riffdb_contract_ir::{ContractBundle, RecordSchema};
6use riffdb_types::EntityTypeId;
7
8use crate::{AdapterConformanceManifest, RoleOperationKind};
9
10pub use riffdb_types::application_portability::*;
11
12/// Compiler- and adapter-owned checks over the serialization-neutral value.
13pub trait ApplicationPortabilityValidation {
14    /// Verifies every mapping against one exact compiler-owned contract bundle.
15    fn validate_compiled_contract(
16        &self,
17        bundle: &ContractBundle,
18    ) -> Result<(), ApplicationPortabilityError>;
19
20    /// Derives the only legal entity reconstitution order.
21    fn compiled_reimport_entity_schedule(
22        &self,
23        bundle: &ContractBundle,
24    ) -> Result<Vec<EntityTypeId>, ApplicationPortabilityError>;
25
26    /// Binds mappings and observations to one exact adapter-owned public surface.
27    fn validate_adapter_conformance(
28        &self,
29        adapter: &AdapterConformanceManifest,
30    ) -> Result<(), ApplicationPortabilityError>;
31}
32
33impl ApplicationPortabilityValidation for ApplicationPortabilityManifest {
34    fn validate_compiled_contract(
35        &self,
36        bundle: &ContractBundle,
37    ) -> Result<(), ApplicationPortabilityError> {
38        let input = self.input();
39        if bundle.lineage() != &input.contract_lineage
40            || bundle.contract_version() != input.contract_version
41            || bundle.bundle_hash() != input.contract_bundle_hash
42        {
43            return Err(error(
44                ApplicationPortabilityErrorKind::ReconciliationMismatch,
45            ));
46        }
47        for mapping in &input.mappings {
48            let source = match mapping.class() {
49                PortableRecordClass::Entity => bundle
50                    .schema()
51                    .entities()
52                    .iter()
53                    .find(|entity| entity.name() == mapping.symbol().as_str())
54                    .map(|entity| entity.record()),
55                PortableRecordClass::Event => bundle
56                    .schema()
57                    .events()
58                    .iter()
59                    .find(|event| event.name() == mapping.symbol().as_str())
60                    .map(|event| event.payload()),
61            }
62            .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
63            match mapping.strategy() {
64                PortableReimportStrategy::ReimportCommand { command } => {
65                    if mapping.class() != PortableRecordClass::Entity {
66                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
67                    }
68                    let command = bundle
69                        .commands()
70                        .iter()
71                        .find(|plan| plan.name() == command.as_str())
72                        .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
73                    let target = command.input().record();
74                    let [target_field] = target.fields() else {
75                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
76                    };
77                    let Some((element, maximum)) = target_field.value_type().list_parts() else {
78                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
79                    };
80                    if !command.is_reimport()
81                        || command.idempotency_input().is_some()
82                        || maximum == 0
83                        || element.record_ref() != Some(source.owner())
84                    {
85                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
86                    }
87                }
88                PortableReimportStrategy::LegacyApplicationCommand {
89                    command,
90                    idempotency_input,
91                    record_input,
92                    fields,
93                } => {
94                    let command = bundle
95                        .commands()
96                        .iter()
97                        .find(|plan| plan.name() == command.as_str())
98                        .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
99                    let target = command.input().record();
100                    if let Some(record_input) = record_input {
101                        let target_field = field_by_name(target, record_input.as_str())
102                            .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
103                        let Some((element, maximum)) = target_field.value_type().list_parts()
104                        else {
105                            return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
106                        };
107                        if maximum == 0 || element.record_ref() != Some(source.owner()) {
108                            return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
109                        }
110                    } else {
111                        for binding in fields {
112                            let source_field =
113                                field_by_name(source, binding.source_field().as_str()).ok_or_else(
114                                    || error(ApplicationPortabilityErrorKind::InvalidShape),
115                                )?;
116                            let target_field =
117                                field_by_name(target, binding.command_input().as_str())
118                                    .ok_or_else(|| {
119                                        error(ApplicationPortabilityErrorKind::InvalidShape)
120                                    })?;
121                            if source_field.value_type() != target_field.value_type() {
122                                return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
123                            }
124                        }
125                    }
126                    let idempotency_field = field_by_name(target, idempotency_input.as_str());
127                    if idempotency_field.is_none()
128                        || command.idempotency_input() != idempotency_field.map(|field| field.id())
129                        || target.fields().iter().any(|target_field| {
130                            target_field.name() != idempotency_input.as_str()
131                                && record_input.as_ref().is_none_or(|record_input| {
132                                    record_input.as_str() != target_field.name()
133                                })
134                                && !fields.iter().any(|binding| {
135                                    binding.command_input().as_str() == target_field.name()
136                                })
137                        })
138                    {
139                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
140                    }
141                }
142                PortableReimportStrategy::Migration { .. } => {}
143            }
144        }
145        Ok(())
146    }
147
148    fn compiled_reimport_entity_schedule(
149        &self,
150        bundle: &ContractBundle,
151    ) -> Result<Vec<EntityTypeId>, ApplicationPortabilityError> {
152        self.validate_compiled_contract(bundle)?;
153        let selected = self
154            .input()
155            .mappings
156            .iter()
157            .filter(|mapping| mapping.class() == PortableRecordClass::Entity)
158            .map(|mapping| {
159                if !matches!(
160                    mapping.strategy(),
161                    PortableReimportStrategy::ReimportCommand { .. }
162                ) {
163                    return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
164                }
165                let entity = bundle
166                    .schema()
167                    .entities()
168                    .iter()
169                    .find(|entity| entity.name() == mapping.symbol().as_str())
170                    .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
171                Ok((entity.id(), entity.name()))
172            })
173            .collect::<Result<Vec<_>, _>>()?;
174        if selected.is_empty() {
175            return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
176        }
177        let selected_ids = selected
178            .iter()
179            .map(|(entity, _)| *entity)
180            .collect::<BTreeSet<_>>();
181        let mut indegree = selected_ids
182            .iter()
183            .map(|entity| (*entity, 0_usize))
184            .collect::<BTreeMap<_, _>>();
185        let mut dependents = selected_ids
186            .iter()
187            .map(|entity| (*entity, BTreeSet::new()))
188            .collect::<BTreeMap<_, _>>();
189        for relationship in bundle.schema().relationships() {
190            if !selected_ids.contains(&relationship.source_entity()) {
191                continue;
192            }
193            if !selected_ids.contains(&relationship.target_entity()) {
194                return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
195            }
196            if dependents
197                .get_mut(&relationship.target_entity())
198                .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?
199                .insert(relationship.source_entity())
200            {
201                let count = indegree
202                    .get_mut(&relationship.source_entity())
203                    .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
204                *count = count
205                    .checked_add(1)
206                    .ok_or_else(|| error(ApplicationPortabilityErrorKind::LimitExceeded))?;
207            }
208        }
209        let mut ready = indegree
210            .iter()
211            .filter_map(|(entity, count)| (*count == 0).then_some(*entity))
212            .collect::<BTreeSet<_>>();
213        let mut schedule = Vec::with_capacity(selected_ids.len());
214        while let Some(entity) = ready.pop_first() {
215            schedule.push(entity);
216            for dependent in dependents.get(&entity).into_iter().flatten() {
217                let count = indegree
218                    .get_mut(dependent)
219                    .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
220                *count = count
221                    .checked_sub(1)
222                    .ok_or_else(|| error(ApplicationPortabilityErrorKind::InvalidShape))?;
223                if *count == 0 {
224                    ready.insert(*dependent);
225                }
226            }
227        }
228        if schedule.len() != selected_ids.len() {
229            return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
230        }
231        Ok(schedule)
232    }
233
234    fn validate_adapter_conformance(
235        &self,
236        adapter: &AdapterConformanceManifest,
237    ) -> Result<(), ApplicationPortabilityError> {
238        let input = self.input();
239        if adapter.identity() != input.adapter_manifest_hash
240            || adapter.input().contract_lineage != input.contract_lineage
241            || !adapter.input().evolution.iter().any(|evolution| {
242                evolution.successor().version() == input.contract_version
243                    && evolution.successor().bundle_hash() == input.contract_bundle_hash
244            })
245        {
246            return Err(error(
247                ApplicationPortabilityErrorKind::ReconciliationMismatch,
248            ));
249        }
250        for mapping in &input.mappings {
251            match mapping.strategy() {
252                PortableReimportStrategy::ReimportCommand { command } => {
253                    if adapter.input().roles.iter().any(|role| {
254                        role.operations().iter().any(|operation| {
255                            operation.kind() == RoleOperationKind::Command
256                                && operation.name().as_str() == command.as_str()
257                        })
258                    }) {
259                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
260                    }
261                }
262                PortableReimportStrategy::LegacyApplicationCommand { command, .. } => {
263                    if !adapter.input().roles.iter().any(|role| {
264                        role.operations().iter().any(|operation| {
265                            operation.kind() == RoleOperationKind::Command
266                                && operation.name().as_str() == command.as_str()
267                        })
268                    }) {
269                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
270                    }
271                }
272                PortableReimportStrategy::Migration { migration_hash } => {
273                    if !adapter
274                        .input()
275                        .evolution
276                        .iter()
277                        .any(|evolution| evolution.migration_hash() == Some(*migration_hash))
278                    {
279                        return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
280                    }
281                }
282            }
283        }
284        if input.observations.iter().any(|observation| {
285            !adapter.input().roles.iter().any(|role| {
286                role.operations().iter().any(|operation| {
287                    operation.kind() == RoleOperationKind::Query
288                        && operation.name().as_str() == observation.query().as_str()
289                })
290            })
291        }) {
292            return Err(error(ApplicationPortabilityErrorKind::InvalidShape));
293        }
294        Ok(())
295    }
296}
297
298const fn error(kind: ApplicationPortabilityErrorKind) -> ApplicationPortabilityError {
299    ApplicationPortabilityError::new(kind)
300}
301
302fn field_by_name<'a>(
303    record: &'a RecordSchema,
304    name: &str,
305) -> Option<&'a riffdb_contract_ir::FieldSchema> {
306    record.fields().iter().find(|field| field.name() == name)
307}