//! `MetaBackend` trait — la frontera entre el widget metainterfaz //! (yahweh) y la implementación concreta de persistencia/ejecución //! (nakui-core, Surreal, mocks para tests). //! //! El widget consume este trait; el binario lo implementa con su //! stack particular. Esto es lo que hace que el widget sea reusable. //! //! Convenciones documentadas en el doc del trait abajo. use std::collections::BTreeMap; use serde_json::Value; use uuid::Uuid; /// Resultado uniforme de una operación de escritura del backend. /// /// La UI lo usa para componer el toast: `id` para mostrar el /// short_uuid, `changed` para diferenciar "actualizado X (3 campos)" /// vs "sin cambios", `post_status` para concatenar mensajes /// emitidos por hooks internos del backend (ej. "auto-compact: /// snapshot @ seq 49") sin que la UI tenga que conocer el detalle. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct WriteOutcome { /// Id del record afectado. `Some` para seed/update/delete; /// `None` para morphism cuando afecta múltiples records. pub id: Option, /// Cantidad de cambios efectivos. `0` = no-op (edit que no /// modificó ningún campo, etc.). pub changed: usize, /// Mensaje de status opcional para concatenar al toast del op /// original con el separator estándar. pub post_status: Option, } impl WriteOutcome { /// Constructor para no-op writes (edits sin cambios). pub fn no_change(id: Uuid) -> Self { Self { id: Some(id), changed: 0, post_status: None, } } } /// Backend que un widget de metainterfaz usa para leer y mutar /// records. Decoupla el widget (yahweh) de la implementación /// concreta (nakui-core, Surreal, mock para tests). /// /// # Convención sobre ids /// /// `Uuid` canónico. Backends que internamente usan otros tipos /// deben mapear via Uuid (hash determinista, wrapper, lo que sirva). /// Esto evita generic associated types que complicarían el dispatch /// en `cx.listener` de GPUI. /// /// # Convención sobre validación /// /// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel /// post-checks, conservación, etc.). El widget pre-valida shape /// (yahweh-meta-runtime: `parse_field_value`, `validate_entity_refs`) /// pero el backend puede rebotar con `Err(...)` si su validación /// adicional falla — el widget muestra el error al usuario. /// /// # Convención sobre threading /// /// `'static` (no `Send + Sync`): el widget vive en `Entity>` /// que requiere `'static`, pero los handlers son single-threaded en /// el main UI thread de GPUI. Si en el futuro un backend necesita /// `cx.spawn`, agregamos los marker traits. /// /// # Convención sobre delta computation /// /// El widget pre-computa `set` y `clear` con /// [`crate::delta::compute_field_delta`] + /// [`crate::delta::compute_clear_fields`] *antes* de llamar a /// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos /// vacíos devuelve `changed = 0` sin escribir nada. Esto evita /// double-roundtrip al store por el mismo dato. pub trait MetaBackend: 'static { /// Snapshot ordenado de records de una entity. /// Orden estable (lexicográfico por id) para UI determinista. /// Vacío si no hay records. fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>; /// Lee un record por id. `None` si no existe. fn load_record(&self, entity: &str, id: Uuid) -> Option; /// Crea un record nuevo. El backend asigna el `Uuid` /// (devuelve en `WriteOutcome.id`). `changed = 1` siempre. fn seed( &mut self, entity: &str, data: serde_json::Map, ) -> Result; /// Edita un record existente. Aplica `set` (overrides) y /// `clear` (key removal). `changed = set.len() + clear.len()`. /// Si ambos están vacíos (no-op edit), devuelve /// `WriteOutcome::no_change(id)` sin error y sin escribir al log. fn update( &mut self, entity: &str, id: Uuid, set: serde_json::Map, clear: Vec, ) -> Result; /// Borra un record. `changed = 1` si existía, error si no. fn delete(&mut self, entity: &str, id: Uuid) -> Result; /// Ejecuta un morphism declarado por un módulo. El backend /// resuelve la implementación, valida, computa ops, las aplica. /// `changed = N ops aplicadas`. /// /// `module_id` ubica al módulo (el trait no asume estructura del /// manifest — el backend lo resuelve internamente). fn morphism( &mut self, module_id: &str, name: &str, inputs: BTreeMap, params: Value, ) -> Result; } #[cfg(test)] mod tests { //! Tests del trait via un `MemBackend` mínimo (HashMap por //! `(entity, uuid)`). Verifica el contrato del trait sin atar //! a un backend concreto. use super::*; use serde_json::json; use std::collections::HashMap; /// Mock backend in-memory. NO soporta morphisms (devuelve error /// inmediato) — un mock para tests del trait, no para uso real. #[derive(Default)] struct MemBackend { records: HashMap<(String, Uuid), Value>, } impl MetaBackend for MemBackend { fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> { let mut out: Vec<(Uuid, Value)> = self .records .iter() .filter(|((e, _), _)| e == entity) .map(|((_, id), v)| (*id, v.clone())) .collect(); out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); out } fn load_record(&self, entity: &str, id: Uuid) -> Option { self.records.get(&(entity.to_string(), id)).cloned() } fn seed( &mut self, entity: &str, data: serde_json::Map, ) -> Result { let id = Uuid::new_v4(); self.records .insert((entity.to_string(), id), Value::Object(data)); Ok(WriteOutcome { id: Some(id), changed: 1, post_status: None, }) } fn update( &mut self, entity: &str, id: Uuid, set: serde_json::Map, clear: Vec, ) -> Result { if set.is_empty() && clear.is_empty() { return Ok(WriteOutcome::no_change(id)); } let rec = self .records .get_mut(&(entity.to_string(), id)) .ok_or_else(|| format!("not found: {entity}/{id}"))?; let map = rec .as_object_mut() .ok_or_else(|| format!("not an object: {entity}/{id}"))?; let changed = set.len() + clear.len(); for (k, v) in set { map.insert(k, v); } for k in clear { map.remove(&k); } Ok(WriteOutcome { id: Some(id), changed, post_status: None, }) } fn delete(&mut self, entity: &str, id: Uuid) -> Result { self.records .remove(&(entity.to_string(), id)) .ok_or_else(|| format!("not found: {entity}/{id}"))?; Ok(WriteOutcome { id: Some(id), changed: 1, post_status: None, }) } fn morphism( &mut self, _module_id: &str, name: &str, _inputs: BTreeMap, _params: Value, ) -> Result { Err(format!("MemBackend no soporta morphism '{name}'")) } } fn map_of(items: &[(&str, Value)]) -> serde_json::Map { items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() } #[test] fn seed_then_load_round_trip() { let mut b = MemBackend::default(); let out = b .seed("Customer", map_of(&[("name", json!("Acme"))])) .unwrap(); let id = out.id.expect("seed devuelve id"); assert_eq!(out.changed, 1); assert!(out.post_status.is_none()); let rec = b.load_record("Customer", id).unwrap(); assert_eq!(rec.get("name"), Some(&json!("Acme"))); } #[test] fn list_records_filters_by_entity_and_orders_stably() { let mut b = MemBackend::default(); let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap(); let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap(); let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap(); let a = b.list_records("A"); assert_eq!(a.len(), 2); let b_only = b.list_records("B"); assert_eq!(b_only.len(), 1); let none = b.list_records("Missing"); assert!(none.is_empty()); // Orden estable: re-llamadas devuelven mismo orden. let a_again = b.list_records("A"); assert_eq!( a.iter().map(|(id, _)| *id).collect::>(), a_again.iter().map(|(id, _)| *id).collect::>(), ); } #[test] fn update_with_set_changes_field() { let mut b = MemBackend::default(); let id = b .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))])) .unwrap() .id .unwrap(); let out = b .update( "Customer", id, map_of(&[("name", json!("Acme S.A."))]), vec![], ) .unwrap(); assert_eq!(out.changed, 1); assert_eq!(out.id, Some(id)); let rec = b.load_record("Customer", id).unwrap(); assert_eq!(rec.get("name"), Some(&json!("Acme S.A."))); assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto"); } #[test] fn update_with_clear_removes_key() { let mut b = MemBackend::default(); let id = b .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))])) .unwrap() .id .unwrap(); let out = b .update("Customer", id, serde_json::Map::new(), vec!["notes".into()]) .unwrap(); assert_eq!(out.changed, 1); let rec = b.load_record("Customer", id).unwrap(); assert_eq!(rec.get("name"), Some(&json!("Acme"))); assert!(rec.get("notes").is_none(), "notes debería estar borrado"); } #[test] fn update_with_empty_set_and_clear_returns_no_change() { let mut b = MemBackend::default(); let id = b .seed("Customer", map_of(&[("name", json!("Acme"))])) .unwrap() .id .unwrap(); let out = b .update("Customer", id, serde_json::Map::new(), vec![]) .unwrap(); assert_eq!(out, WriteOutcome::no_change(id)); } #[test] fn update_on_missing_record_errors() { let mut b = MemBackend::default(); let id = Uuid::new_v4(); let err = b .update("Customer", id, map_of(&[("x", json!(1))]), vec![]) .unwrap_err(); assert!(err.contains("not found")); } #[test] fn delete_removes_and_then_load_returns_none() { let mut b = MemBackend::default(); let id = b .seed("Customer", map_of(&[("name", json!("Acme"))])) .unwrap() .id .unwrap(); let out = b.delete("Customer", id).unwrap(); assert_eq!(out.changed, 1); assert_eq!(out.id, Some(id)); assert!(b.load_record("Customer", id).is_none()); } #[test] fn delete_on_missing_record_errors() { let mut b = MemBackend::default(); let id = Uuid::new_v4(); assert!(b.delete("Customer", id).is_err()); } /// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend` /// (object-safety). Esto permite que el widget tenga /// `Box` si el use case requiere borrado de /// tipo (vs. el path normal con `MetaApp`). #[test] fn trait_is_object_safe() { let mut b: Box = Box::new(MemBackend::default()); let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap(); assert_eq!(b.list_records("X").len(), 1); } }