data_modelling_sdk/
lib.rs

1//! Data Modelling SDK - Shared library for model operations across platforms
2//!
3//! Provides unified interfaces for:
4//! - File/folder operations (via storage backends)
5//! - Model loading/saving
6//! - Import/export functionality
7//! - Validation logic
8//! - Authentication types (shared across web, desktop, mobile)
9//! - Workspace management types
10
11pub mod auth;
12#[cfg(feature = "cli")]
13pub mod cli;
14pub mod convert;
15pub mod export;
16#[cfg(feature = "git")]
17pub mod git;
18pub mod import;
19pub mod model;
20pub mod models;
21pub mod storage;
22pub mod validation;
23pub mod workspace;
24
25// Re-export commonly used types
26#[cfg(feature = "api-backend")]
27pub use storage::api::ApiStorageBackend;
28#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
29pub use storage::browser::BrowserStorageBackend;
30#[cfg(feature = "native-fs")]
31pub use storage::filesystem::FileSystemStorageBackend;
32pub use storage::{StorageBackend, StorageError};
33
34pub use convert::{ConversionError, convert_to_odcs};
35#[cfg(feature = "png-export")]
36pub use export::PNGExporter;
37pub use export::{
38    AvroExporter, ExportError, ExportResult, JSONSchemaExporter, ODCSExporter, ProtobufExporter,
39    SQLExporter,
40};
41pub use import::{
42    AvroImporter, ImportError, ImportResult, JSONSchemaImporter, ODCSImporter, ProtobufImporter,
43    SQLImporter,
44};
45#[cfg(feature = "api-backend")]
46pub use model::ApiModelLoader;
47pub use model::{ModelLoader, ModelSaver};
48pub use validation::{
49    RelationshipValidationError, RelationshipValidationResult, TableValidationError,
50    TableValidationResult,
51};
52
53// Re-export models
54pub use models::enums::*;
55pub use models::{Column, ContactDetails, DataModel, ForeignKey, Relationship, SlaProperty, Table};
56
57// Re-export auth types
58pub use auth::{
59    AuthMode, AuthState, GitHubEmail, InitiateOAuthRequest, InitiateOAuthResponse,
60    SelectEmailRequest,
61};
62
63// Re-export workspace types
64pub use workspace::{
65    CreateWorkspaceRequest, CreateWorkspaceResponse, ListProfilesResponse, LoadProfileRequest,
66    ProfileInfo, WorkspaceInfo,
67};
68
69// Re-export Git types
70#[cfg(feature = "git")]
71pub use git::{GitError, GitService, GitStatus};
72
73// WASM bindings for import/export functions
74#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
75mod wasm {
76    use crate::export::ExportError;
77    use crate::import::{ImportError, ImportResult};
78    use crate::models::DataModel;
79    use js_sys;
80    use serde::{Deserialize, Serialize};
81    use serde_json;
82    use serde_yaml;
83    use uuid;
84    use wasm_bindgen::prelude::*;
85    use wasm_bindgen_futures;
86
87    /// Structured error type for WASM bindings.
88    /// Provides detailed error information that can be parsed by JavaScript consumers.
89    #[derive(Debug, Clone, Serialize, Deserialize)]
90    pub struct WasmError {
91        /// Error category (e.g., "ImportError", "ExportError", "ValidationError")
92        pub error_type: String,
93        /// Human-readable error message
94        pub message: String,
95        /// Optional error code for programmatic handling
96        #[serde(skip_serializing_if = "Option::is_none")]
97        pub code: Option<String>,
98        /// Optional additional details
99        #[serde(skip_serializing_if = "Option::is_none")]
100        pub details: Option<serde_json::Value>,
101    }
102
103    impl WasmError {
104        /// Create a new WasmError with the given type and message
105        fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
106            Self {
107                error_type: error_type.into(),
108                message: message.into(),
109                code: None,
110                details: None,
111            }
112        }
113
114        /// Create a WasmError with a specific error code
115        fn with_code(mut self, code: impl Into<String>) -> Self {
116            self.code = Some(code.into());
117            self
118        }
119
120        /// Convert to JsValue for returning to JavaScript
121        fn to_js_value(&self) -> JsValue {
122            // Serialize to JSON string for structured error handling in JS
123            match serde_json::to_string(self) {
124                Ok(json) => JsValue::from_str(&json),
125                // Fallback to simple message if serialization fails
126                Err(_) => JsValue::from_str(&self.message),
127            }
128        }
129    }
130
131    /// Convert ImportError to structured JsValue for JavaScript error handling
132    fn import_error_to_js(err: ImportError) -> JsValue {
133        WasmError::new("ImportError", err.to_string())
134            .with_code("IMPORT_FAILED")
135            .to_js_value()
136    }
137
138    /// Convert ExportError to structured JsValue for JavaScript error handling
139    fn export_error_to_js(err: ExportError) -> JsValue {
140        WasmError::new("ExportError", err.to_string())
141            .with_code("EXPORT_FAILED")
142            .to_js_value()
143    }
144
145    /// Create a serialization error
146    fn serialization_error(err: impl std::fmt::Display) -> JsValue {
147        WasmError::new(
148            "SerializationError",
149            format!("Serialization error: {}", err),
150        )
151        .with_code("SERIALIZATION_FAILED")
152        .to_js_value()
153    }
154
155    /// Create a deserialization error
156    fn deserialization_error(err: impl std::fmt::Display) -> JsValue {
157        WasmError::new(
158            "DeserializationError",
159            format!("Deserialization error: {}", err),
160        )
161        .with_code("DESERIALIZATION_FAILED")
162        .to_js_value()
163    }
164
165    /// Create a parse error
166    fn parse_error(err: impl std::fmt::Display) -> JsValue {
167        WasmError::new("ParseError", format!("Parse error: {}", err))
168            .with_code("PARSE_FAILED")
169            .to_js_value()
170    }
171
172    /// Create a validation error
173    fn validation_error(err: impl std::fmt::Display) -> JsValue {
174        WasmError::new("ValidationError", err.to_string())
175            .with_code("VALIDATION_FAILED")
176            .to_js_value()
177    }
178
179    /// Create an invalid input error
180    fn invalid_input_error(field: &str, err: impl std::fmt::Display) -> JsValue {
181        WasmError::new("InvalidInputError", format!("Invalid {}: {}", field, err))
182            .with_code("INVALID_INPUT")
183            .to_js_value()
184    }
185
186    /// Create a conversion error
187    fn conversion_error(err: impl std::fmt::Display) -> JsValue {
188        WasmError::new("ConversionError", format!("Conversion error: {}", err))
189            .with_code("CONVERSION_FAILED")
190            .to_js_value()
191    }
192
193    /// Create a storage error
194    fn storage_error(err: impl std::fmt::Display) -> JsValue {
195        WasmError::new("StorageError", format!("Storage error: {}", err))
196            .with_code("STORAGE_FAILED")
197            .to_js_value()
198    }
199
200    /// Serialize ImportResult to JSON string
201    fn serialize_import_result(result: &ImportResult) -> Result<String, JsValue> {
202        serde_json::to_string(result).map_err(serialization_error)
203    }
204
205    /// Flatten STRUCT columns in ImportResult into nested columns with dot notation
206    ///
207    /// This processes each table's columns and expands STRUCT types into individual
208    /// columns with parent.child naming:
209    /// - STRUCT<field1: TYPE1, field2: TYPE2> → parent.field1, parent.field2
210    /// - ARRAY<STRUCT<...>> → parent.[].field1, parent.[].field2
211    /// - MAP types are kept as-is (keys are dynamic)
212    fn flatten_struct_columns(result: ImportResult) -> ImportResult {
213        use crate::import::{ColumnData, ODCSImporter, TableData};
214
215        let importer = ODCSImporter::new();
216
217        let tables = result
218            .tables
219            .into_iter()
220            .map(|table_data| {
221                let mut all_columns = Vec::new();
222
223                for col_data in table_data.columns {
224                    let data_type_upper = col_data.data_type.to_uppercase();
225                    let is_map = data_type_upper.starts_with("MAP<");
226
227                    // Skip parsing for MAP types - keys are dynamic
228                    if is_map {
229                        all_columns.push(col_data);
230                        continue;
231                    }
232
233                    // For STRUCT or ARRAY<STRUCT> types, try to parse and create nested columns
234                    let is_struct = data_type_upper.contains("STRUCT<");
235                    if is_struct {
236                        let field_data = serde_json::Map::new();
237                        if let Ok(nested_cols) = importer.parse_struct_type_from_string(
238                            &col_data.name,
239                            &col_data.data_type,
240                            &field_data,
241                        ) {
242                            if !nested_cols.is_empty() {
243                                // Add parent column with simplified type
244                                let parent_data_type =
245                                    if col_data.data_type.to_uppercase().starts_with("ARRAY<") {
246                                        "ARRAY<STRUCT<...>>".to_string()
247                                    } else {
248                                        "STRUCT<...>".to_string()
249                                    };
250
251                                all_columns.push(ColumnData {
252                                    name: col_data.name.clone(),
253                                    data_type: parent_data_type,
254                                    physical_type: col_data.physical_type.clone(),
255                                    nullable: col_data.nullable,
256                                    primary_key: col_data.primary_key,
257                                    description: col_data.description.clone(),
258                                    quality: col_data.quality.clone(),
259                                    relationships: col_data.relationships.clone(),
260                                    enum_values: col_data.enum_values.clone(),
261                                    ..Default::default()
262                                });
263
264                                // Add nested columns converted from Column to ColumnData
265                                for nested_col in nested_cols {
266                                    all_columns.push(
267                                        crate::import::odcs_shared::column_to_column_data(
268                                            &nested_col,
269                                        ),
270                                    );
271                                }
272                                continue;
273                            }
274                        }
275                    }
276
277                    // Regular column or STRUCT parsing failed - add as-is
278                    all_columns.push(col_data);
279                }
280
281                TableData {
282                    table_index: table_data.table_index,
283                    name: table_data.name,
284                    columns: all_columns,
285                }
286            })
287            .collect();
288
289        ImportResult {
290            tables,
291            tables_requiring_name: result.tables_requiring_name,
292            errors: result.errors,
293            ai_suggestions: result.ai_suggestions,
294        }
295    }
296
297    /// Deserialize workspace structure from JSON string
298    fn deserialize_workspace(json: &str) -> Result<DataModel, JsValue> {
299        serde_json::from_str(json).map_err(deserialization_error)
300    }
301
302    /// Parse ODCS YAML content and return a structured workspace representation.
303    ///
304    /// # Arguments
305    ///
306    /// * `yaml_content` - ODCS YAML content as a string
307    ///
308    /// # Returns
309    ///
310    /// JSON string containing ImportResult object, or JsValue error
311    #[wasm_bindgen]
312    pub fn parse_odcs_yaml(yaml_content: &str) -> Result<String, JsValue> {
313        let mut importer = crate::import::ODCSImporter::new();
314        match importer.import(yaml_content) {
315            Ok(result) => {
316                let flattened = flatten_struct_columns(result);
317                serialize_import_result(&flattened)
318            }
319            Err(err) => Err(import_error_to_js(err)),
320        }
321    }
322
323    /// Import data model from legacy ODCL (Open Data Contract Language) YAML format.
324    ///
325    /// This function parses legacy ODCL formats including:
326    /// - Data Contract Specification format (dataContractSpecification, models, definitions)
327    /// - Simple ODCL format (name, columns)
328    ///
329    /// For ODCS v3.1.0/v3.0.x format, use `parse_odcs_yaml` instead.
330    ///
331    /// # Arguments
332    ///
333    /// * `yaml_content` - ODCL YAML content as a string
334    ///
335    /// # Returns
336    ///
337    /// JSON string containing ImportResult object, or JsValue error
338    #[wasm_bindgen]
339    pub fn parse_odcl_yaml(yaml_content: &str) -> Result<String, JsValue> {
340        let mut importer = crate::import::ODCLImporter::new();
341        match importer.import(yaml_content) {
342            Ok(result) => {
343                let flattened = flatten_struct_columns(result);
344                serialize_import_result(&flattened)
345            }
346            Err(err) => Err(import_error_to_js(err)),
347        }
348    }
349
350    /// Check if the given YAML content is in legacy ODCL format.
351    ///
352    /// Returns true if the content is in ODCL format (Data Contract Specification
353    /// or simple ODCL format), false if it's in ODCS v3.x format or invalid.
354    ///
355    /// # Arguments
356    ///
357    /// * `yaml_content` - YAML content to check
358    ///
359    /// # Returns
360    ///
361    /// Boolean indicating if the content is ODCL format
362    #[wasm_bindgen]
363    pub fn is_odcl_format(yaml_content: &str) -> bool {
364        let importer = crate::import::ODCLImporter::new();
365        importer.can_handle(yaml_content)
366    }
367
368    /// Export a workspace structure to ODCS YAML format.
369    ///
370    /// # Arguments
371    ///
372    /// * `workspace_json` - JSON string containing workspace/data model structure
373    ///
374    /// # Returns
375    ///
376    /// ODCS YAML format string, or JsValue error
377    #[wasm_bindgen]
378    pub fn export_to_odcs_yaml(workspace_json: &str) -> Result<String, JsValue> {
379        let model = deserialize_workspace(workspace_json)?;
380
381        // Export all tables as separate YAML documents, joined with ---\n
382        let exports = crate::export::ODCSExporter::export_model(&model, None, "odcs_v3_1_0");
383
384        // Combine all YAML documents into a single multi-document string
385        let yaml_docs: Vec<String> = exports.values().cloned().collect();
386        Ok(yaml_docs.join("\n---\n"))
387    }
388
389    /// Import data model from SQL CREATE TABLE statements.
390    ///
391    /// # Arguments
392    ///
393    /// * `sql_content` - SQL CREATE TABLE statements
394    /// * `dialect` - SQL dialect ("postgresql", "mysql", "sqlserver", "databricks")
395    ///
396    /// # Returns
397    ///
398    /// JSON string containing ImportResult object, or JsValue error
399    #[wasm_bindgen]
400    pub fn import_from_sql(sql_content: &str, dialect: &str) -> Result<String, JsValue> {
401        let importer = crate::import::SQLImporter::new(dialect);
402        match importer.parse(sql_content) {
403            Ok(result) => {
404                // Flatten STRUCT columns into nested columns with dot notation
405                let flattened = flatten_struct_columns(result);
406                serialize_import_result(&flattened)
407            }
408            Err(err) => Err(parse_error(err)),
409        }
410    }
411
412    /// Import data model from AVRO schema.
413    ///
414    /// # Arguments
415    ///
416    /// * `avro_content` - AVRO schema JSON as a string
417    ///
418    /// # Returns
419    ///
420    /// JSON string containing ImportResult object, or JsValue error
421    #[wasm_bindgen]
422    pub fn import_from_avro(avro_content: &str) -> Result<String, JsValue> {
423        let importer = crate::import::AvroImporter::new();
424        match importer.import(avro_content) {
425            Ok(result) => {
426                let flattened = flatten_struct_columns(result);
427                serialize_import_result(&flattened)
428            }
429            Err(err) => Err(import_error_to_js(err)),
430        }
431    }
432
433    /// Import data model from JSON Schema definition.
434    ///
435    /// # Arguments
436    ///
437    /// * `json_schema_content` - JSON Schema definition as a string
438    ///
439    /// # Returns
440    ///
441    /// JSON string containing ImportResult object, or JsValue error
442    #[wasm_bindgen]
443    pub fn import_from_json_schema(json_schema_content: &str) -> Result<String, JsValue> {
444        let importer = crate::import::JSONSchemaImporter::new();
445        match importer.import(json_schema_content) {
446            Ok(result) => {
447                let flattened = flatten_struct_columns(result);
448                serialize_import_result(&flattened)
449            }
450            Err(err) => Err(import_error_to_js(err)),
451        }
452    }
453
454    /// Import data model from Protobuf schema.
455    ///
456    /// # Arguments
457    ///
458    /// * `protobuf_content` - Protobuf schema text
459    ///
460    /// # Returns
461    ///
462    /// JSON string containing ImportResult object, or JsValue error
463    #[wasm_bindgen]
464    pub fn import_from_protobuf(protobuf_content: &str) -> Result<String, JsValue> {
465        let importer = crate::import::ProtobufImporter::new();
466        match importer.import(protobuf_content) {
467            Ok(result) => {
468                let flattened = flatten_struct_columns(result);
469                serialize_import_result(&flattened)
470            }
471            Err(err) => Err(import_error_to_js(err)),
472        }
473    }
474
475    /// Export a data model to SQL CREATE TABLE statements.
476    ///
477    /// # Arguments
478    ///
479    /// * `workspace_json` - JSON string containing workspace/data model structure
480    /// * `dialect` - SQL dialect ("postgresql", "mysql", "sqlserver", "databricks")
481    ///
482    /// # Returns
483    ///
484    /// SQL CREATE TABLE statements, or JsValue error
485    #[wasm_bindgen]
486    pub fn export_to_sql(workspace_json: &str, dialect: &str) -> Result<String, JsValue> {
487        let model = deserialize_workspace(workspace_json)?;
488        let exporter = crate::export::SQLExporter;
489        match exporter.export(&model.tables, Some(dialect)) {
490            Ok(result) => Ok(result.content),
491            Err(err) => Err(export_error_to_js(err)),
492        }
493    }
494
495    /// Export a data model to AVRO schema.
496    ///
497    /// # Arguments
498    ///
499    /// * `workspace_json` - JSON string containing workspace/data model structure
500    ///
501    /// # Returns
502    ///
503    /// AVRO schema JSON string, or JsValue error
504    #[wasm_bindgen]
505    pub fn export_to_avro(workspace_json: &str) -> Result<String, JsValue> {
506        let model = deserialize_workspace(workspace_json)?;
507        let exporter = crate::export::AvroExporter;
508        match exporter.export(&model.tables) {
509            Ok(result) => Ok(result.content),
510            Err(err) => Err(export_error_to_js(err)),
511        }
512    }
513
514    /// Export a data model to JSON Schema definition.
515    ///
516    /// # Arguments
517    ///
518    /// * `workspace_json` - JSON string containing workspace/data model structure
519    ///
520    /// # Returns
521    ///
522    /// JSON Schema definition string, or JsValue error
523    #[wasm_bindgen]
524    pub fn export_to_json_schema(workspace_json: &str) -> Result<String, JsValue> {
525        let model = deserialize_workspace(workspace_json)?;
526        let exporter = crate::export::JSONSchemaExporter;
527        match exporter.export(&model.tables) {
528            Ok(result) => Ok(result.content),
529            Err(err) => Err(export_error_to_js(err)),
530        }
531    }
532
533    /// Export a data model to Protobuf schema.
534    ///
535    /// # Arguments
536    ///
537    /// * `workspace_json` - JSON string containing workspace/data model structure
538    ///
539    /// # Returns
540    ///
541    /// Protobuf schema text, or JsValue error
542    #[wasm_bindgen]
543    pub fn export_to_protobuf(workspace_json: &str) -> Result<String, JsValue> {
544        let model = deserialize_workspace(workspace_json)?;
545        let exporter = crate::export::ProtobufExporter;
546        match exporter.export(&model.tables) {
547            Ok(result) => Ok(result.content),
548            Err(err) => Err(export_error_to_js(err)),
549        }
550    }
551
552    /// Import CADS YAML content and return a structured representation.
553    ///
554    /// # Arguments
555    ///
556    /// * `yaml_content` - CADS YAML content as a string
557    ///
558    /// # Returns
559    ///
560    /// JSON string containing CADS asset, or JsValue error
561    #[wasm_bindgen]
562    pub fn import_from_cads(yaml_content: &str) -> Result<String, JsValue> {
563        let importer = crate::import::CADSImporter::new();
564        match importer.import(yaml_content) {
565            Ok(asset) => serde_json::to_string(&asset).map_err(serialization_error),
566            Err(err) => Err(import_error_to_js(err)),
567        }
568    }
569
570    /// Export a CADS asset to YAML format.
571    ///
572    /// # Arguments
573    ///
574    /// * `asset_json` - JSON string containing CADS asset
575    ///
576    /// # Returns
577    ///
578    /// CADS YAML format string, or JsValue error
579    #[wasm_bindgen]
580    pub fn export_to_cads(asset_json: &str) -> Result<String, JsValue> {
581        let asset: crate::models::cads::CADSAsset =
582            serde_json::from_str(asset_json).map_err(deserialization_error)?;
583        let exporter = crate::export::CADSExporter;
584        match exporter.export(&asset) {
585            Ok(yaml) => Ok(yaml),
586            Err(err) => Err(export_error_to_js(err)),
587        }
588    }
589
590    /// Import ODPS YAML content and return a structured representation.
591    ///
592    /// # Arguments
593    ///
594    /// * `yaml_content` - ODPS YAML content as a string
595    ///
596    /// # Returns
597    ///
598    /// JSON string containing ODPS data product, or JsValue error
599    #[wasm_bindgen]
600    pub fn import_from_odps(yaml_content: &str) -> Result<String, JsValue> {
601        let importer = crate::import::ODPSImporter::new();
602        match importer.import(yaml_content) {
603            Ok(product) => serde_json::to_string(&product).map_err(serialization_error),
604            Err(err) => Err(import_error_to_js(err)),
605        }
606    }
607
608    /// Export an ODPS data product to YAML format.
609    ///
610    /// # Arguments
611    ///
612    /// * `product_json` - JSON string containing ODPS data product
613    ///
614    /// # Returns
615    ///
616    /// ODPS YAML format string, or JsValue error
617    #[wasm_bindgen]
618    pub fn export_to_odps(product_json: &str) -> Result<String, JsValue> {
619        let product: crate::models::odps::ODPSDataProduct =
620            serde_json::from_str(product_json).map_err(deserialization_error)?;
621        let exporter = crate::export::ODPSExporter;
622        match exporter.export(&product) {
623            Ok(yaml) => Ok(yaml),
624            Err(err) => Err(export_error_to_js(err)),
625        }
626    }
627
628    /// Validate ODPS YAML content against the ODPS JSON Schema.
629    ///
630    /// # Arguments
631    ///
632    /// * `yaml_content` - ODPS YAML content as a string
633    ///
634    /// # Returns
635    ///
636    /// Empty string on success, or error message string
637    #[cfg(feature = "odps-validation")]
638    #[wasm_bindgen]
639    pub fn validate_odps(yaml_content: &str) -> Result<(), JsValue> {
640        #[cfg(feature = "cli")]
641        {
642            use crate::cli::validation::validate_odps_internal;
643            validate_odps_internal(yaml_content).map_err(validation_error)
644        }
645        #[cfg(not(feature = "cli"))]
646        {
647            // Inline validation when CLI feature is not enabled
648            use jsonschema::Validator;
649            use serde_json::Value;
650
651            let schema_content = include_str!("../schemas/odps-json-schema-latest.json");
652            let schema: Value = serde_json::from_str(schema_content)
653                .map_err(|e| validation_error(format!("Failed to load ODPS schema: {}", e)))?;
654
655            let validator = Validator::new(&schema)
656                .map_err(|e| validation_error(format!("Failed to compile ODPS schema: {}", e)))?;
657
658            let data: Value = serde_yaml::from_str(yaml_content).map_err(parse_error)?;
659
660            if let Err(error) = validator.validate(&data) {
661                return Err(validation_error(format!(
662                    "ODPS validation failed: {}",
663                    error
664                )));
665            }
666
667            Ok(())
668        }
669    }
670
671    #[cfg(not(feature = "odps-validation"))]
672    #[wasm_bindgen]
673    pub fn validate_odps(_yaml_content: &str) -> Result<(), JsValue> {
674        // Validation disabled - feature not enabled
675        // Return success to maintain backward compatibility
676        Ok(())
677    }
678
679    /// Create a new business domain.
680    ///
681    /// # Arguments
682    ///
683    /// * `name` - Domain name
684    ///
685    /// # Returns
686    ///
687    /// JSON string containing Domain, or JsValue error
688    #[wasm_bindgen]
689    pub fn create_domain(name: &str) -> Result<String, JsValue> {
690        let domain = crate::models::domain::Domain::new(name.to_string());
691        serde_json::to_string(&domain).map_err(serialization_error)
692    }
693
694    /// Import Domain YAML content and return a structured representation.
695    ///
696    /// # Arguments
697    ///
698    /// * `yaml_content` - Domain YAML content as a string
699    ///
700    /// # Returns
701    ///
702    /// JSON string containing Domain, or JsValue error
703    #[wasm_bindgen]
704    pub fn import_from_domain(yaml_content: &str) -> Result<String, JsValue> {
705        match crate::models::domain::Domain::from_yaml(yaml_content) {
706            Ok(domain) => serde_json::to_string(&domain).map_err(serialization_error),
707            Err(e) => Err(parse_error(e)),
708        }
709    }
710
711    /// Export a Domain to YAML format.
712    ///
713    /// # Arguments
714    ///
715    /// * `domain_json` - JSON string containing Domain
716    ///
717    /// # Returns
718    ///
719    /// Domain YAML format string, or JsValue error
720    #[wasm_bindgen]
721    pub fn export_to_domain(domain_json: &str) -> Result<String, JsValue> {
722        let domain: crate::models::domain::Domain =
723            serde_json::from_str(domain_json).map_err(deserialization_error)?;
724        domain.to_yaml().map_err(serialization_error)
725    }
726
727    /// Migrate DataFlow YAML to Domain schema format.
728    ///
729    /// # Arguments
730    ///
731    /// * `dataflow_yaml` - DataFlow YAML content as a string
732    /// * `domain_name` - Optional domain name (defaults to "MigratedDomain")
733    ///
734    /// # Returns
735    ///
736    /// JSON string containing Domain, or JsValue error
737    #[wasm_bindgen]
738    pub fn migrate_dataflow_to_domain(
739        dataflow_yaml: &str,
740        domain_name: Option<String>,
741    ) -> Result<String, JsValue> {
742        match crate::convert::migrate_dataflow::migrate_dataflow_to_domain(
743            dataflow_yaml,
744            domain_name.as_deref(),
745        ) {
746            Ok(domain) => serde_json::to_string(&domain).map_err(serialization_error),
747            Err(e) => Err(conversion_error(e)),
748        }
749    }
750
751    /// Parse a tag string into a Tag enum.
752    ///
753    /// # Arguments
754    ///
755    /// * `tag_str` - Tag string (Simple, Pair, or List format)
756    ///
757    /// # Returns
758    ///
759    /// JSON string containing Tag, or JsValue error
760    #[wasm_bindgen]
761    pub fn parse_tag(tag_str: &str) -> Result<String, JsValue> {
762        use crate::models::Tag;
763        use std::str::FromStr;
764        match Tag::from_str(tag_str) {
765            Ok(tag) => serde_json::to_string(&tag).map_err(serialization_error),
766            Err(_) => Err(parse_error("Invalid tag format")),
767        }
768    }
769
770    /// Serialize a Tag enum to string format.
771    ///
772    /// # Arguments
773    ///
774    /// * `tag_json` - JSON string containing Tag
775    ///
776    /// # Returns
777    ///
778    /// Tag string (Simple, Pair, or List format), or JsValue error
779    #[wasm_bindgen]
780    pub fn serialize_tag(tag_json: &str) -> Result<String, JsValue> {
781        use crate::models::Tag;
782        let tag: Tag = serde_json::from_str(tag_json).map_err(deserialization_error)?;
783        Ok(tag.to_string())
784    }
785
786    /// Convert any format to ODCS v3.1.0 YAML format.
787    ///
788    /// # Arguments
789    ///
790    /// * `input` - Format-specific content as a string
791    /// * `format` - Optional format identifier. If None, attempts auto-detection.
792    ///   Supported formats: "sql", "json_schema", "avro", "protobuf", "odcl", "odcs", "cads", "odps", "domain"
793    ///
794    /// # Returns
795    ///
796    /// ODCS v3.1.0 YAML string, or JsValue error
797    #[wasm_bindgen]
798    pub fn convert_to_odcs(input: &str, format: Option<String>) -> Result<String, JsValue> {
799        match crate::convert::convert_to_odcs(input, format.as_deref()) {
800            Ok(yaml) => Ok(yaml),
801            Err(e) => Err(conversion_error(e)),
802        }
803    }
804
805    /// Filter Data Flow nodes (tables) by owner.
806    ///
807    /// # Arguments
808    ///
809    /// * `workspace_json` - JSON string containing workspace/data model structure
810    /// * `owner` - Owner name to filter by (case-sensitive exact match)
811    ///
812    /// # Returns
813    ///
814    /// JSON string containing array of matching tables, or JsValue error
815    #[wasm_bindgen]
816    pub fn filter_nodes_by_owner(workspace_json: &str, owner: &str) -> Result<String, JsValue> {
817        let model = deserialize_workspace(workspace_json)?;
818        let filtered = model.filter_nodes_by_owner(owner);
819        serde_json::to_string(&filtered).map_err(serialization_error)
820    }
821
822    /// Filter Data Flow relationships by owner.
823    ///
824    /// # Arguments
825    ///
826    /// * `workspace_json` - JSON string containing workspace/data model structure
827    /// * `owner` - Owner name to filter by (case-sensitive exact match)
828    ///
829    /// # Returns
830    ///
831    /// JSON string containing array of matching relationships, or JsValue error
832    #[wasm_bindgen]
833    pub fn filter_relationships_by_owner(
834        workspace_json: &str,
835        owner: &str,
836    ) -> Result<String, JsValue> {
837        let model = deserialize_workspace(workspace_json)?;
838        let filtered = model.filter_relationships_by_owner(owner);
839        serde_json::to_string(&filtered).map_err(serialization_error)
840    }
841
842    /// Filter Data Flow nodes (tables) by infrastructure type.
843    ///
844    /// # Arguments
845    ///
846    /// * `workspace_json` - JSON string containing workspace/data model structure
847    /// * `infrastructure_type` - Infrastructure type string (e.g., "Kafka", "PostgreSQL")
848    ///
849    /// # Returns
850    ///
851    /// JSON string containing array of matching tables, or JsValue error
852    #[wasm_bindgen]
853    pub fn filter_nodes_by_infrastructure_type(
854        workspace_json: &str,
855        infrastructure_type: &str,
856    ) -> Result<String, JsValue> {
857        let model = deserialize_workspace(workspace_json)?;
858        let infra_type: crate::models::enums::InfrastructureType =
859            serde_json::from_str(&format!("\"{}\"", infrastructure_type))
860                .map_err(|e| invalid_input_error("infrastructure type", e))?;
861        let filtered = model.filter_nodes_by_infrastructure_type(infra_type);
862        serde_json::to_string(&filtered).map_err(serialization_error)
863    }
864
865    /// Filter Data Flow relationships by infrastructure type.
866    ///
867    /// # Arguments
868    ///
869    /// * `workspace_json` - JSON string containing workspace/data model structure
870    /// * `infrastructure_type` - Infrastructure type string (e.g., "Kafka", "PostgreSQL")
871    ///
872    /// # Returns
873    ///
874    /// JSON string containing array of matching relationships, or JsValue error
875    #[wasm_bindgen]
876    pub fn filter_relationships_by_infrastructure_type(
877        workspace_json: &str,
878        infrastructure_type: &str,
879    ) -> Result<String, JsValue> {
880        let model = deserialize_workspace(workspace_json)?;
881        let infra_type: crate::models::enums::InfrastructureType =
882            serde_json::from_str(&format!("\"{}\"", infrastructure_type))
883                .map_err(|e| invalid_input_error("infrastructure type", e))?;
884        let filtered = model.filter_relationships_by_infrastructure_type(infra_type);
885        serde_json::to_string(&filtered).map_err(serialization_error)
886    }
887
888    /// Filter Data Flow nodes and relationships by tag.
889    ///
890    /// # Arguments
891    ///
892    /// * `workspace_json` - JSON string containing workspace/data model structure
893    /// * `tag` - Tag to filter by
894    ///
895    /// # Returns
896    ///
897    /// JSON string containing object with `nodes` and `relationships` arrays, or JsValue error
898    #[wasm_bindgen]
899    pub fn filter_by_tags(workspace_json: &str, tag: &str) -> Result<String, JsValue> {
900        let model = deserialize_workspace(workspace_json)?;
901        let (nodes, relationships) = model.filter_by_tags(tag);
902        let result = serde_json::json!({
903            "nodes": nodes,
904            "relationships": relationships
905        });
906        serde_json::to_string(&result).map_err(serialization_error)
907    }
908
909    // ============================================================================
910    // Domain Operations
911    // ============================================================================
912
913    /// Add a system to a domain in a DataModel.
914    ///
915    /// # Arguments
916    ///
917    /// * `workspace_json` - JSON string containing workspace/data model structure
918    /// * `domain_id` - Domain UUID as string
919    /// * `system_json` - JSON string containing System
920    ///
921    /// # Returns
922    ///
923    /// JSON string containing updated DataModel, or JsValue error
924    #[wasm_bindgen]
925    pub fn add_system_to_domain(
926        workspace_json: &str,
927        domain_id: &str,
928        system_json: &str,
929    ) -> Result<String, JsValue> {
930        let mut model = deserialize_workspace(workspace_json)?;
931        let domain_uuid =
932            uuid::Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
933        let system: crate::models::domain::System =
934            serde_json::from_str(system_json).map_err(deserialization_error)?;
935        model
936            .add_system_to_domain(domain_uuid, system)
937            .map_err(|e| WasmError::new("OperationError", e).to_js_value())?;
938        serde_json::to_string(&model).map_err(serialization_error)
939    }
940
941    /// Add a CADS node to a domain in a DataModel.
942    ///
943    /// # Arguments
944    ///
945    /// * `workspace_json` - JSON string containing workspace/data model structure
946    /// * `domain_id` - Domain UUID as string
947    /// * `node_json` - JSON string containing CADSNode
948    ///
949    /// # Returns
950    ///
951    /// JSON string containing updated DataModel, or JsValue error
952    #[wasm_bindgen]
953    pub fn add_cads_node_to_domain(
954        workspace_json: &str,
955        domain_id: &str,
956        node_json: &str,
957    ) -> Result<String, JsValue> {
958        let mut model = deserialize_workspace(workspace_json)?;
959        let domain_uuid =
960            uuid::Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
961        let node: crate::models::domain::CADSNode =
962            serde_json::from_str(node_json).map_err(deserialization_error)?;
963        model
964            .add_cads_node_to_domain(domain_uuid, node)
965            .map_err(|e| WasmError::new("OperationError", e).to_js_value())?;
966        serde_json::to_string(&model).map_err(serialization_error)
967    }
968
969    /// Add an ODCS node to a domain in a DataModel.
970    ///
971    /// # Arguments
972    ///
973    /// * `workspace_json` - JSON string containing workspace/data model structure
974    /// * `domain_id` - Domain UUID as string
975    /// * `node_json` - JSON string containing ODCSNode
976    ///
977    /// # Returns
978    ///
979    /// JSON string containing updated DataModel, or JsValue error
980    #[wasm_bindgen]
981    pub fn add_odcs_node_to_domain(
982        workspace_json: &str,
983        domain_id: &str,
984        node_json: &str,
985    ) -> Result<String, JsValue> {
986        let mut model = deserialize_workspace(workspace_json)?;
987        let domain_uuid =
988            uuid::Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
989        let node: crate::models::domain::ODCSNode =
990            serde_json::from_str(node_json).map_err(deserialization_error)?;
991        model
992            .add_odcs_node_to_domain(domain_uuid, node)
993            .map_err(|e| WasmError::new("OperationError", e).to_js_value())?;
994        serde_json::to_string(&model).map_err(serialization_error)
995    }
996
997    // ============================================================================
998    // Validation Functions
999    // ============================================================================
1000
1001    /// Validate a table name.
1002    ///
1003    /// # Arguments
1004    ///
1005    /// * `name` - Table name to validate
1006    ///
1007    /// # Returns
1008    ///
1009    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
1010    #[wasm_bindgen]
1011    pub fn validate_table_name(name: &str) -> Result<String, JsValue> {
1012        match crate::validation::input::validate_table_name(name) {
1013            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
1014            Err(err) => {
1015                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
1016            }
1017        }
1018    }
1019
1020    /// Validate a column name.
1021    ///
1022    /// # Arguments
1023    ///
1024    /// * `name` - Column name to validate
1025    ///
1026    /// # Returns
1027    ///
1028    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
1029    #[wasm_bindgen]
1030    pub fn validate_column_name(name: &str) -> Result<String, JsValue> {
1031        match crate::validation::input::validate_column_name(name) {
1032            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
1033            Err(err) => {
1034                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
1035            }
1036        }
1037    }
1038
1039    /// Validate a UUID string.
1040    ///
1041    /// # Arguments
1042    ///
1043    /// * `id` - UUID string to validate
1044    ///
1045    /// # Returns
1046    ///
1047    /// JSON string with validation result: `{"valid": true, "uuid": "..."}` or `{"valid": false, "error": "error message"}`
1048    #[wasm_bindgen]
1049    pub fn validate_uuid(id: &str) -> Result<String, JsValue> {
1050        match crate::validation::input::validate_uuid(id) {
1051            Ok(uuid) => {
1052                Ok(serde_json::json!({"valid": true, "uuid": uuid.to_string()}).to_string())
1053            }
1054            Err(err) => {
1055                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
1056            }
1057        }
1058    }
1059
1060    /// Validate a data type string.
1061    ///
1062    /// # Arguments
1063    ///
1064    /// * `data_type` - Data type string to validate
1065    ///
1066    /// # Returns
1067    ///
1068    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
1069    #[wasm_bindgen]
1070    pub fn validate_data_type(data_type: &str) -> Result<String, JsValue> {
1071        match crate::validation::input::validate_data_type(data_type) {
1072            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
1073            Err(err) => {
1074                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
1075            }
1076        }
1077    }
1078
1079    /// Validate a description string.
1080    ///
1081    /// # Arguments
1082    ///
1083    /// * `desc` - Description string to validate
1084    ///
1085    /// # Returns
1086    ///
1087    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
1088    #[wasm_bindgen]
1089    pub fn validate_description(desc: &str) -> Result<String, JsValue> {
1090        match crate::validation::input::validate_description(desc) {
1091            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
1092            Err(err) => {
1093                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
1094            }
1095        }
1096    }
1097
1098    /// Sanitize a SQL identifier by quoting it.
1099    ///
1100    /// # Arguments
1101    ///
1102    /// * `name` - SQL identifier to sanitize
1103    /// * `dialect` - SQL dialect ("postgresql", "mysql", "sqlserver", etc.)
1104    ///
1105    /// # Returns
1106    ///
1107    /// Sanitized SQL identifier string
1108    #[wasm_bindgen]
1109    pub fn sanitize_sql_identifier(name: &str, dialect: &str) -> String {
1110        crate::validation::input::sanitize_sql_identifier(name, dialect)
1111    }
1112
1113    /// Sanitize a description string.
1114    ///
1115    /// # Arguments
1116    ///
1117    /// * `desc` - Description string to sanitize
1118    ///
1119    /// # Returns
1120    ///
1121    /// Sanitized description string
1122    #[wasm_bindgen]
1123    pub fn sanitize_description(desc: &str) -> String {
1124        crate::validation::input::sanitize_description(desc)
1125    }
1126
1127    /// Detect naming conflicts between existing and new tables.
1128    ///
1129    /// # Arguments
1130    ///
1131    /// * `existing_tables_json` - JSON string containing array of existing tables
1132    /// * `new_tables_json` - JSON string containing array of new tables
1133    ///
1134    /// # Returns
1135    ///
1136    /// JSON string containing array of naming conflicts
1137    #[wasm_bindgen]
1138    pub fn detect_naming_conflicts(
1139        existing_tables_json: &str,
1140        new_tables_json: &str,
1141    ) -> Result<String, JsValue> {
1142        let existing_tables: Vec<crate::models::Table> =
1143            serde_json::from_str(existing_tables_json).map_err(deserialization_error)?;
1144        let new_tables: Vec<crate::models::Table> =
1145            serde_json::from_str(new_tables_json).map_err(deserialization_error)?;
1146
1147        let validator = crate::validation::tables::TableValidator::new();
1148        let conflicts = validator.detect_naming_conflicts(&existing_tables, &new_tables);
1149
1150        serde_json::to_string(&conflicts).map_err(serialization_error)
1151    }
1152
1153    /// Validate pattern exclusivity for a table (SCD pattern and Data Vault classification are mutually exclusive).
1154    ///
1155    /// # Arguments
1156    ///
1157    /// * `table_json` - JSON string containing table to validate
1158    ///
1159    /// # Returns
1160    ///
1161    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "violation": {...}}`
1162    #[wasm_bindgen]
1163    pub fn validate_pattern_exclusivity(table_json: &str) -> Result<String, JsValue> {
1164        let table: crate::models::Table =
1165            serde_json::from_str(table_json).map_err(deserialization_error)?;
1166
1167        let validator = crate::validation::tables::TableValidator::new();
1168        match validator.validate_pattern_exclusivity(&table) {
1169            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
1170            Err(violation) => {
1171                Ok(serde_json::json!({"valid": false, "violation": violation}).to_string())
1172            }
1173        }
1174    }
1175
1176    /// Check for circular dependencies in relationships.
1177    ///
1178    /// # Arguments
1179    ///
1180    /// * `relationships_json` - JSON string containing array of existing relationships
1181    /// * `source_table_id` - Source table ID (UUID string) of the new relationship
1182    /// * `target_table_id` - Target table ID (UUID string) of the new relationship
1183    ///
1184    /// # Returns
1185    ///
1186    /// JSON string with result: `{"has_cycle": true/false, "cycle_path": [...]}` or error
1187    #[wasm_bindgen]
1188    pub fn check_circular_dependency(
1189        relationships_json: &str,
1190        source_table_id: &str,
1191        target_table_id: &str,
1192    ) -> Result<String, JsValue> {
1193        let relationships: Vec<crate::models::Relationship> =
1194            serde_json::from_str(relationships_json).map_err(deserialization_error)?;
1195
1196        let source_id = uuid::Uuid::parse_str(source_table_id)
1197            .map_err(|e| invalid_input_error("source_table_id", e))?;
1198        let target_id = uuid::Uuid::parse_str(target_table_id)
1199            .map_err(|e| invalid_input_error("target_table_id", e))?;
1200
1201        let validator = crate::validation::relationships::RelationshipValidator::new();
1202        match validator.check_circular_dependency(&relationships, source_id, target_id) {
1203            Ok((has_cycle, cycle_path)) => {
1204                let cycle_path_strs: Vec<String> = cycle_path
1205                    .map(|path| path.iter().map(|id| id.to_string()).collect())
1206                    .unwrap_or_default();
1207                Ok(serde_json::json!({
1208                    "has_cycle": has_cycle,
1209                    "cycle_path": cycle_path_strs
1210                })
1211                .to_string())
1212            }
1213            Err(err) => Err(validation_error(err)),
1214        }
1215    }
1216
1217    /// Validate that source and target tables are different (no self-reference).
1218    ///
1219    /// # Arguments
1220    ///
1221    /// * `source_table_id` - Source table ID (UUID string)
1222    /// * `target_table_id` - Target table ID (UUID string)
1223    ///
1224    /// # Returns
1225    ///
1226    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "self_reference": {...}}`
1227    #[wasm_bindgen]
1228    pub fn validate_no_self_reference(
1229        source_table_id: &str,
1230        target_table_id: &str,
1231    ) -> Result<String, JsValue> {
1232        let source_id = uuid::Uuid::parse_str(source_table_id)
1233            .map_err(|e| invalid_input_error("source_table_id", e))?;
1234        let target_id = uuid::Uuid::parse_str(target_table_id)
1235            .map_err(|e| invalid_input_error("target_table_id", e))?;
1236
1237        let validator = crate::validation::relationships::RelationshipValidator::new();
1238        match validator.validate_no_self_reference(source_id, target_id) {
1239            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
1240            Err(self_ref) => {
1241                Ok(serde_json::json!({"valid": false, "self_reference": self_ref}).to_string())
1242            }
1243        }
1244    }
1245
1246    // ============================================================================
1247    // PNG Export
1248    // ============================================================================
1249
1250    /// Export a data model to PNG image format.
1251    ///
1252    /// # Arguments
1253    ///
1254    /// * `workspace_json` - JSON string containing workspace/data model structure
1255    /// * `width` - Image width in pixels
1256    /// * `height` - Image height in pixels
1257    ///
1258    /// # Returns
1259    ///
1260    /// Base64-encoded PNG image string, or JsValue error
1261    #[cfg(feature = "png-export")]
1262    #[wasm_bindgen]
1263    pub fn export_to_png(workspace_json: &str, width: u32, height: u32) -> Result<String, JsValue> {
1264        let model = deserialize_workspace(workspace_json)?;
1265        let exporter = crate::export::PNGExporter::new();
1266        match exporter.export(&model.tables, width, height) {
1267            Ok(result) => Ok(result.content), // Already base64-encoded
1268            Err(err) => Err(export_error_to_js(err)),
1269        }
1270    }
1271
1272    // ============================================================================
1273    // Model Loading/Saving (Async)
1274    // ============================================================================
1275
1276    /// Load a model from browser storage (IndexedDB/localStorage).
1277    ///
1278    /// # Arguments
1279    ///
1280    /// * `db_name` - IndexedDB database name
1281    /// * `store_name` - Object store name
1282    /// * `workspace_path` - Workspace path to load from
1283    ///
1284    /// # Returns
1285    ///
1286    /// Promise that resolves to JSON string containing ModelLoadResult, or rejects with error
1287    #[wasm_bindgen]
1288    pub fn load_model(db_name: &str, store_name: &str, workspace_path: &str) -> js_sys::Promise {
1289        let db_name = db_name.to_string();
1290        let store_name = store_name.to_string();
1291        let workspace_path = workspace_path.to_string();
1292
1293        wasm_bindgen_futures::future_to_promise(async move {
1294            let storage = crate::storage::browser::BrowserStorageBackend::new(db_name, store_name);
1295            let loader = crate::model::ModelLoader::new(storage);
1296            match loader.load_model(&workspace_path).await {
1297                Ok(result) => serde_json::to_string(&result)
1298                    .map(|s| JsValue::from_str(&s))
1299                    .map_err(serialization_error),
1300                Err(err) => Err(storage_error(err)),
1301            }
1302        })
1303    }
1304
1305    /// Save a model to browser storage (IndexedDB/localStorage).
1306    ///
1307    /// # Arguments
1308    ///
1309    /// * `db_name` - IndexedDB database name
1310    /// * `store_name` - Object store name
1311    /// * `workspace_path` - Workspace path to save to
1312    /// * `model_json` - JSON string containing DataModel to save
1313    ///
1314    /// # Returns
1315    ///
1316    /// Promise that resolves to success message, or rejects with error
1317    #[wasm_bindgen]
1318    pub fn save_model(
1319        db_name: &str,
1320        store_name: &str,
1321        workspace_path: &str,
1322        model_json: &str,
1323    ) -> js_sys::Promise {
1324        let db_name = db_name.to_string();
1325        let store_name = store_name.to_string();
1326        let workspace_path = workspace_path.to_string();
1327        let model_json = model_json.to_string();
1328
1329        wasm_bindgen_futures::future_to_promise(async move {
1330            let model: crate::models::DataModel =
1331                serde_json::from_str(&model_json).map_err(deserialization_error)?;
1332
1333            let storage = crate::storage::browser::BrowserStorageBackend::new(db_name, store_name);
1334            let saver = crate::model::ModelSaver::new(storage);
1335
1336            // Convert DataModel to table/relationship data for saving
1337            // For each table, save as YAML
1338            for table in &model.tables {
1339                // Export table to ODCS YAML
1340                let yaml = crate::export::ODCSExporter::export_table(table, "odcs_v3_1_0");
1341                let table_data = crate::model::saver::TableData {
1342                    id: table.id,
1343                    name: table.name.clone(),
1344                    yaml_file_path: Some(format!("tables/{}.yaml", table.name)),
1345                    yaml_value: serde_yaml::from_str(&yaml).map_err(parse_error)?,
1346                };
1347                saver
1348                    .save_table(&workspace_path, &table_data)
1349                    .await
1350                    .map_err(storage_error)?;
1351            }
1352
1353            // Save relationships
1354            if !model.relationships.is_empty() {
1355                let rel_data: Vec<crate::model::saver::RelationshipData> = model
1356                    .relationships
1357                    .iter()
1358                    .map(|rel| {
1359                        let yaml_value = serde_json::json!({
1360                            "id": rel.id.to_string(),
1361                            "source_table_id": rel.source_table_id.to_string(),
1362                            "target_table_id": rel.target_table_id.to_string(),
1363                        });
1364                        // Convert JSON value to YAML value
1365                        let yaml_str = serde_json::to_string(&yaml_value)
1366                            .map_err(|e| format!("Failed to serialize relationship: {}", e))?;
1367                        let yaml_value = serde_yaml::from_str(&yaml_str)
1368                            .map_err(|e| format!("Failed to convert to YAML: {}", e))?;
1369                        Ok(crate::model::saver::RelationshipData {
1370                            id: rel.id,
1371                            source_table_id: rel.source_table_id,
1372                            target_table_id: rel.target_table_id,
1373                            yaml_value,
1374                        })
1375                    })
1376                    .collect::<Result<Vec<_>, String>>()
1377                    .map_err(|e| WasmError::new("OperationError", e).to_js_value())?;
1378
1379                saver
1380                    .save_relationships(&workspace_path, &rel_data)
1381                    .await
1382                    .map_err(|e| storage_error(e))?;
1383            }
1384
1385            Ok(JsValue::from_str("Model saved successfully"))
1386        })
1387    }
1388
1389    // BPMN WASM Bindings
1390    /// Import a BPMN model from XML content.
1391    ///
1392    /// # Arguments
1393    ///
1394    /// * `domain_id` - Domain UUID as string
1395    /// * `xml_content` - BPMN XML content as a string
1396    /// * `model_name` - Optional model name (extracted from XML if not provided)
1397    ///
1398    /// # Returns
1399    ///
1400    /// JSON string containing BPMNModel, or JsValue error
1401    #[cfg(feature = "bpmn")]
1402    #[wasm_bindgen]
1403    pub fn import_bpmn_model(
1404        domain_id: &str,
1405        xml_content: &str,
1406        model_name: Option<String>,
1407    ) -> Result<String, JsValue> {
1408        use crate::import::bpmn::BPMNImporter;
1409        use uuid::Uuid;
1410
1411        let domain_uuid =
1412            Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
1413
1414        let mut importer = BPMNImporter::new();
1415        match importer.import(xml_content, domain_uuid, model_name.as_deref()) {
1416            Ok(model) => serde_json::to_string(&model).map_err(serialization_error),
1417            Err(e) => Err(import_error_to_js(ImportError::ParseError(e.to_string()))),
1418        }
1419    }
1420
1421    /// Export a BPMN model to XML content.
1422    ///
1423    /// # Arguments
1424    ///
1425    /// * `xml_content` - BPMN XML content as a string
1426    ///
1427    /// # Returns
1428    ///
1429    /// BPMN XML content as string, or JsValue error
1430    #[cfg(feature = "bpmn")]
1431    #[wasm_bindgen]
1432    pub fn export_bpmn_model(xml_content: &str) -> Result<String, JsValue> {
1433        use crate::export::bpmn::BPMNExporter;
1434        let exporter = BPMNExporter::new();
1435        exporter
1436            .export(xml_content)
1437            .map_err(|e| export_error_to_js(ExportError::SerializationError(e.to_string())))
1438    }
1439
1440    // DMN WASM Bindings
1441    /// Import a DMN model from XML content.
1442    ///
1443    /// # Arguments
1444    ///
1445    /// * `domain_id` - Domain UUID as string
1446    /// * `xml_content` - DMN XML content as a string
1447    /// * `model_name` - Optional model name (extracted from XML if not provided)
1448    ///
1449    /// # Returns
1450    ///
1451    /// JSON string containing DMNModel, or JsValue error
1452    #[cfg(feature = "dmn")]
1453    #[wasm_bindgen]
1454    pub fn import_dmn_model(
1455        domain_id: &str,
1456        xml_content: &str,
1457        model_name: Option<String>,
1458    ) -> Result<String, JsValue> {
1459        use crate::import::dmn::DMNImporter;
1460        use uuid::Uuid;
1461
1462        let domain_uuid =
1463            Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
1464
1465        let mut importer = DMNImporter::new();
1466        match importer.import(xml_content, domain_uuid, model_name.as_deref()) {
1467            Ok(model) => serde_json::to_string(&model).map_err(serialization_error),
1468            Err(e) => Err(import_error_to_js(ImportError::ParseError(e.to_string()))),
1469        }
1470    }
1471
1472    /// Export a DMN model to XML content.
1473    ///
1474    /// # Arguments
1475    ///
1476    /// * `xml_content` - DMN XML content as a string
1477    ///
1478    /// # Returns
1479    ///
1480    /// DMN XML content as string, or JsValue error
1481    #[cfg(feature = "dmn")]
1482    #[wasm_bindgen]
1483    pub fn export_dmn_model(xml_content: &str) -> Result<String, JsValue> {
1484        use crate::export::dmn::DMNExporter;
1485        let exporter = DMNExporter::new();
1486        exporter
1487            .export(xml_content)
1488            .map_err(|e| export_error_to_js(ExportError::SerializationError(e.to_string())))
1489    }
1490
1491    // OpenAPI WASM Bindings
1492    /// Import an OpenAPI specification from YAML or JSON content.
1493    ///
1494    /// # Arguments
1495    ///
1496    /// * `domain_id` - Domain UUID as string
1497    /// * `content` - OpenAPI YAML or JSON content as a string
1498    /// * `api_name` - Optional API name (extracted from info.title if not provided)
1499    ///
1500    /// # Returns
1501    ///
1502    /// JSON string containing OpenAPIModel, or JsValue error
1503    #[cfg(feature = "openapi")]
1504    #[wasm_bindgen]
1505    pub fn import_openapi_spec(
1506        domain_id: &str,
1507        content: &str,
1508        api_name: Option<String>,
1509    ) -> Result<String, JsValue> {
1510        use crate::import::openapi::OpenAPIImporter;
1511        use uuid::Uuid;
1512
1513        let domain_uuid =
1514            Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
1515
1516        let mut importer = OpenAPIImporter::new();
1517        match importer.import(content, domain_uuid, api_name.as_deref()) {
1518            Ok(model) => serde_json::to_string(&model).map_err(serialization_error),
1519            Err(e) => Err(import_error_to_js(ImportError::ParseError(e.to_string()))),
1520        }
1521    }
1522
1523    /// Export an OpenAPI specification to YAML or JSON content.
1524    ///
1525    /// # Arguments
1526    ///
1527    /// * `content` - OpenAPI content as a string
1528    /// * `source_format` - Source format ("yaml" or "json")
1529    /// * `target_format` - Optional target format for conversion (None to keep original)
1530    ///
1531    /// # Returns
1532    ///
1533    /// OpenAPI content in requested format, or JsValue error
1534    #[cfg(feature = "openapi")]
1535    #[wasm_bindgen]
1536    pub fn export_openapi_spec(
1537        content: &str,
1538        source_format: &str,
1539        target_format: Option<String>,
1540    ) -> Result<String, JsValue> {
1541        use crate::export::openapi::OpenAPIExporter;
1542        use crate::models::openapi::OpenAPIFormat;
1543
1544        let source_fmt = match source_format {
1545            "yaml" | "yml" => OpenAPIFormat::Yaml,
1546            "json" => OpenAPIFormat::Json,
1547            _ => {
1548                return Err(invalid_input_error("source format", "Use 'yaml' or 'json'"));
1549            }
1550        };
1551
1552        let target_fmt = if let Some(tf) = target_format {
1553            match tf.as_str() {
1554                "yaml" | "yml" => Some(OpenAPIFormat::Yaml),
1555                "json" => Some(OpenAPIFormat::Json),
1556                _ => {
1557                    return Err(invalid_input_error("target format", "Use 'yaml' or 'json'"));
1558                }
1559            }
1560        } else {
1561            None
1562        };
1563
1564        let exporter = OpenAPIExporter::new();
1565        exporter
1566            .export(content, source_fmt, target_fmt)
1567            .map_err(|e| export_error_to_js(ExportError::SerializationError(e.to_string())))
1568    }
1569
1570    /// Convert an OpenAPI schema component to an ODCS table.
1571    ///
1572    /// # Arguments
1573    ///
1574    /// * `openapi_content` - OpenAPI YAML or JSON content as a string
1575    /// * `component_name` - Name of the schema component to convert
1576    /// * `table_name` - Optional desired ODCS table name (uses component_name if None)
1577    ///
1578    /// # Returns
1579    ///
1580    /// JSON string containing ODCS Table, or JsValue error
1581    #[cfg(feature = "openapi")]
1582    #[wasm_bindgen]
1583    pub fn convert_openapi_to_odcs(
1584        openapi_content: &str,
1585        component_name: &str,
1586        table_name: Option<String>,
1587    ) -> Result<String, JsValue> {
1588        use crate::convert::openapi_to_odcs::OpenAPIToODCSConverter;
1589
1590        let converter = OpenAPIToODCSConverter::new();
1591        match converter.convert_component(openapi_content, component_name, table_name.as_deref()) {
1592            Ok(table) => serde_json::to_string(&table).map_err(serialization_error),
1593            Err(e) => Err(conversion_error(e)),
1594        }
1595    }
1596
1597    /// Analyze an OpenAPI component for conversion feasibility.
1598    ///
1599    /// # Arguments
1600    ///
1601    /// * `openapi_content` - OpenAPI YAML or JSON content as a string
1602    /// * `component_name` - Name of the schema component to analyze
1603    ///
1604    /// # Returns
1605    ///
1606    /// JSON string containing ConversionReport, or JsValue error
1607    #[cfg(feature = "openapi")]
1608    #[wasm_bindgen]
1609    pub fn analyze_openapi_conversion(
1610        openapi_content: &str,
1611        component_name: &str,
1612    ) -> Result<String, JsValue> {
1613        use crate::convert::openapi_to_odcs::OpenAPIToODCSConverter;
1614
1615        let converter = OpenAPIToODCSConverter::new();
1616        match converter.analyze_conversion(openapi_content, component_name) {
1617            Ok(report) => serde_json::to_string(&report).map_err(serialization_error),
1618            Err(e) => Err(WasmError::new("AnalysisError", e.to_string())
1619                .with_code("ANALYSIS_FAILED")
1620                .to_js_value()),
1621        }
1622    }
1623
1624    // ============================================================================
1625    // Workspace and DomainConfig Operations
1626    // ============================================================================
1627
1628    /// Create a new workspace.
1629    ///
1630    /// # Arguments
1631    ///
1632    /// * `name` - Workspace name
1633    /// * `owner_id` - Owner UUID as string
1634    ///
1635    /// # Returns
1636    ///
1637    /// JSON string containing Workspace, or JsValue error
1638    #[wasm_bindgen]
1639    pub fn create_workspace(name: &str, owner_id: &str) -> Result<String, JsValue> {
1640        use crate::models::workspace::Workspace;
1641        use chrono::Utc;
1642        use uuid::Uuid;
1643
1644        let owner_uuid =
1645            Uuid::parse_str(owner_id).map_err(|e| invalid_input_error("owner ID", e))?;
1646
1647        let workspace = Workspace {
1648            id: Uuid::new_v4(),
1649            name: name.to_string(),
1650            owner_id: owner_uuid,
1651            created_at: Utc::now(),
1652            last_modified_at: Utc::now(),
1653            domains: Vec::new(),
1654        };
1655
1656        serde_json::to_string(&workspace).map_err(serialization_error)
1657    }
1658
1659    /// Parse workspace YAML content and return a structured representation.
1660    ///
1661    /// # Arguments
1662    ///
1663    /// * `yaml_content` - Workspace YAML content as a string
1664    ///
1665    /// # Returns
1666    ///
1667    /// JSON string containing Workspace, or JsValue error
1668    #[wasm_bindgen]
1669    pub fn parse_workspace_yaml(yaml_content: &str) -> Result<String, JsValue> {
1670        use crate::models::workspace::Workspace;
1671
1672        let workspace: Workspace = serde_yaml::from_str(yaml_content).map_err(parse_error)?;
1673        serde_json::to_string(&workspace).map_err(serialization_error)
1674    }
1675
1676    /// Export a workspace to YAML format.
1677    ///
1678    /// # Arguments
1679    ///
1680    /// * `workspace_json` - JSON string containing Workspace
1681    ///
1682    /// # Returns
1683    ///
1684    /// Workspace YAML format string, or JsValue error
1685    #[wasm_bindgen]
1686    pub fn export_workspace_to_yaml(workspace_json: &str) -> Result<String, JsValue> {
1687        use crate::models::workspace::Workspace;
1688
1689        let workspace: Workspace =
1690            serde_json::from_str(workspace_json).map_err(deserialization_error)?;
1691        serde_yaml::to_string(&workspace).map_err(serialization_error)
1692    }
1693
1694    /// Add a domain reference to a workspace.
1695    ///
1696    /// # Arguments
1697    ///
1698    /// * `workspace_json` - JSON string containing Workspace
1699    /// * `domain_id` - Domain UUID as string
1700    /// * `domain_name` - Domain name
1701    ///
1702    /// # Returns
1703    ///
1704    /// JSON string containing updated Workspace, or JsValue error
1705    #[wasm_bindgen]
1706    pub fn add_domain_to_workspace(
1707        workspace_json: &str,
1708        domain_id: &str,
1709        domain_name: &str,
1710    ) -> Result<String, JsValue> {
1711        use crate::models::workspace::{DomainReference, Workspace};
1712        use chrono::Utc;
1713        use uuid::Uuid;
1714
1715        let mut workspace: Workspace =
1716            serde_json::from_str(workspace_json).map_err(deserialization_error)?;
1717        let domain_uuid =
1718            Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
1719
1720        // Check if domain already exists
1721        if workspace.domains.iter().any(|d| d.id == domain_uuid) {
1722            return Err(WasmError::new(
1723                "DuplicateError",
1724                format!("Domain {} already exists in workspace", domain_id),
1725            )
1726            .with_code("DUPLICATE_DOMAIN")
1727            .to_js_value());
1728        }
1729
1730        workspace.domains.push(DomainReference {
1731            id: domain_uuid,
1732            name: domain_name.to_string(),
1733        });
1734        workspace.last_modified_at = Utc::now();
1735
1736        serde_json::to_string(&workspace).map_err(serialization_error)
1737    }
1738
1739    /// Remove a domain reference from a workspace.
1740    ///
1741    /// # Arguments
1742    ///
1743    /// * `workspace_json` - JSON string containing Workspace
1744    /// * `domain_id` - Domain UUID as string to remove
1745    ///
1746    /// # Returns
1747    ///
1748    /// JSON string containing updated Workspace, or JsValue error
1749    #[wasm_bindgen]
1750    pub fn remove_domain_from_workspace(
1751        workspace_json: &str,
1752        domain_id: &str,
1753    ) -> Result<String, JsValue> {
1754        use crate::models::workspace::Workspace;
1755        use chrono::Utc;
1756        use uuid::Uuid;
1757
1758        let mut workspace: Workspace =
1759            serde_json::from_str(workspace_json).map_err(deserialization_error)?;
1760        let domain_uuid =
1761            Uuid::parse_str(domain_id).map_err(|e| invalid_input_error("domain ID", e))?;
1762
1763        let original_len = workspace.domains.len();
1764        workspace.domains.retain(|d| d.id != domain_uuid);
1765
1766        if workspace.domains.len() == original_len {
1767            return Err(WasmError::new(
1768                "NotFoundError",
1769                format!("Domain {} not found in workspace", domain_id),
1770            )
1771            .with_code("DOMAIN_NOT_FOUND")
1772            .to_js_value());
1773        }
1774
1775        workspace.last_modified_at = Utc::now();
1776        serde_json::to_string(&workspace).map_err(serialization_error)
1777    }
1778
1779    /// Create a new domain configuration.
1780    ///
1781    /// # Arguments
1782    ///
1783    /// * `name` - Domain name
1784    /// * `workspace_id` - Workspace UUID as string
1785    ///
1786    /// # Returns
1787    ///
1788    /// JSON string containing DomainConfig, or JsValue error
1789    #[wasm_bindgen]
1790    pub fn create_domain_config(name: &str, workspace_id: &str) -> Result<String, JsValue> {
1791        use crate::models::domain_config::DomainConfig;
1792        use chrono::Utc;
1793        use std::collections::HashMap;
1794        use uuid::Uuid;
1795
1796        let workspace_uuid =
1797            Uuid::parse_str(workspace_id).map_err(|e| invalid_input_error("workspace ID", e))?;
1798
1799        let config = DomainConfig {
1800            id: Uuid::new_v4(),
1801            workspace_id: workspace_uuid,
1802            name: name.to_string(),
1803            description: None,
1804            created_at: Utc::now(),
1805            last_modified_at: Utc::now(),
1806            owner: None,
1807            systems: Vec::new(),
1808            tables: Vec::new(),
1809            products: Vec::new(),
1810            assets: Vec::new(),
1811            processes: Vec::new(),
1812            decisions: Vec::new(),
1813            view_positions: HashMap::new(),
1814            folder_path: None,
1815            workspace_path: None,
1816        };
1817
1818        serde_json::to_string(&config).map_err(serialization_error)
1819    }
1820
1821    /// Parse domain config YAML content and return a structured representation.
1822    ///
1823    /// # Arguments
1824    ///
1825    /// * `yaml_content` - Domain config YAML content as a string
1826    ///
1827    /// # Returns
1828    ///
1829    /// JSON string containing DomainConfig, or JsValue error
1830    #[wasm_bindgen]
1831    pub fn parse_domain_config_yaml(yaml_content: &str) -> Result<String, JsValue> {
1832        use crate::models::domain_config::DomainConfig;
1833
1834        let config: DomainConfig = serde_yaml::from_str(yaml_content).map_err(parse_error)?;
1835        serde_json::to_string(&config).map_err(serialization_error)
1836    }
1837
1838    /// Export a domain config to YAML format.
1839    ///
1840    /// # Arguments
1841    ///
1842    /// * `config_json` - JSON string containing DomainConfig
1843    ///
1844    /// # Returns
1845    ///
1846    /// DomainConfig YAML format string, or JsValue error
1847    #[wasm_bindgen]
1848    pub fn export_domain_config_to_yaml(config_json: &str) -> Result<String, JsValue> {
1849        use crate::models::domain_config::DomainConfig;
1850
1851        let config: DomainConfig =
1852            serde_json::from_str(config_json).map_err(deserialization_error)?;
1853        serde_yaml::to_string(&config).map_err(serialization_error)
1854    }
1855
1856    /// Get the domain ID from a domain config JSON.
1857    ///
1858    /// # Arguments
1859    ///
1860    /// * `config_json` - JSON string containing DomainConfig
1861    ///
1862    /// # Returns
1863    ///
1864    /// Domain UUID as string, or JsValue error
1865    #[wasm_bindgen]
1866    pub fn get_domain_config_id(config_json: &str) -> Result<String, JsValue> {
1867        use crate::models::domain_config::DomainConfig;
1868
1869        let config: DomainConfig =
1870            serde_json::from_str(config_json).map_err(deserialization_error)?;
1871        Ok(config.id.to_string())
1872    }
1873
1874    /// Update domain config with new view positions.
1875    ///
1876    /// # Arguments
1877    ///
1878    /// * `config_json` - JSON string containing DomainConfig
1879    /// * `positions_json` - JSON string containing view positions map
1880    ///
1881    /// # Returns
1882    ///
1883    /// JSON string containing updated DomainConfig, or JsValue error
1884    #[wasm_bindgen]
1885    pub fn update_domain_view_positions(
1886        config_json: &str,
1887        positions_json: &str,
1888    ) -> Result<String, JsValue> {
1889        use crate::models::domain_config::{DomainConfig, ViewPosition};
1890        use chrono::Utc;
1891        use std::collections::HashMap;
1892
1893        let mut config: DomainConfig =
1894            serde_json::from_str(config_json).map_err(deserialization_error)?;
1895        let positions: HashMap<String, HashMap<String, ViewPosition>> =
1896            serde_json::from_str(positions_json).map_err(deserialization_error)?;
1897
1898        config.view_positions = positions;
1899        config.last_modified_at = Utc::now();
1900
1901        serde_json::to_string(&config).map_err(serialization_error)
1902    }
1903
1904    /// Add an entity reference to a domain config.
1905    ///
1906    /// # Arguments
1907    ///
1908    /// * `config_json` - JSON string containing DomainConfig
1909    /// * `entity_type` - Entity type: "system", "table", "product", "asset", "process", "decision"
1910    /// * `entity_id` - Entity UUID as string
1911    ///
1912    /// # Returns
1913    ///
1914    /// JSON string containing updated DomainConfig, or JsValue error
1915    #[wasm_bindgen]
1916    pub fn add_entity_to_domain_config(
1917        config_json: &str,
1918        entity_type: &str,
1919        entity_id: &str,
1920    ) -> Result<String, JsValue> {
1921        use crate::models::domain_config::DomainConfig;
1922        use chrono::Utc;
1923        use uuid::Uuid;
1924
1925        let mut config: DomainConfig =
1926            serde_json::from_str(config_json).map_err(deserialization_error)?;
1927        let entity_uuid =
1928            Uuid::parse_str(entity_id).map_err(|e| invalid_input_error("entity ID", e))?;
1929
1930        let entities = match entity_type {
1931            "system" => &mut config.systems,
1932            "table" => &mut config.tables,
1933            "product" => &mut config.products,
1934            "asset" => &mut config.assets,
1935            "process" => &mut config.processes,
1936            "decision" => &mut config.decisions,
1937            _ => {
1938                return Err(invalid_input_error(
1939                    "entity type",
1940                    "Use 'system', 'table', 'product', 'asset', 'process', or 'decision'",
1941                ));
1942            }
1943        };
1944
1945        if entities.contains(&entity_uuid) {
1946            return Err(WasmError::new(
1947                "DuplicateError",
1948                format!(
1949                    "{} {} already exists in domain config",
1950                    entity_type, entity_id
1951                ),
1952            )
1953            .with_code("DUPLICATE_ENTITY")
1954            .to_js_value());
1955        }
1956
1957        entities.push(entity_uuid);
1958        config.last_modified_at = Utc::now();
1959
1960        serde_json::to_string(&config).map_err(serialization_error)
1961    }
1962
1963    /// Remove an entity reference from a domain config.
1964    ///
1965    /// # Arguments
1966    ///
1967    /// * `config_json` - JSON string containing DomainConfig
1968    /// * `entity_type` - Entity type: "system", "table", "product", "asset", "process", "decision"
1969    /// * `entity_id` - Entity UUID as string to remove
1970    ///
1971    /// # Returns
1972    ///
1973    /// JSON string containing updated DomainConfig, or JsValue error
1974    #[wasm_bindgen]
1975    pub fn remove_entity_from_domain_config(
1976        config_json: &str,
1977        entity_type: &str,
1978        entity_id: &str,
1979    ) -> Result<String, JsValue> {
1980        use crate::models::domain_config::DomainConfig;
1981        use chrono::Utc;
1982        use uuid::Uuid;
1983
1984        let mut config: DomainConfig =
1985            serde_json::from_str(config_json).map_err(deserialization_error)?;
1986        let entity_uuid =
1987            Uuid::parse_str(entity_id).map_err(|e| invalid_input_error("entity ID", e))?;
1988
1989        let entities = match entity_type {
1990            "system" => &mut config.systems,
1991            "table" => &mut config.tables,
1992            "product" => &mut config.products,
1993            "asset" => &mut config.assets,
1994            "process" => &mut config.processes,
1995            "decision" => &mut config.decisions,
1996            _ => {
1997                return Err(invalid_input_error(
1998                    "entity type",
1999                    "Use 'system', 'table', 'product', 'asset', 'process', or 'decision'",
2000                ));
2001            }
2002        };
2003
2004        let original_len = entities.len();
2005        entities.retain(|id| *id != entity_uuid);
2006
2007        if entities.len() == original_len {
2008            return Err(WasmError::new(
2009                "NotFoundError",
2010                format!("{} {} not found in domain config", entity_type, entity_id),
2011            )
2012            .with_code("ENTITY_NOT_FOUND")
2013            .to_js_value());
2014        }
2015
2016        config.last_modified_at = Utc::now();
2017        serde_json::to_string(&config).map_err(serialization_error)
2018    }
2019}