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;
12pub mod export;
13#[cfg(feature = "git")]
14pub mod git;
15pub mod import;
16pub mod model;
17pub mod models;
18pub mod storage;
19pub mod validation;
20pub mod workspace;
21
22// Re-export commonly used types
23#[cfg(feature = "api-backend")]
24pub use storage::api::ApiStorageBackend;
25#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
26pub use storage::browser::BrowserStorageBackend;
27#[cfg(feature = "native-fs")]
28pub use storage::filesystem::FileSystemStorageBackend;
29pub use storage::{StorageBackend, StorageError};
30
31#[cfg(feature = "png-export")]
32pub use export::PNGExporter;
33pub use export::{
34    AvroExporter, ExportError, ExportResult, JSONSchemaExporter, ODCSExporter, ProtobufExporter,
35    SQLExporter, dataflow::DataFlowExporter,
36};
37pub use import::{
38    AvroImporter, ImportError, ImportResult, JSONSchemaImporter, ODCSImporter, ProtobufImporter,
39    SQLImporter, dataflow::DataFlowImporter,
40};
41#[cfg(feature = "api-backend")]
42pub use model::ApiModelLoader;
43pub use model::{ModelLoader, ModelSaver};
44pub use validation::{
45    RelationshipValidationError, RelationshipValidationResult, TableValidationError,
46    TableValidationResult,
47};
48
49// Re-export models
50pub use models::enums::*;
51pub use models::{Column, ContactDetails, DataModel, ForeignKey, Relationship, SlaProperty, Table};
52
53// Re-export auth types
54pub use auth::{
55    AuthMode, AuthState, GitHubEmail, InitiateOAuthRequest, InitiateOAuthResponse,
56    SelectEmailRequest,
57};
58
59// Re-export workspace types
60pub use workspace::{
61    CreateWorkspaceRequest, CreateWorkspaceResponse, ListProfilesResponse, LoadProfileRequest,
62    ProfileInfo, WorkspaceInfo,
63};
64
65// Re-export Git types
66#[cfg(feature = "git")]
67pub use git::{GitError, GitService, GitStatus};
68
69// WASM bindings for import/export functions
70#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
71mod wasm {
72    use crate::export::ExportError;
73    use crate::import::{ImportError, ImportResult};
74    use crate::models::DataModel;
75    use js_sys;
76    use serde_json;
77    use serde_yaml;
78    use uuid;
79    use wasm_bindgen::prelude::*;
80    use wasm_bindgen_futures;
81
82    /// Convert ImportError to JsValue for JavaScript error handling
83    fn import_error_to_js(err: ImportError) -> JsValue {
84        JsValue::from_str(&err.to_string())
85    }
86
87    /// Convert ExportError to JsValue for JavaScript error handling
88    fn export_error_to_js(err: ExportError) -> JsValue {
89        JsValue::from_str(&err.to_string())
90    }
91
92    /// Serialize ImportResult to JSON string
93    fn serialize_import_result(result: &ImportResult) -> Result<String, JsValue> {
94        serde_json::to_string(result)
95            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
96    }
97
98    /// Deserialize workspace structure from JSON string
99    fn deserialize_workspace(json: &str) -> Result<DataModel, JsValue> {
100        serde_json::from_str(json)
101            .map_err(|e| JsValue::from_str(&format!("Deserialization error: {}", e)))
102    }
103
104    /// Parse ODCS YAML content and return a structured workspace representation.
105    ///
106    /// # Arguments
107    ///
108    /// * `yaml_content` - ODCS YAML content as a string
109    ///
110    /// # Returns
111    ///
112    /// JSON string containing ImportResult object, or JsValue error
113    #[wasm_bindgen]
114    pub fn parse_odcs_yaml(yaml_content: &str) -> Result<String, JsValue> {
115        let mut importer = crate::import::ODCSImporter::new();
116        match importer.import(yaml_content) {
117            Ok(result) => serialize_import_result(&result),
118            Err(err) => Err(import_error_to_js(err)),
119        }
120    }
121
122    /// Export a workspace structure to ODCS YAML format.
123    ///
124    /// # Arguments
125    ///
126    /// * `workspace_json` - JSON string containing workspace/data model structure
127    ///
128    /// # Returns
129    ///
130    /// ODCS YAML format string, or JsValue error
131    #[wasm_bindgen]
132    pub fn export_to_odcs_yaml(workspace_json: &str) -> Result<String, JsValue> {
133        let model = deserialize_workspace(workspace_json)?;
134
135        // Export all tables as separate YAML documents, joined with ---\n
136        let exports = crate::export::ODCSExporter::export_model(&model, None, "odcs_v3_1_0");
137
138        // Combine all YAML documents into a single multi-document string
139        let yaml_docs: Vec<String> = exports.values().cloned().collect();
140        Ok(yaml_docs.join("\n---\n"))
141    }
142
143    /// Import data model from SQL CREATE TABLE statements.
144    ///
145    /// # Arguments
146    ///
147    /// * `sql_content` - SQL CREATE TABLE statements
148    /// * `dialect` - SQL dialect ("postgresql", "mysql", "sqlserver", "databricks")
149    ///
150    /// # Returns
151    ///
152    /// JSON string containing ImportResult object, or JsValue error
153    #[wasm_bindgen]
154    pub fn import_from_sql(sql_content: &str, dialect: &str) -> Result<String, JsValue> {
155        let importer = crate::import::SQLImporter::new(dialect);
156        match importer.parse(sql_content) {
157            Ok(result) => serialize_import_result(&result),
158            Err(err) => Err(JsValue::from_str(&format!("Parse error: {}", err))),
159        }
160    }
161
162    /// Import data model from AVRO schema.
163    ///
164    /// # Arguments
165    ///
166    /// * `avro_content` - AVRO schema JSON as a string
167    ///
168    /// # Returns
169    ///
170    /// JSON string containing ImportResult object, or JsValue error
171    #[wasm_bindgen]
172    pub fn import_from_avro(avro_content: &str) -> Result<String, JsValue> {
173        let importer = crate::import::AvroImporter::new();
174        match importer.import(avro_content) {
175            Ok(result) => serialize_import_result(&result),
176            Err(err) => Err(import_error_to_js(err)),
177        }
178    }
179
180    /// Import data model from JSON Schema definition.
181    ///
182    /// # Arguments
183    ///
184    /// * `json_schema_content` - JSON Schema definition as a string
185    ///
186    /// # Returns
187    ///
188    /// JSON string containing ImportResult object, or JsValue error
189    #[wasm_bindgen]
190    pub fn import_from_json_schema(json_schema_content: &str) -> Result<String, JsValue> {
191        let importer = crate::import::JSONSchemaImporter::new();
192        match importer.import(json_schema_content) {
193            Ok(result) => serialize_import_result(&result),
194            Err(err) => Err(import_error_to_js(err)),
195        }
196    }
197
198    /// Import data model from Protobuf schema.
199    ///
200    /// # Arguments
201    ///
202    /// * `protobuf_content` - Protobuf schema text
203    ///
204    /// # Returns
205    ///
206    /// JSON string containing ImportResult object, or JsValue error
207    #[wasm_bindgen]
208    pub fn import_from_protobuf(protobuf_content: &str) -> Result<String, JsValue> {
209        let importer = crate::import::ProtobufImporter::new();
210        match importer.import(protobuf_content) {
211            Ok(result) => serialize_import_result(&result),
212            Err(err) => Err(import_error_to_js(err)),
213        }
214    }
215
216    /// Export a data model to SQL CREATE TABLE statements.
217    ///
218    /// # Arguments
219    ///
220    /// * `workspace_json` - JSON string containing workspace/data model structure
221    /// * `dialect` - SQL dialect ("postgresql", "mysql", "sqlserver", "databricks")
222    ///
223    /// # Returns
224    ///
225    /// SQL CREATE TABLE statements, or JsValue error
226    #[wasm_bindgen]
227    pub fn export_to_sql(workspace_json: &str, dialect: &str) -> Result<String, JsValue> {
228        let model = deserialize_workspace(workspace_json)?;
229        let exporter = crate::export::SQLExporter;
230        match exporter.export(&model.tables, Some(dialect)) {
231            Ok(result) => Ok(result.content),
232            Err(err) => Err(export_error_to_js(err)),
233        }
234    }
235
236    /// Export a data model to AVRO schema.
237    ///
238    /// # Arguments
239    ///
240    /// * `workspace_json` - JSON string containing workspace/data model structure
241    ///
242    /// # Returns
243    ///
244    /// AVRO schema JSON string, or JsValue error
245    #[wasm_bindgen]
246    pub fn export_to_avro(workspace_json: &str) -> Result<String, JsValue> {
247        let model = deserialize_workspace(workspace_json)?;
248        let exporter = crate::export::AvroExporter;
249        match exporter.export(&model.tables) {
250            Ok(result) => Ok(result.content),
251            Err(err) => Err(export_error_to_js(err)),
252        }
253    }
254
255    /// Export a data model to JSON Schema definition.
256    ///
257    /// # Arguments
258    ///
259    /// * `workspace_json` - JSON string containing workspace/data model structure
260    ///
261    /// # Returns
262    ///
263    /// JSON Schema definition string, or JsValue error
264    #[wasm_bindgen]
265    pub fn export_to_json_schema(workspace_json: &str) -> Result<String, JsValue> {
266        let model = deserialize_workspace(workspace_json)?;
267        let exporter = crate::export::JSONSchemaExporter;
268        match exporter.export(&model.tables) {
269            Ok(result) => Ok(result.content),
270            Err(err) => Err(export_error_to_js(err)),
271        }
272    }
273
274    /// Export a data model to Protobuf schema.
275    ///
276    /// # Arguments
277    ///
278    /// * `workspace_json` - JSON string containing workspace/data model structure
279    ///
280    /// # Returns
281    ///
282    /// Protobuf schema text, or JsValue error
283    #[wasm_bindgen]
284    pub fn export_to_protobuf(workspace_json: &str) -> Result<String, JsValue> {
285        let model = deserialize_workspace(workspace_json)?;
286        let exporter = crate::export::ProtobufExporter;
287        match exporter.export(&model.tables) {
288            Ok(result) => Ok(result.content),
289            Err(err) => Err(export_error_to_js(err)),
290        }
291    }
292
293    /// Import Data Flow format YAML content (lightweight format for Data Flow nodes and relationships).
294    ///
295    /// # Arguments
296    ///
297    /// * `yaml_content` - Data Flow format YAML content as a string
298    ///
299    /// # Returns
300    ///
301    /// JSON string containing DataModel with nodes and relationships, or JsValue error
302    #[wasm_bindgen]
303    pub fn import_from_dataflow(yaml_content: &str) -> Result<String, JsValue> {
304        let importer = crate::import::dataflow::DataFlowImporter::new();
305        match importer.import(yaml_content) {
306            Ok(model) => serde_json::to_string(&model)
307                .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e))),
308            Err(err) => Err(import_error_to_js(err)),
309        }
310    }
311
312    /// Export a data model to Data Flow format YAML (lightweight format for Data Flow nodes and relationships).
313    ///
314    /// # Arguments
315    ///
316    /// * `workspace_json` - JSON string containing workspace/data model structure
317    ///
318    /// # Returns
319    ///
320    /// Data Flow format YAML string, or JsValue error
321    #[wasm_bindgen]
322    pub fn export_to_dataflow(workspace_json: &str) -> Result<String, JsValue> {
323        let model = deserialize_workspace(workspace_json)?;
324        let exporter = crate::export::dataflow::DataFlowExporter::new();
325        Ok(exporter.export_model(&model))
326    }
327
328    /// Filter Data Flow nodes (tables) by owner.
329    ///
330    /// # Arguments
331    ///
332    /// * `workspace_json` - JSON string containing workspace/data model structure
333    /// * `owner` - Owner name to filter by (case-sensitive exact match)
334    ///
335    /// # Returns
336    ///
337    /// JSON string containing array of matching tables, or JsValue error
338    #[wasm_bindgen]
339    pub fn filter_nodes_by_owner(workspace_json: &str, owner: &str) -> Result<String, JsValue> {
340        let model = deserialize_workspace(workspace_json)?;
341        let filtered = model.filter_nodes_by_owner(owner);
342        serde_json::to_string(&filtered)
343            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
344    }
345
346    /// Filter Data Flow relationships by owner.
347    ///
348    /// # Arguments
349    ///
350    /// * `workspace_json` - JSON string containing workspace/data model structure
351    /// * `owner` - Owner name to filter by (case-sensitive exact match)
352    ///
353    /// # Returns
354    ///
355    /// JSON string containing array of matching relationships, or JsValue error
356    #[wasm_bindgen]
357    pub fn filter_relationships_by_owner(
358        workspace_json: &str,
359        owner: &str,
360    ) -> Result<String, JsValue> {
361        let model = deserialize_workspace(workspace_json)?;
362        let filtered = model.filter_relationships_by_owner(owner);
363        serde_json::to_string(&filtered)
364            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
365    }
366
367    /// Filter Data Flow nodes (tables) by infrastructure type.
368    ///
369    /// # Arguments
370    ///
371    /// * `workspace_json` - JSON string containing workspace/data model structure
372    /// * `infrastructure_type` - Infrastructure type string (e.g., "Kafka", "PostgreSQL")
373    ///
374    /// # Returns
375    ///
376    /// JSON string containing array of matching tables, or JsValue error
377    #[wasm_bindgen]
378    pub fn filter_nodes_by_infrastructure_type(
379        workspace_json: &str,
380        infrastructure_type: &str,
381    ) -> Result<String, JsValue> {
382        let model = deserialize_workspace(workspace_json)?;
383        let infra_type: crate::models::enums::InfrastructureType =
384            serde_json::from_str(&format!("\"{}\"", infrastructure_type)).map_err(|e| {
385                JsValue::from_str(&format!(
386                    "Invalid infrastructure type '{}': {}",
387                    infrastructure_type, e
388                ))
389            })?;
390        let filtered = model.filter_nodes_by_infrastructure_type(infra_type);
391        serde_json::to_string(&filtered)
392            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
393    }
394
395    /// Filter Data Flow relationships by infrastructure type.
396    ///
397    /// # Arguments
398    ///
399    /// * `workspace_json` - JSON string containing workspace/data model structure
400    /// * `infrastructure_type` - Infrastructure type string (e.g., "Kafka", "PostgreSQL")
401    ///
402    /// # Returns
403    ///
404    /// JSON string containing array of matching relationships, or JsValue error
405    #[wasm_bindgen]
406    pub fn filter_relationships_by_infrastructure_type(
407        workspace_json: &str,
408        infrastructure_type: &str,
409    ) -> Result<String, JsValue> {
410        let model = deserialize_workspace(workspace_json)?;
411        let infra_type: crate::models::enums::InfrastructureType =
412            serde_json::from_str(&format!("\"{}\"", infrastructure_type)).map_err(|e| {
413                JsValue::from_str(&format!(
414                    "Invalid infrastructure type '{}': {}",
415                    infrastructure_type, e
416                ))
417            })?;
418        let filtered = model.filter_relationships_by_infrastructure_type(infra_type);
419        serde_json::to_string(&filtered)
420            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
421    }
422
423    /// Filter Data Flow nodes and relationships by tag.
424    ///
425    /// # Arguments
426    ///
427    /// * `workspace_json` - JSON string containing workspace/data model structure
428    /// * `tag` - Tag to filter by
429    ///
430    /// # Returns
431    ///
432    /// JSON string containing object with `nodes` and `relationships` arrays, or JsValue error
433    #[wasm_bindgen]
434    pub fn filter_by_tags(workspace_json: &str, tag: &str) -> Result<String, JsValue> {
435        let model = deserialize_workspace(workspace_json)?;
436        let (nodes, relationships) = model.filter_by_tags(tag);
437        let result = serde_json::json!({
438            "nodes": nodes,
439            "relationships": relationships
440        });
441        serde_json::to_string(&result)
442            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
443    }
444
445    // ============================================================================
446    // Validation Functions
447    // ============================================================================
448
449    /// Validate a table name.
450    ///
451    /// # Arguments
452    ///
453    /// * `name` - Table name to validate
454    ///
455    /// # Returns
456    ///
457    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
458    #[wasm_bindgen]
459    pub fn validate_table_name(name: &str) -> Result<String, JsValue> {
460        match crate::validation::input::validate_table_name(name) {
461            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
462            Err(err) => {
463                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
464            }
465        }
466    }
467
468    /// Validate a column name.
469    ///
470    /// # Arguments
471    ///
472    /// * `name` - Column name to validate
473    ///
474    /// # Returns
475    ///
476    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
477    #[wasm_bindgen]
478    pub fn validate_column_name(name: &str) -> Result<String, JsValue> {
479        match crate::validation::input::validate_column_name(name) {
480            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
481            Err(err) => {
482                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
483            }
484        }
485    }
486
487    /// Validate a UUID string.
488    ///
489    /// # Arguments
490    ///
491    /// * `id` - UUID string to validate
492    ///
493    /// # Returns
494    ///
495    /// JSON string with validation result: `{"valid": true, "uuid": "..."}` or `{"valid": false, "error": "error message"}`
496    #[wasm_bindgen]
497    pub fn validate_uuid(id: &str) -> Result<String, JsValue> {
498        match crate::validation::input::validate_uuid(id) {
499            Ok(uuid) => {
500                Ok(serde_json::json!({"valid": true, "uuid": uuid.to_string()}).to_string())
501            }
502            Err(err) => {
503                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
504            }
505        }
506    }
507
508    /// Validate a data type string.
509    ///
510    /// # Arguments
511    ///
512    /// * `data_type` - Data type string to validate
513    ///
514    /// # Returns
515    ///
516    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
517    #[wasm_bindgen]
518    pub fn validate_data_type(data_type: &str) -> Result<String, JsValue> {
519        match crate::validation::input::validate_data_type(data_type) {
520            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
521            Err(err) => {
522                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
523            }
524        }
525    }
526
527    /// Validate a description string.
528    ///
529    /// # Arguments
530    ///
531    /// * `desc` - Description string to validate
532    ///
533    /// # Returns
534    ///
535    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "error": "error message"}`
536    #[wasm_bindgen]
537    pub fn validate_description(desc: &str) -> Result<String, JsValue> {
538        match crate::validation::input::validate_description(desc) {
539            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
540            Err(err) => {
541                Ok(serde_json::json!({"valid": false, "error": err.to_string()}).to_string())
542            }
543        }
544    }
545
546    /// Sanitize a SQL identifier by quoting it.
547    ///
548    /// # Arguments
549    ///
550    /// * `name` - SQL identifier to sanitize
551    /// * `dialect` - SQL dialect ("postgresql", "mysql", "sqlserver", etc.)
552    ///
553    /// # Returns
554    ///
555    /// Sanitized SQL identifier string
556    #[wasm_bindgen]
557    pub fn sanitize_sql_identifier(name: &str, dialect: &str) -> String {
558        crate::validation::input::sanitize_sql_identifier(name, dialect)
559    }
560
561    /// Sanitize a description string.
562    ///
563    /// # Arguments
564    ///
565    /// * `desc` - Description string to sanitize
566    ///
567    /// # Returns
568    ///
569    /// Sanitized description string
570    #[wasm_bindgen]
571    pub fn sanitize_description(desc: &str) -> String {
572        crate::validation::input::sanitize_description(desc)
573    }
574
575    /// Detect naming conflicts between existing and new tables.
576    ///
577    /// # Arguments
578    ///
579    /// * `existing_tables_json` - JSON string containing array of existing tables
580    /// * `new_tables_json` - JSON string containing array of new tables
581    ///
582    /// # Returns
583    ///
584    /// JSON string containing array of naming conflicts
585    #[wasm_bindgen]
586    pub fn detect_naming_conflicts(
587        existing_tables_json: &str,
588        new_tables_json: &str,
589    ) -> Result<String, JsValue> {
590        let existing_tables: Vec<crate::models::Table> = serde_json::from_str(existing_tables_json)
591            .map_err(|e| JsValue::from_str(&format!("Failed to parse existing tables: {}", e)))?;
592        let new_tables: Vec<crate::models::Table> = serde_json::from_str(new_tables_json)
593            .map_err(|e| JsValue::from_str(&format!("Failed to parse new tables: {}", e)))?;
594
595        let validator = crate::validation::tables::TableValidator::new();
596        let conflicts = validator.detect_naming_conflicts(&existing_tables, &new_tables);
597
598        serde_json::to_string(&conflicts)
599            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
600    }
601
602    /// Validate pattern exclusivity for a table (SCD pattern and Data Vault classification are mutually exclusive).
603    ///
604    /// # Arguments
605    ///
606    /// * `table_json` - JSON string containing table to validate
607    ///
608    /// # Returns
609    ///
610    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "violation": {...}}`
611    #[wasm_bindgen]
612    pub fn validate_pattern_exclusivity(table_json: &str) -> Result<String, JsValue> {
613        let table: crate::models::Table = serde_json::from_str(table_json)
614            .map_err(|e| JsValue::from_str(&format!("Failed to parse table: {}", e)))?;
615
616        let validator = crate::validation::tables::TableValidator::new();
617        match validator.validate_pattern_exclusivity(&table) {
618            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
619            Err(violation) => {
620                Ok(serde_json::json!({"valid": false, "violation": violation}).to_string())
621            }
622        }
623    }
624
625    /// Check for circular dependencies in relationships.
626    ///
627    /// # Arguments
628    ///
629    /// * `relationships_json` - JSON string containing array of existing relationships
630    /// * `source_table_id` - Source table ID (UUID string) of the new relationship
631    /// * `target_table_id` - Target table ID (UUID string) of the new relationship
632    ///
633    /// # Returns
634    ///
635    /// JSON string with result: `{"has_cycle": true/false, "cycle_path": [...]}` or error
636    #[wasm_bindgen]
637    pub fn check_circular_dependency(
638        relationships_json: &str,
639        source_table_id: &str,
640        target_table_id: &str,
641    ) -> Result<String, JsValue> {
642        let relationships: Vec<crate::models::Relationship> =
643            serde_json::from_str(relationships_json)
644                .map_err(|e| JsValue::from_str(&format!("Failed to parse relationships: {}", e)))?;
645
646        let source_id = uuid::Uuid::parse_str(source_table_id)
647            .map_err(|e| JsValue::from_str(&format!("Invalid source_table_id: {}", e)))?;
648        let target_id = uuid::Uuid::parse_str(target_table_id)
649            .map_err(|e| JsValue::from_str(&format!("Invalid target_table_id: {}", e)))?;
650
651        let validator = crate::validation::relationships::RelationshipValidator::new();
652        match validator.check_circular_dependency(&relationships, source_id, target_id) {
653            Ok((has_cycle, cycle_path)) => {
654                let cycle_path_strs: Vec<String> = cycle_path
655                    .map(|path| path.iter().map(|id| id.to_string()).collect())
656                    .unwrap_or_default();
657                Ok(serde_json::json!({
658                    "has_cycle": has_cycle,
659                    "cycle_path": cycle_path_strs
660                })
661                .to_string())
662            }
663            Err(err) => Err(JsValue::from_str(&format!("Validation error: {}", err))),
664        }
665    }
666
667    /// Validate that source and target tables are different (no self-reference).
668    ///
669    /// # Arguments
670    ///
671    /// * `source_table_id` - Source table ID (UUID string)
672    /// * `target_table_id` - Target table ID (UUID string)
673    ///
674    /// # Returns
675    ///
676    /// JSON string with validation result: `{"valid": true}` or `{"valid": false, "self_reference": {...}}`
677    #[wasm_bindgen]
678    pub fn validate_no_self_reference(
679        source_table_id: &str,
680        target_table_id: &str,
681    ) -> Result<String, JsValue> {
682        let source_id = uuid::Uuid::parse_str(source_table_id)
683            .map_err(|e| JsValue::from_str(&format!("Invalid source_table_id: {}", e)))?;
684        let target_id = uuid::Uuid::parse_str(target_table_id)
685            .map_err(|e| JsValue::from_str(&format!("Invalid target_table_id: {}", e)))?;
686
687        let validator = crate::validation::relationships::RelationshipValidator::new();
688        match validator.validate_no_self_reference(source_id, target_id) {
689            Ok(()) => Ok(serde_json::json!({"valid": true}).to_string()),
690            Err(self_ref) => {
691                Ok(serde_json::json!({"valid": false, "self_reference": self_ref}).to_string())
692            }
693        }
694    }
695
696    // ============================================================================
697    // PNG Export
698    // ============================================================================
699
700    /// Export a data model to PNG image format.
701    ///
702    /// # Arguments
703    ///
704    /// * `workspace_json` - JSON string containing workspace/data model structure
705    /// * `width` - Image width in pixels
706    /// * `height` - Image height in pixels
707    ///
708    /// # Returns
709    ///
710    /// Base64-encoded PNG image string, or JsValue error
711    #[cfg(feature = "png-export")]
712    #[wasm_bindgen]
713    pub fn export_to_png(workspace_json: &str, width: u32, height: u32) -> Result<String, JsValue> {
714        let model = deserialize_workspace(workspace_json)?;
715        let exporter = crate::export::PNGExporter::new();
716        match exporter.export(&model.tables, width, height) {
717            Ok(result) => Ok(result.content), // Already base64-encoded
718            Err(err) => Err(export_error_to_js(err)),
719        }
720    }
721
722    // ============================================================================
723    // Model Loading/Saving (Async)
724    // ============================================================================
725
726    /// Load a model from browser storage (IndexedDB/localStorage).
727    ///
728    /// # Arguments
729    ///
730    /// * `db_name` - IndexedDB database name
731    /// * `store_name` - Object store name
732    /// * `workspace_path` - Workspace path to load from
733    ///
734    /// # Returns
735    ///
736    /// Promise that resolves to JSON string containing ModelLoadResult, or rejects with error
737    #[wasm_bindgen]
738    pub fn load_model(db_name: &str, store_name: &str, workspace_path: &str) -> js_sys::Promise {
739        let db_name = db_name.to_string();
740        let store_name = store_name.to_string();
741        let workspace_path = workspace_path.to_string();
742
743        wasm_bindgen_futures::future_to_promise(async move {
744            let storage = crate::storage::browser::BrowserStorageBackend::new(db_name, store_name);
745            let loader = crate::model::ModelLoader::new(storage);
746            match loader.load_model(&workspace_path).await {
747                Ok(result) => serde_json::to_string(&result)
748                    .map(|s| JsValue::from_str(&s))
749                    .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e))),
750                Err(err) => Err(JsValue::from_str(&format!("Storage error: {}", err))),
751            }
752        })
753    }
754
755    /// Save a model to browser storage (IndexedDB/localStorage).
756    ///
757    /// # Arguments
758    ///
759    /// * `db_name` - IndexedDB database name
760    /// * `store_name` - Object store name
761    /// * `workspace_path` - Workspace path to save to
762    /// * `model_json` - JSON string containing DataModel to save
763    ///
764    /// # Returns
765    ///
766    /// Promise that resolves to success message, or rejects with error
767    #[wasm_bindgen]
768    pub fn save_model(
769        db_name: &str,
770        store_name: &str,
771        workspace_path: &str,
772        model_json: &str,
773    ) -> js_sys::Promise {
774        let db_name = db_name.to_string();
775        let store_name = store_name.to_string();
776        let workspace_path = workspace_path.to_string();
777        let model_json = model_json.to_string();
778
779        wasm_bindgen_futures::future_to_promise(async move {
780            let model: crate::models::DataModel = serde_json::from_str(&model_json)
781                .map_err(|e| JsValue::from_str(&format!("Failed to parse model: {}", e)))?;
782
783            let storage = crate::storage::browser::BrowserStorageBackend::new(db_name, store_name);
784            let saver = crate::model::ModelSaver::new(storage);
785
786            // Convert DataModel to table/relationship data for saving
787            // For each table, save as YAML
788            for table in &model.tables {
789                // Export table to ODCS YAML
790                let yaml = crate::export::ODCSExporter::export_table(table, "odcs_v3_1_0");
791                let table_data = crate::model::saver::TableData {
792                    id: table.id,
793                    name: table.name.clone(),
794                    yaml_file_path: Some(format!("tables/{}.yaml", table.name)),
795                    yaml_value: serde_yaml::from_str(&yaml)
796                        .map_err(|e| JsValue::from_str(&format!("Failed to parse YAML: {}", e)))?,
797                };
798                saver
799                    .save_table(&workspace_path, &table_data)
800                    .await
801                    .map_err(|e| JsValue::from_str(&format!("Failed to save table: {}", e)))?;
802            }
803
804            // Save relationships
805            if !model.relationships.is_empty() {
806                let rel_data: Vec<crate::model::saver::RelationshipData> = model
807                    .relationships
808                    .iter()
809                    .map(|rel| {
810                        let yaml_value = serde_json::json!({
811                            "id": rel.id.to_string(),
812                            "source_table_id": rel.source_table_id.to_string(),
813                            "target_table_id": rel.target_table_id.to_string(),
814                        });
815                        // Convert JSON value to YAML value
816                        let yaml_str = serde_json::to_string(&yaml_value)
817                            .map_err(|e| format!("Failed to serialize relationship: {}", e))?;
818                        let yaml_value = serde_yaml::from_str(&yaml_str)
819                            .map_err(|e| format!("Failed to convert to YAML: {}", e))?;
820                        Ok(crate::model::saver::RelationshipData {
821                            id: rel.id,
822                            source_table_id: rel.source_table_id,
823                            target_table_id: rel.target_table_id,
824                            yaml_value,
825                        })
826                    })
827                    .collect::<Result<Vec<_>, String>>()
828                    .map_err(|e| JsValue::from_str(&e))?;
829
830                saver
831                    .save_relationships(&workspace_path, &rel_data)
832                    .await
833                    .map_err(|e| {
834                        JsValue::from_str(&format!("Failed to save relationships: {}", e))
835                    })?;
836            }
837
838            Ok(JsValue::from_str("Model saved successfully"))
839        })
840    }
841}