composer.rs (14197B)
1 //! Typed local editing boundary; no publishable plan, byte handle or raw database API. 2 use crate::{FfiAddCommandType, FfiEventTimingKind, MOBILE_FFI_SCHEMA_VERSION, TeraAppError}; 3 use tera_core::runtime::product_surface::{ 4 AddCommandType, ComposerDraft, ComposerEditSequence, ComposerError, ComposerFormInput, 5 ComposerId, ComposerListEntry, ComposerMediaInput, ComposerPage, ComposerPartialForm, 6 ComposerRepairReason, ComposerRevision, ComposerSaveReceipt, ComposerScope, LocalNetworkId, 7 Phase1DraftEventTiming, 8 }; 9 10 mod error; 11 12 /// Reserves only a random editing identity, without writing or acknowledging a save. 13 #[cfg_attr(not(coverage_nightly), uniffi::export)] 14 pub fn composer_reserve_id() -> Result<FfiComposerIdRecord, TeraAppError> { 15 Ok(FfiComposerIdRecord { 16 schema_version: MOBILE_FFI_SCHEMA_VERSION, 17 id: hex::encode(ComposerId::generate()?.as_bytes()), 18 }) 19 } 20 21 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 22 pub struct FfiComposerIdRecord { 23 pub schema_version: u16, 24 pub id: String, 25 } 26 27 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 28 pub struct FfiComposerScopeRecord { 29 pub schema_version: u16, 30 pub author_public_key: String, 31 pub local_network_id: String, 32 } 33 34 impl TryFrom<FfiComposerScopeRecord> for ComposerScope { 35 type Error = TeraAppError; 36 fn try_from(value: FfiComposerScopeRecord) -> Result<Self, Self::Error> { 37 version(value.schema_version)?; 38 if value.author_public_key.len() != 64 || !canonical_hex(&value.author_public_key) { 39 return Err(TeraAppError::invalid_argument("composer_scope_invalid")); 40 } 41 let author = value 42 .author_public_key 43 .parse() 44 .map_err(|_| TeraAppError::invalid_argument("composer_scope_invalid"))?; 45 let context = LocalNetworkId::new(value.local_network_id) 46 .map_err(|_| TeraAppError::invalid_argument("composer_scope_invalid"))?; 47 Ok(Self::new(author, context)) 48 } 49 } 50 51 impl From<&ComposerScope> for FfiComposerScopeRecord { 52 fn from(value: &ComposerScope) -> Self { 53 Self { 54 schema_version: MOBILE_FFI_SCHEMA_VERSION, 55 author_public_key: value.author().to_hex(), 56 local_network_id: value.local_network().as_str().to_owned(), 57 } 58 } 59 } 60 61 /// Reference metadata only; neither a file handle nor proof of upload readiness. 62 #[derive(Clone, Eq, PartialEq, uniffi::Record)] 63 pub struct FfiComposerMediaRecord { 64 pub schema_version: u16, 65 pub opaque_reference: String, 66 pub sha256: String, 67 pub media_type: String, 68 pub byte_size: u64, 69 pub width: u32, 70 pub height: u32, 71 pub alt: String, 72 pub prepared_at_unix_s: u64, 73 } 74 75 #[derive(Clone, Eq, PartialEq, uniffi::Record)] 76 pub struct FfiComposerFormRecord { 77 pub schema_version: u16, 78 pub command_type: FfiAddCommandType, 79 pub content: String, 80 pub identifier: Option<String>, 81 pub title: Option<String>, 82 pub summary: Option<String>, 83 pub location: Option<String>, 84 pub event_timing: Option<FfiEventTimingKind>, 85 pub event_start_date: Option<String>, 86 pub event_end_date: Option<String>, 87 pub event_start_unix_s: Option<u64>, 88 pub event_end_unix_s: Option<u64>, 89 pub event_timezone: Option<String>, 90 pub price_amount: Option<String>, 91 pub currency: Option<String>, 92 pub unit: Option<String>, 93 pub quantity: Option<String>, 94 pub food_published_at_unix_s: Option<u64>, 95 pub food_status: Option<String>, 96 pub media: Vec<FfiComposerMediaRecord>, 97 } 98 99 impl TryFrom<FfiComposerFormRecord> for ComposerPartialForm { 100 type Error = TeraAppError; 101 fn try_from(value: FfiComposerFormRecord) -> Result<Self, Self::Error> { 102 version(value.schema_version)?; 103 let input = ComposerFormInput { 104 command_type: match value.command_type { 105 FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, 106 FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate, 107 FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk, 108 FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent, 109 FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability, 110 }, 111 content: value.content, 112 identifier: value.identifier, 113 title: value.title, 114 summary: value.summary, 115 location: value.location, 116 event_timing: value.event_timing.map(|timing| match timing { 117 FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay, 118 FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed, 119 }), 120 event_start_date: value.event_start_date, 121 event_end_date: value.event_end_date, 122 event_start_unix_s: value.event_start_unix_s, 123 event_end_unix_s: value.event_end_unix_s, 124 event_timezone: value.event_timezone, 125 price_amount: value.price_amount, 126 currency: value.currency, 127 unit: value.unit, 128 quantity: value.quantity, 129 food_published_at_unix_s: value.food_published_at_unix_s, 130 food_status: value.food_status, 131 media: value 132 .media 133 .into_iter() 134 .map(TryInto::try_into) 135 .collect::<Result<_, _>>()?, 136 }; 137 Self::new(input).map_err(Into::into) 138 } 139 } 140 141 impl TryFrom<FfiComposerMediaRecord> for ComposerMediaInput { 142 type Error = TeraAppError; 143 fn try_from(value: FfiComposerMediaRecord) -> Result<Self, Self::Error> { 144 version(value.schema_version)?; 145 Ok(Self { 146 opaque_reference: value.opaque_reference, 147 sha256: value.sha256, 148 media_type: value.media_type, 149 byte_size: value.byte_size, 150 width: value.width, 151 height: value.height, 152 alt: value.alt, 153 prepared_at_unix_s: value.prepared_at_unix_s, 154 }) 155 } 156 } 157 158 impl From<&ComposerPartialForm> for FfiComposerFormRecord { 159 fn from(form: &ComposerPartialForm) -> Self { 160 let value = form.input(); 161 Self { 162 schema_version: MOBILE_FFI_SCHEMA_VERSION, 163 command_type: value.command_type.into(), 164 content: value.content.clone(), 165 identifier: value.identifier.clone(), 166 title: value.title.clone(), 167 summary: value.summary.clone(), 168 location: value.location.clone(), 169 event_timing: value.event_timing.map(|timing| match timing { 170 Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay, 171 Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed, 172 }), 173 event_start_date: value.event_start_date.clone(), 174 event_end_date: value.event_end_date.clone(), 175 event_start_unix_s: value.event_start_unix_s, 176 event_end_unix_s: value.event_end_unix_s, 177 event_timezone: value.event_timezone.clone(), 178 price_amount: value.price_amount.clone(), 179 currency: value.currency.clone(), 180 unit: value.unit.clone(), 181 quantity: value.quantity.clone(), 182 food_published_at_unix_s: value.food_published_at_unix_s, 183 food_status: value.food_status.clone(), 184 media: value.media.iter().map(Into::into).collect(), 185 } 186 } 187 } 188 189 impl From<&ComposerMediaInput> for FfiComposerMediaRecord { 190 fn from(value: &ComposerMediaInput) -> Self { 191 Self { 192 schema_version: MOBILE_FFI_SCHEMA_VERSION, 193 opaque_reference: value.opaque_reference.clone(), 194 sha256: value.sha256.clone(), 195 media_type: value.media_type.clone(), 196 byte_size: value.byte_size, 197 width: value.width, 198 height: value.height, 199 alt: value.alt.clone(), 200 prepared_at_unix_s: value.prepared_at_unix_s, 201 } 202 } 203 } 204 205 #[derive(Clone, Eq, PartialEq, uniffi::Record)] 206 pub struct FfiComposerSaveRequest { 207 pub schema_version: u16, 208 pub scope: FfiComposerScopeRecord, 209 pub id: String, 210 /// None creates a new composer; Some compares with the durable head. 211 pub expected_revision: Option<u64>, 212 pub edit_sequence: u64, 213 pub form: FfiComposerFormRecord, 214 } 215 216 #[derive(Clone, Eq, PartialEq, uniffi::Record)] 217 pub struct FfiComposerDraftRecord { 218 pub schema_version: u16, 219 pub scope: FfiComposerScopeRecord, 220 pub id: String, 221 pub revision: u64, 222 pub edit_sequence: u64, 223 pub form: FfiComposerFormRecord, 224 } 225 226 impl From<&ComposerDraft> for FfiComposerDraftRecord { 227 fn from(value: &ComposerDraft) -> Self { 228 Self { 229 schema_version: MOBILE_FFI_SCHEMA_VERSION, 230 scope: value.scope().into(), 231 id: hex::encode(value.id().as_bytes()), 232 revision: value.revision().get(), 233 edit_sequence: value.edit_sequence().get(), 234 form: value.form().into(), 235 } 236 } 237 } 238 239 /// A historical owner-committed acknowledgment, not a promise that no later edit exists. 240 #[derive(Clone, Eq, PartialEq, uniffi::Record)] 241 pub struct FfiComposerSaveReceipt { 242 pub schema_version: u16, 243 pub draft: FfiComposerDraftRecord, 244 pub replayed: bool, 245 } 246 247 impl From<&ComposerSaveReceipt> for FfiComposerSaveReceipt { 248 fn from(value: &ComposerSaveReceipt) -> Self { 249 Self { 250 schema_version: MOBILE_FFI_SCHEMA_VERSION, 251 draft: value.draft().into(), 252 replayed: value.is_replay(), 253 } 254 } 255 } 256 257 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 258 pub struct FfiComposerSummaryRecord { 259 pub schema_version: u16, 260 pub id: String, 261 pub revision: u64, 262 pub edit_sequence: u64, 263 pub command_type: FfiAddCommandType, 264 pub created_at_unix_ms: u64, 265 pub updated_at_unix_ms: u64, 266 } 267 268 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 269 pub enum FfiComposerRepairReason { 270 UnsupportedSchema, 271 CorruptRecord, 272 } 273 274 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Enum)] 275 pub enum FfiComposerListEntry { 276 Draft { 277 summary: FfiComposerSummaryRecord, 278 }, 279 /// An opaque storage locator, including possibly invalid ID bytes. Never an editing ID. 280 Repair { 281 draft_key: String, 282 revision: u64, 283 reason: FfiComposerRepairReason, 284 }, 285 } 286 287 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 288 pub struct FfiComposerPageRecord { 289 pub schema_version: u16, 290 pub scope: FfiComposerScopeRecord, 291 pub entries: Vec<FfiComposerListEntry>, 292 pub next_cursor: Option<String>, 293 } 294 295 impl From<&ComposerPage> for FfiComposerPageRecord { 296 fn from(value: &ComposerPage) -> Self { 297 Self { 298 schema_version: MOBILE_FFI_SCHEMA_VERSION, 299 scope: value.scope().into(), 300 entries: value 301 .entries() 302 .iter() 303 .map(|entry| match entry { 304 ComposerListEntry::Draft(summary) => FfiComposerListEntry::Draft { 305 summary: FfiComposerSummaryRecord { 306 schema_version: MOBILE_FFI_SCHEMA_VERSION, 307 id: hex::encode(summary.id().as_bytes()), 308 revision: summary.revision().get(), 309 edit_sequence: summary.edit_sequence().get(), 310 command_type: summary.command_type().into(), 311 created_at_unix_ms: summary.created_at_unix_ms(), 312 updated_at_unix_ms: summary.updated_at_unix_ms(), 313 }, 314 }, 315 ComposerListEntry::Repair { 316 draft_key, 317 revision, 318 reason, 319 } => FfiComposerListEntry::Repair { 320 draft_key: hex::encode(draft_key), 321 revision: *revision, 322 reason: match reason { 323 ComposerRepairReason::UnsupportedSchema => { 324 FfiComposerRepairReason::UnsupportedSchema 325 } 326 ComposerRepairReason::CorruptRecord => { 327 FfiComposerRepairReason::CorruptRecord 328 } 329 }, 330 }, 331 }) 332 .collect(), 333 next_cursor: value.next_cursor().map(str::to_owned), 334 } 335 } 336 } 337 338 pub(crate) fn version(value: u16) -> Result<(), TeraAppError> { 339 if value != MOBILE_FFI_SCHEMA_VERSION { 340 return Err(TeraAppError::invalid_argument( 341 "composer_schema_unsupported", 342 )); 343 } 344 Ok(()) 345 } 346 347 fn canonical_hex(value: &str) -> bool { 348 value 349 .bytes() 350 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 351 } 352 353 pub(crate) fn id(value: &str) -> Result<ComposerId, TeraAppError> { 354 if value.len() != 32 || !canonical_hex(value) { 355 return Err(ComposerError::InvalidIdentity.into()); 356 } 357 ComposerId::new(crate::decode_id(value, "composer_id_invalid")?).map_err(Into::into) 358 } 359 360 impl FfiComposerSaveRequest { 361 pub(crate) fn validate_identity( 362 &self, 363 ) -> Result<(ComposerId, Option<ComposerRevision>, ComposerEditSequence), TeraAppError> { 364 version(self.schema_version)?; 365 Ok(( 366 id(&self.id)?, 367 self.expected_revision 368 .map(ComposerRevision::new) 369 .transpose()?, 370 ComposerEditSequence::new(self.edit_sequence)?, 371 )) 372 } 373 } 374 375 // Editing payloads must not leak into diagnostic formatting. 376 macro_rules! redacted_debug { 377 ($($name:ty),+ $(,)?) => {$( 378 impl std::fmt::Debug for $name { 379 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 380 formatter.debug_struct(stringify!($name)).finish_non_exhaustive() 381 } 382 } 383 )+}; 384 } 385 redacted_debug!( 386 FfiComposerMediaRecord, 387 FfiComposerFormRecord, 388 FfiComposerSaveRequest, 389 FfiComposerDraftRecord, 390 FfiComposerSaveReceipt 391 );