1use std::collections::HashMap;
2use std::str::FromStr;
3
4use autosurgeon::{Hydrate, Reconcile, Text, reconcile};
5pub use catcolab_document_types::current::DocumentType;
6use samod::DocumentId;
7use serde::Deserialize;
8use sqlx::PgPool;
9use tracing::{debug, error, info, warn};
10use ts_rs::TS;
11
12use crate::app::{AppError, AppState};
13use crate::autosurgeon_datetime::{datetime_millis, option_datetime_millis};
14
15pub const DEFAULT_DOC_NAME: &str = "untitled";
17
18#[derive(Debug, Clone, Eq, PartialEq, Reconcile, Hydrate, TS)]
23#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
24pub struct UserInfo {
25 #[ts(as = "Option<String>")]
27 pub username: Option<Text>,
28 #[autosurgeon(rename = "displayName")]
30 #[ts(as = "Option<String>")]
31 pub display_name: Option<Text>,
32}
33
34#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
38#[derive(Debug, Clone, Deserialize, Reconcile, Hydrate, TS)]
39#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
40pub struct PermissionInfo {
41 #[key]
43 pub user: Option<String>,
44 pub level: crate::auth::PermissionLevel,
46}
47
48#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
50#[derive(Debug, Clone, Reconcile, Hydrate, TS)]
51#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
52pub struct RelationInfo {
53 #[autosurgeon(rename = "refId")]
55 #[ts(type = "Uint8Array")]
56 pub ref_id: uuid::Uuid,
57 #[autosurgeon(rename = "relationType")]
59 #[ts(rename = "relationType")]
60 pub relation_type: String,
61}
62
63#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
65#[derive(Debug, Clone, Deserialize, Reconcile, Hydrate, TS)]
66#[serde(rename_all = "camelCase")]
67#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
68pub struct SnapshotInfo {
69 pub parent: Option<i32>,
71 #[autosurgeon(rename = "createdAt", with = "datetime_millis")]
73 #[ts(type = "number")]
74 pub created_at: chrono::DateTime<chrono::Utc>,
75 pub heads: Vec<String>,
77}
78
79#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
83#[derive(Debug, Clone, Reconcile, Hydrate, TS)]
84#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
85pub struct DocInfo {
86 #[ts(as = "String")]
88 pub name: Text,
89 #[autosurgeon(rename = "typeName")]
91 pub type_name: DocumentType,
92 pub theory: Option<String>,
94 pub permissions: Vec<PermissionInfo>,
96 #[autosurgeon(rename = "createdAt", with = "datetime_millis")]
98 #[ts(type = "number")]
99 pub created_at: chrono::DateTime<chrono::Utc>,
100 #[autosurgeon(rename = "deletedAt", with = "option_datetime_millis")]
102 #[ts(type = "number | null")]
103 pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
104 #[autosurgeon(rename = "currentSnapshotUpdatedAt", with = "datetime_millis")]
106 #[ts(type = "number")]
107 pub current_snapshot_updated_at: chrono::DateTime<chrono::Utc>,
108 #[autosurgeon(rename = "currentSnapshot")]
110 pub current_snapshot: i32,
111 pub snapshots: HashMap<String, SnapshotInfo>,
113 #[autosurgeon(rename = "dependsOn")]
115 pub depends_on: Vec<RelationInfo>,
116 #[autosurgeon(rename = "usedBy")]
121 pub used_by: Vec<RelationInfo>,
122}
123
124#[cfg_attr(feature = "property-tests", derive(PartialEq, Eq))]
126#[derive(Debug, Clone, Reconcile, Hydrate, TS)]
127#[cfg_attr(not(test), ts(export, export_to = "user_state.ts"))]
128pub struct UserState {
129 pub profile: UserInfo,
131 #[autosurgeon(rename = "knownUsers")]
133 #[ts(rename = "knownUsers")]
134 pub known_users: HashMap<String, UserInfo>,
135 pub documents: HashMap<String, DocInfo>,
138}
139
140impl UserState {
141 pub fn reconcile_into(&self, doc: &mut automerge::Automerge) -> Result<(), AppError> {
143 doc.transact(|tx| reconcile(tx, self))
144 .map_err(|e| AppError::UserStateSync(format!("Failed to reconcile: {:?}", e)))?;
145 Ok(())
146 }
147
148 pub fn new() -> Self {
150 Self {
151 profile: UserInfo { username: None, display_name: None },
152 known_users: HashMap::new(),
153 documents: HashMap::new(),
154 }
155 }
156
157 pub fn recompute_used_by(&mut self) {
163 for doc in self.documents.values_mut() {
165 doc.used_by.clear();
166 }
167
168 let pairs: Vec<(String, RelationInfo)> = self
169 .documents
170 .iter()
171 .flat_map(|(key, doc)| {
172 let child_uuid = uuid::Uuid::parse_str(key).ok()?;
173 Some(doc.depends_on.iter().map(move |rel| {
174 (
175 rel.ref_id.to_string(),
176 RelationInfo {
177 ref_id: child_uuid,
178 relation_type: rel.relation_type.clone(),
179 },
180 )
181 }))
182 })
183 .flatten()
184 .collect();
185
186 for (target_key, relation) in pairs {
187 if let Some(target_doc) = self.documents.get_mut(&target_key) {
188 target_doc.used_by.push(relation);
189 }
190 }
191 }
192}
193
194impl Default for UserState {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200pub fn extract_relations_from_json(value: &serde_json::Value) -> Vec<RelationInfo> {
205 let mut relations = Vec::new();
206 collect_relations(value, &mut relations);
207
208 relations.sort_by(|a, b| a.ref_id.cmp(&b.ref_id).then(a.relation_type.cmp(&b.relation_type)));
210 relations.dedup_by(|a, b| a.ref_id == b.ref_id && a.relation_type == b.relation_type);
211
212 relations
213}
214
215fn collect_relations(value: &serde_json::Value, out: &mut Vec<RelationInfo>) {
216 match value {
217 serde_json::Value::Object(map) => {
218 if let (Some(serde_json::Value::String(id)), Some(serde_json::Value::String(ty))) =
219 (map.get("_id"), map.get("type"))
220 && let Ok(ref_id) = uuid::Uuid::parse_str(id)
221 {
222 out.push(RelationInfo { ref_id, relation_type: ty.clone() });
223 }
224 for child in map.values() {
225 collect_relations(child, out);
226 }
227 }
228 serde_json::Value::Array(arr) => {
229 for child in arr {
230 collect_relations(child, out);
231 }
232 }
233 _ => {}
234 }
235}
236
237pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result<UserState, AppError> {
239 debug!(user_id = %user_id, "Reading user state from database");
240
241 let results = sqlx::query!(
246 r#"
247 WITH
248 filtered_ids AS (
249 SELECT refs.id
250 FROM refs
251 WHERE
252 -- filter by minimum permission level (read)
253 get_max_permission($1, refs.id) >= 'read'::permission_level
254 -- exclude public-only documents (user must have explicit permission)
255 AND EXISTS (
256 SELECT 1
257 FROM permissions p_searcher
258 WHERE
259 p_searcher.object = refs.id
260 AND p_searcher.subject = $1
261 )
262 )
263 SELECT
264 refs.id AS "ref_id!",
265 snapshots.content->>'name' AS name,
266 snapshots.content->>'type' AS type_name,
267 snapshots.content->>'theory' AS theory,
268 refs.created AS "created_at!",
269 refs.deleted_at,
270 refs.current_snapshot_updated_at AS "current_snapshot_updated_at!",
271 snapshots.content AS "content!",
272 refs.current_snapshot AS "current_snapshot!",
273 COALESCE(
274 (SELECT json_agg(json_build_object(
275 'user', p.subject,
276 'level', INITCAP(p.level::text)
277 ) ORDER BY p.level DESC)
278 FROM permissions p
279 WHERE p.object = refs.id
280 ), '[]'::json
281 ) AS "permissions!: sqlx::types::Json<serde_json::Value>",
282 COALESCE(
283 (SELECT json_object_agg(
284 s.id::text,
285 json_build_object(
286 'parent', s.parent,
287 'createdAt', s.created_at,
288 'heads', (SELECT array_agg(encode(h, 'hex')) FROM unnest(s.heads) AS h)
289 )
290 )
291 FROM snapshots s
292 WHERE s.for_ref = refs.id
293 ), '{}'::json
294 ) AS "snapshots!: sqlx::types::Json<serde_json::Value>"
295 FROM filtered_ids
296 JOIN refs ON refs.id = filtered_ids.id
297 JOIN snapshots ON snapshots.id = refs.current_snapshot
298 ORDER BY refs.created DESC;
299 "#,
300 user_id,
301 )
302 .fetch_all(db)
303 .await?;
304
305 let mut documents: HashMap<String, DocInfo> = HashMap::new();
306 for row in results {
307 let key = row.ref_id.to_string();
308 let Some(type_str) = row.type_name.as_deref() else {
309 warn!(ref_id = %key, "Skipping document with no type");
310 continue;
311 };
312 let Ok(type_name) = type_str.parse::<DocumentType>() else {
313 warn!(ref_id = %key, type_name = %type_str, "Skipping document with unknown type");
314 continue;
315 };
316 let Ok(permissions) = serde_json::from_value::<Vec<PermissionInfo>>(row.permissions.0)
317 else {
318 error!(ref_id = %key, "Skipping document with invalid permissions JSON");
319 continue;
320 };
321 let Ok(snapshots) =
322 serde_json::from_value::<HashMap<String, SnapshotInfo>>(row.snapshots.0)
323 else {
324 error!(ref_id = %key, "Skipping document with invalid snapshots JSON");
325 continue;
326 };
327 let depends_on = extract_relations_from_json(&row.content);
328
329 let info = DocInfo {
330 name: Text::from(row.name.unwrap_or_else(|| DEFAULT_DOC_NAME.to_string())),
331 type_name,
332 theory: row.theory,
333 permissions,
334 created_at: row.created_at,
335 deleted_at: row.deleted_at,
336 current_snapshot_updated_at: row.current_snapshot_updated_at,
337 current_snapshot: row.current_snapshot,
338 snapshots,
339 depends_on,
340 used_by: Vec::new(),
341 };
342 documents.insert(key, info);
343 }
344
345 let user_ids: Vec<String> = documents
347 .values()
348 .flat_map(|doc| doc.permissions.iter().filter_map(|p| p.user.clone()))
349 .collect();
350
351 let known_users: HashMap<String, UserInfo> = if user_ids.is_empty() {
352 HashMap::new()
353 } else {
354 let user_rows = sqlx::query!(
355 r#"
356 SELECT id, username, display_name FROM users
357 WHERE id = ANY($1)
358 "#,
359 &user_ids,
360 )
361 .fetch_all(db)
362 .await?;
363
364 user_rows
365 .into_iter()
366 .map(|row| {
367 (
368 row.id,
369 UserInfo {
370 username: row.username.map(Text::from),
371 display_name: row.display_name.map(Text::from),
372 },
373 )
374 })
375 .collect()
376 };
377
378 let profile_row = sqlx::query!(
380 r#"
381 SELECT username, display_name FROM users
382 WHERE id = $1
383 "#,
384 user_id,
385 )
386 .fetch_optional(db)
387 .await?;
388
389 let profile = match profile_row {
390 Some(row) => UserInfo {
391 username: row.username.map(Text::from),
392 display_name: row.display_name.map(Text::from),
393 },
394 None => UserInfo { username: None, display_name: None },
395 };
396
397 let mut user_state = UserState { profile, known_users, documents };
398 user_state.recompute_used_by();
399 Ok(user_state)
400}
401
402pub async fn get_user_state_doc(state: &AppState, user_id: &str) -> Option<DocumentId> {
404 let (doc_id_str,): (String,) =
405 sqlx::query_as("SELECT state_doc_id FROM users WHERE id = $1 AND state_doc_id IS NOT NULL")
406 .bind(user_id)
407 .fetch_optional(&state.db)
408 .await
409 .ok()??;
410
411 DocumentId::from_str(&doc_id_str).ok()
412}
413
414pub async fn get_or_create_user_state_doc(
416 state: &AppState,
417 user_id: &str,
418) -> Result<DocumentId, AppError> {
419 debug!(user_id = %user_id, "Getting or creating user state document");
420
421 {
422 let initialized = state.initialized_user_states.read().await;
423 if let Some(doc_id) = initialized.get(user_id) {
424 return Ok(doc_id.clone());
425 }
426 }
427
428 let user_state = read_user_state_from_db(user_id.to_string(), &state.db).await?;
429 let doc_id = initialize_user_state_doc(state, user_id, &user_state).await?;
430
431 let mut initialized = state.initialized_user_states.write().await;
432 initialized.insert(user_id.to_string(), doc_id.clone());
433 Ok(doc_id)
434}
435
436pub fn user_state_to_automerge(state: &UserState) -> Result<automerge::Automerge, AppError> {
438 let mut doc = automerge::Automerge::new();
439 state.reconcile_into(&mut doc)?;
440 Ok(doc)
441}
442
443pub async fn initialize_user_state_doc(
445 state: &AppState,
446 user_id: &str,
447 user_state: &UserState,
448) -> Result<DocumentId, AppError> {
449 let persisted_doc_id: Option<(String,)> =
450 sqlx::query_as("SELECT state_doc_id FROM users WHERE id = $1 AND state_doc_id IS NOT NULL")
451 .bind(user_id)
452 .fetch_optional(&state.db)
453 .await?;
454
455 if let Some((doc_id_str,)) = &persisted_doc_id
457 && let Ok(doc_id) = DocumentId::from_str(doc_id_str)
458 {
459 if let Ok(Some(doc_handle)) = state.repo.find(doc_id.clone()).await {
460 doc_handle.with_document(|doc| user_state.reconcile_into(doc))?;
461 debug!(
462 user_id = %user_id,
463 doc_id = %doc_id,
464 "Reconciled existing user state document from DB"
465 );
466 return Ok(doc_id);
467 }
468 debug!(
469 user_id = %user_id,
470 doc_id = %doc_id_str,
471 "Persisted state_doc_id not found in repo; creating new document"
472 );
473 }
474
475 let doc = user_state_to_automerge(user_state)?;
477 let doc_handle = state.repo.create(doc).await?;
478 let doc_id = doc_handle.document_id();
479
480 sqlx::query("UPDATE users SET state_doc_id = $2 WHERE id = $1")
481 .bind(user_id)
482 .bind(doc_id.to_string())
483 .execute(&state.db)
484 .await?;
485
486 info!(user_id = %user_id, doc_id = %doc_id, "Initialized user state document");
487
488 Ok(doc_id.clone())
489}
490
491#[cfg(feature = "property-tests")]
493pub mod arbitrary {
494 #![allow(dead_code)]
495 use super::*;
496 use crate::auth::PermissionLevel;
497 use autosurgeon::Text;
498 use chrono::{TimeZone, Utc};
499 use proptest::{arbitrary::Arbitrary, prelude::*};
500 use proptest_arbitrary_interop::arb;
501
502 pub fn arb_document_type() -> BoxedStrategy<DocumentType> {
504 proptest::sample::select(&[
505 DocumentType::Model,
506 DocumentType::Diagram,
507 DocumentType::Analysis,
508 ])
509 .boxed()
510 }
511
512 impl Arbitrary for UserInfo {
513 type Parameters = ();
514 type Strategy = BoxedStrategy<Self>;
515
516 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
517 (any::<Option<String>>(), any::<Option<String>>())
518 .prop_map(|(username, display_name)| {
519 let username = username.filter(|s| !s.is_empty());
523 let display_name = display_name.filter(|s| !s.is_empty());
524 UserInfo {
525 username: username.map(Text::from),
526 display_name: display_name.map(Text::from),
527 }
528 })
529 .boxed()
530 }
531 }
532
533 impl Arbitrary for PermissionInfo {
534 type Parameters = ();
535 type Strategy = BoxedStrategy<Self>;
536
537 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
538 (
539 proptest::option::of(arb::<uuid::Uuid>().prop_map(|u| format!("test_{u}"))),
540 any::<PermissionLevel>(),
541 )
542 .prop_map(|(user, level)| PermissionInfo { user, level })
543 .boxed()
544 }
545 }
546
547 impl Arbitrary for DocInfo {
548 type Parameters = ();
549 type Strategy = BoxedStrategy<Self>;
550
551 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
552 (
553 any::<String>(),
554 arb_document_type(),
555 proptest::option::of(any::<String>()),
556 prop::collection::vec(any::<PermissionInfo>(), 0..5),
557 0i64..253402300799i64,
558 proptest::option::of(0i64..253402300799i64),
559 0i64..253402300799i64,
560 )
561 .prop_map(
562 |(
563 name,
564 type_name,
565 theory,
566 permissions,
567 seconds,
568 deleted_seconds,
569 updated_seconds,
570 )| {
571 DocInfo {
572 name: Text::from(name),
573 type_name,
574 theory,
575 permissions,
576 created_at: Utc
577 .timestamp_opt(seconds, 0)
578 .single()
579 .expect("valid timestamp"),
580 deleted_at: deleted_seconds.map(|s| {
581 Utc.timestamp_opt(s, 0).single().expect("valid timestamp")
582 }),
583 current_snapshot_updated_at: Utc
584 .timestamp_opt(updated_seconds, 0)
585 .single()
586 .expect("valid timestamp"),
587 current_snapshot: 1,
588 snapshots: HashMap::new(),
589 depends_on: Vec::new(),
590 used_by: Vec::new(),
591 }
592 },
593 )
594 .boxed()
595 }
596 }
597
598 fn doc_info_entry_with_permissions(
605 user_id: String,
606 user_profile: UserInfo,
607 ) -> impl Strategy<Value = (String, DocInfo, HashMap<String, UserInfo>)> {
608 (
609 any::<String>(), arb_document_type(), arb::<uuid::Uuid>(), any::<PermissionLevel>(), arb::<uuid::Uuid>(), any::<Option<String>>(), 0i64..253402300799i64, proptest::option::of(0i64..253402300799i64), 0i64..253402300799i64, )
619 .prop_map(
620 move |(
621 name,
622 type_name,
623 ref_id,
624 user_level,
625 other_owner_uuid,
626 other_owner_display_name,
627 seconds,
628 deleted_seconds,
629 updated_seconds,
630 )| {
631 let mut permissions = Vec::new();
632 let mut users = HashMap::new();
633
634 if user_level == PermissionLevel::Own {
635 permissions.push(PermissionInfo {
637 user: Some(user_id.clone()),
638 level: PermissionLevel::Own,
639 });
640 users.insert(user_id.clone(), user_profile.clone());
641 } else {
642 let mut other_id = format!("test_{other_owner_uuid}");
644 if other_id == user_id {
645 other_id = format!("{}_other", other_id);
646 }
647 let other_owner_info = UserInfo {
648 username: Some(Text::from(format!("owner_{other_owner_uuid}"))),
649 display_name: other_owner_display_name
650 .filter(|s| !s.is_empty())
651 .map(Text::from),
652 };
653 users.insert(other_id.clone(), other_owner_info);
654 permissions.push(PermissionInfo {
655 user: Some(other_id),
656 level: PermissionLevel::Own,
657 });
658 users.insert(user_id.clone(), user_profile.clone());
660 permissions.push(PermissionInfo {
661 user: Some(user_id.clone()),
662 level: user_level,
663 });
664 }
665
666 let key = ref_id.to_string();
667 let info = DocInfo {
668 name: Text::from(name),
669 type_name,
670 theory: None,
671 permissions,
672 created_at: Utc
673 .timestamp_opt(seconds, 0)
674 .single()
675 .expect("valid timestamp"),
676 deleted_at: deleted_seconds
677 .map(|s| Utc.timestamp_opt(s, 0).single().expect("valid timestamp")),
678 current_snapshot_updated_at: Utc
679 .timestamp_opt(updated_seconds, 0)
680 .single()
681 .expect("valid timestamp"),
682 current_snapshot: 1,
683 snapshots: HashMap::new(),
684 depends_on: Vec::new(),
687 used_by: Vec::new(),
688 };
689 (key, info, users)
690 },
691 )
692 }
693
694 pub fn arbitrary_user_state_with_id() -> impl Strategy<Value = (String, UserState)> {
698 (arb::<uuid::Uuid>(), any::<Option<String>>(), any::<Option<String>>()).prop_flat_map(
699 |(user_uuid, username, display_name)| {
700 let user_id = format!("test_user_{}", user_uuid);
701 let username = username.filter(|s| !s.is_empty());
702 let display_name = display_name.filter(|s| !s.is_empty());
703 let profile = UserInfo {
704 username: username.map(Text::from),
705 display_name: display_name.map(Text::from),
706 };
707 prop::collection::vec(
708 doc_info_entry_with_permissions(user_id.clone(), profile.clone()),
709 0..5,
710 )
711 .prop_map(move |entries| {
712 let mut known_users = HashMap::new();
713 let mut documents = HashMap::new();
714 for (key, doc_info, entry_users) in entries {
715 known_users.extend(entry_users);
716 documents.insert(key, doc_info);
717 }
718 (
719 user_id.clone(),
720 UserState {
721 profile: profile.clone(),
722 known_users,
723 documents,
724 },
725 )
726 })
727 },
728 )
729 }
730
731 impl Arbitrary for UserState {
732 type Parameters = ();
733 type Strategy = BoxedStrategy<Self>;
734
735 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
736 arbitrary_user_state_with_id().prop_map(|(_, state)| state).boxed()
737 }
738 }
739}
740
741#[cfg(test)]
742mod unit_tests {
743 use super::*;
744 use serde_json::json;
745
746 #[test]
747 fn extract_relations_from_nested_json() {
748 let content = json!({
749 "name": "My Model",
750 "type": "model",
751 "theory": "th_signed_category",
752 "content": {
753 "cells": [
754 {
755 "tag": "ob",
756 "content": {
757 "obType": {
758 "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4233",
759 "type": "model"
760 }
761 }
762 },
763 {
764 "tag": "morphism",
765 "content": {
766 "morType": {
767 "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4234",
768 "type": "diagram"
769 }
770 }
771 }
772 ]
773 }
774 });
775
776 let mut relations = extract_relations_from_json(&content);
777 relations.sort_by(|a, b| a.ref_id.cmp(&b.ref_id));
778
779 assert_eq!(relations.len(), 2);
780 assert_eq!(
781 relations[0].ref_id,
782 uuid::Uuid::parse_str("019532d2-d6a3-7c7e-93c6-6c43419e4233").unwrap()
783 );
784 assert_eq!(relations[0].relation_type, "model");
785 assert_eq!(
786 relations[1].ref_id,
787 uuid::Uuid::parse_str("019532d2-d6a3-7c7e-93c6-6c43419e4234").unwrap()
788 );
789 assert_eq!(relations[1].relation_type, "diagram");
790 }
791
792 #[test]
793 fn extract_relations_deduplicates() {
794 let content = json!({
795 "a": { "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4233", "type": "model" },
796 "b": { "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4233", "type": "model" }
797 });
798
799 let relations = extract_relations_from_json(&content);
800 assert_eq!(relations.len(), 1);
801 }
802
803 #[test]
804 fn extract_relations_empty_document() {
805 let content = json!({ "name": "Empty", "type": "model" });
806 let relations = extract_relations_from_json(&content);
808 assert!(relations.is_empty());
809 }
810}
811
812#[cfg(all(test, feature = "property-tests"))]
813mod tests {
814 use super::*;
815 use autosurgeon::hydrate;
816 use test_strategy::proptest;
817
818 use crate::app::AppError;
819
820 fn automerge_to_user_state(doc: &automerge::Automerge) -> Result<UserState, AppError> {
822 let state: UserState = hydrate(doc)
823 .map_err(|e| AppError::Invalid(format!("Failed to hydrate UserState: {}", e)))?;
824 Ok(state)
825 }
826
827 #[proptest(cases = 16)]
829 fn user_state_automerge_roundtrip(input_state: UserState) {
830 let doc = user_state_to_automerge(&input_state).expect("Failed to convert to Automerge");
831 let output_state = automerge_to_user_state(&doc).expect("Failed to convert from Automerge");
832
833 proptest::prop_assert_eq!(input_state, output_state);
834 }
835}