riffdb_application/
portability.rs1use 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
12pub trait ApplicationPortabilityValidation {
14 fn validate_compiled_contract(
16 &self,
17 bundle: &ContractBundle,
18 ) -> Result<(), ApplicationPortabilityError>;
19
20 fn compiled_reimport_entity_schedule(
22 &self,
23 bundle: &ContractBundle,
24 ) -> Result<Vec<EntityTypeId>, ApplicationPortabilityError>;
25
26 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}