gioser

tawasuyu/brahman

Rust 472TOML 187Markdown 49JSON 176515 Definitions

⎇ d5ef7144b5a1d2e6604505cb4dc08e7a703ecc72 crates/modules/ui_engine/libs/meta-runtime/src/backend.rs Rust · 368 lines · 12.4 KB History Who touched what raw

refactor(yahweh): Fase 2b — MetaBackend trait + NakuiBackend + MetaUi consume el backend 2462aca44 · Sergio ·

1//! `MetaBackend` trait — la frontera entre el widget metainterfaz
2//! (yahweh) y la implementación concreta de persistencia/ejecución
3//! (nakui-core, Surreal, mocks para tests).
4//!
5//! El widget consume este trait; el binario lo implementa con su
6//! stack particular. Esto es lo que hace que el widget sea reusable.
7//!
8//! Convenciones documentadas en el doc del trait abajo.
9
10use std::collections::BTreeMap;
11
12use serde_json::Value;
13use uuid::Uuid;
14
15/// Resultado uniforme de una operación de escritura del backend.
16///
17/// La UI lo usa para componer el toast: `id` para mostrar el
18/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)"
19/// vs "sin cambios", `post_status` para concatenar mensajes
20/// emitidos por hooks internos del backend (ej. "auto-compact:
21/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct WriteOutcome {
24    /// Id del record afectado. `Some` para seed/update/delete;
25    /// `None` para morphism cuando afecta múltiples records.
26    pub id: Option<Uuid>,
27    /// Cantidad de cambios efectivos. `0` = no-op (edit que no
28    /// modificó ningún campo, etc.).
29    pub changed: usize,
30    /// Mensaje de status opcional para concatenar al toast del op
31    /// original con el separator estándar.
32    pub post_status: Option<String>,
33}
34
35impl WriteOutcome {
36    /// Constructor para no-op writes (edits sin cambios).
37    pub fn no_change(id: Uuid) -> Self {
38        Self {
39            id: Some(id),
40            changed: 0,
41            post_status: None,
42        }
43    }
44}
45
46/// Backend que un widget de metainterfaz usa para leer y mutar
47/// records. Decoupla el widget (yahweh) de la implementación
48/// concreta (nakui-core, Surreal, mock para tests).
49///
50/// # Convención sobre ids
51///
52/// `Uuid` canónico. Backends que internamente usan otros tipos
53/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva).
54/// Esto evita generic associated types que complicarían el dispatch
55/// en `cx.listener` de GPUI.
56///
57/// # Convención sobre validación
58///
59/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel
60/// post-checks, conservación, etc.). El widget pre-valida shape
61/// (yahweh-meta-runtime: `parse_field_value`, `validate_entity_refs`)
62/// pero el backend puede rebotar con `Err(...)` si su validación
63/// adicional falla — el widget muestra el error al usuario.
64///
65/// # Convención sobre threading
66///
67/// `'static` (no `Send + Sync`): el widget vive en `Entity<MetaApp<B>>`
68/// que requiere `'static`, pero los handlers son single-threaded en
69/// el main UI thread de GPUI. Si en el futuro un backend necesita
70/// `cx.spawn`, agregamos los marker traits.
71///
72/// # Convención sobre delta computation
73///
74/// El widget pre-computa `set` y `clear` con
75/// [`crate::delta::compute_field_delta`] +
76/// [`crate::delta::compute_clear_fields`] *antes* de llamar a
77/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos
78/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita
79/// double-roundtrip al store por el mismo dato.
80pub trait MetaBackend: 'static {
81    /// Snapshot ordenado de records de una entity.
82    /// Orden estable (lexicográfico por id) para UI determinista.
83    /// Vacío si no hay records.
84    fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>;
85
86    /// Lee un record por id. `None` si no existe.
87    fn load_record(&self, entity: &str, id: Uuid) -> Option<Value>;
88
89    /// Crea un record nuevo. El backend asigna el `Uuid`
90    /// (devuelve en `WriteOutcome.id`). `changed = 1` siempre.
91    fn seed(
92        &mut self,
93        entity: &str,
94        data: serde_json::Map<String, Value>,
95    ) -> Result<WriteOutcome, String>;
96
97    /// Edita un record existente. Aplica `set` (overrides) y
98    /// `clear` (key removal). `changed = set.len() + clear.len()`.
99    /// Si ambos están vacíos (no-op edit), devuelve
100    /// `WriteOutcome::no_change(id)` sin error y sin escribir al log.
101    fn update(
102        &mut self,
103        entity: &str,
104        id: Uuid,
105        set: serde_json::Map<String, Value>,
106        clear: Vec<String>,
107    ) -> Result<WriteOutcome, String>;
108
109    /// Borra un record. `changed = 1` si existía, error si no.
110    fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
111
112    /// Ejecuta un morphism declarado por un módulo. El backend
113    /// resuelve la implementación, valida, computa ops, las aplica.
114    /// `changed = N ops aplicadas`.
115    ///
116    /// `module_id` ubica al módulo (el trait no asume estructura del
117    /// manifest — el backend lo resuelve internamente).
118    fn morphism(
119        &mut self,
120        module_id: &str,
121        name: &str,
122        inputs: BTreeMap<String, Uuid>,
123        params: Value,
124    ) -> Result<WriteOutcome, String>;
125}
126
127#[cfg(test)]
128mod tests {
129    //! Tests del trait via un `MemBackend` mínimo (HashMap por
130    //! `(entity, uuid)`). Verifica el contrato del trait sin atar
131    //! a un backend concreto.
132
133    use super::*;
134    use serde_json::json;
135    use std::collections::HashMap;
136
137    /// Mock backend in-memory. NO soporta morphisms (devuelve error
138    /// inmediato) — un mock para tests del trait, no para uso real.
139    #[derive(Default)]
140    struct MemBackend {
141        records: HashMap<(String, Uuid), Value>,
142    }
143
144    impl MetaBackend for MemBackend {
145        fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
146            let mut out: Vec<(Uuid, Value)> = self
147                .records
148                .iter()
149                .filter(|((e, _), _)| e == entity)
150                .map(|((_, id), v)| (*id, v.clone()))
151                .collect();
152            out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
153            out
154        }
155
156        fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
157            self.records.get(&(entity.to_string(), id)).cloned()
158        }
159
160        fn seed(
161            &mut self,
162            entity: &str,
163            data: serde_json::Map<String, Value>,
164        ) -> Result<WriteOutcome, String> {
165            let id = Uuid::new_v4();
166            self.records
167                .insert((entity.to_string(), id), Value::Object(data));
168            Ok(WriteOutcome {
169                id: Some(id),
170                changed: 1,
171                post_status: None,
172            })
173        }
174
175        fn update(
176            &mut self,
177            entity: &str,
178            id: Uuid,
179            set: serde_json::Map<String, Value>,
180            clear: Vec<String>,
181        ) -> Result<WriteOutcome, String> {
182            if set.is_empty() && clear.is_empty() {
183                return Ok(WriteOutcome::no_change(id));
184            }
185            let rec = self
186                .records
187                .get_mut(&(entity.to_string(), id))
188                .ok_or_else(|| format!("not found: {entity}/{id}"))?;
189            let map = rec
190                .as_object_mut()
191                .ok_or_else(|| format!("not an object: {entity}/{id}"))?;
192            let changed = set.len() + clear.len();
193            for (k, v) in set {
194                map.insert(k, v);
195            }
196            for k in clear {
197                map.remove(&k);
198            }
199            Ok(WriteOutcome {
200                id: Some(id),
201                changed,
202                post_status: None,
203            })
204        }
205
206        fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
207            self.records
208                .remove(&(entity.to_string(), id))
209                .ok_or_else(|| format!("not found: {entity}/{id}"))?;
210            Ok(WriteOutcome {
211                id: Some(id),
212                changed: 1,
213                post_status: None,
214            })
215        }
216
217        fn morphism(
218            &mut self,
219            _module_id: &str,
220            name: &str,
221            _inputs: BTreeMap<String, Uuid>,
222            _params: Value,
223        ) -> Result<WriteOutcome, String> {
224            Err(format!("MemBackend no soporta morphism '{name}'"))
225        }
226    }
227
228    fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
229        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
230    }
231
232    #[test]
233    fn seed_then_load_round_trip() {
234        let mut b = MemBackend::default();
235        let out = b
236            .seed("Customer", map_of(&[("name", json!("Acme"))]))
237            .unwrap();
238        let id = out.id.expect("seed devuelve id");
239        assert_eq!(out.changed, 1);
240        assert!(out.post_status.is_none());
241
242        let rec = b.load_record("Customer", id).unwrap();
243        assert_eq!(rec.get("name"), Some(&json!("Acme")));
244    }
245
246    #[test]
247    fn list_records_filters_by_entity_and_orders_stably() {
248        let mut b = MemBackend::default();
249        let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
250        let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
251        let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
252
253        let a = b.list_records("A");
254        assert_eq!(a.len(), 2);
255        let b_only = b.list_records("B");
256        assert_eq!(b_only.len(), 1);
257        let none = b.list_records("Missing");
258        assert!(none.is_empty());
259
260        // Orden estable: re-llamadas devuelven mismo orden.
261        let a_again = b.list_records("A");
262        assert_eq!(
263            a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
264            a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
265        );
266    }
267
268    #[test]
269    fn update_with_set_changes_field() {
270        let mut b = MemBackend::default();
271        let id = b
272            .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
273            .unwrap()
274            .id
275            .unwrap();
276
277        let out = b
278            .update(
279                "Customer",
280                id,
281                map_of(&[("name", json!("Acme S.A."))]),
282                vec![],
283            )
284            .unwrap();
285        assert_eq!(out.changed, 1);
286        assert_eq!(out.id, Some(id));
287
288        let rec = b.load_record("Customer", id).unwrap();
289        assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
290        assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
291    }
292
293    #[test]
294    fn update_with_clear_removes_key() {
295        let mut b = MemBackend::default();
296        let id = b
297            .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
298            .unwrap()
299            .id
300            .unwrap();
301
302        let out = b
303            .update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
304            .unwrap();
305        assert_eq!(out.changed, 1);
306
307        let rec = b.load_record("Customer", id).unwrap();
308        assert_eq!(rec.get("name"), Some(&json!("Acme")));
309        assert!(rec.get("notes").is_none(), "notes debería estar borrado");
310    }
311
312    #[test]
313    fn update_with_empty_set_and_clear_returns_no_change() {
314        let mut b = MemBackend::default();
315        let id = b
316            .seed("Customer", map_of(&[("name", json!("Acme"))]))
317            .unwrap()
318            .id
319            .unwrap();
320
321        let out = b
322            .update("Customer", id, serde_json::Map::new(), vec![])
323            .unwrap();
324        assert_eq!(out, WriteOutcome::no_change(id));
325    }
326
327    #[test]
328    fn update_on_missing_record_errors() {
329        let mut b = MemBackend::default();
330        let id = Uuid::new_v4();
331        let err = b
332            .update("Customer", id, map_of(&[("x", json!(1))]), vec![])
333            .unwrap_err();
334        assert!(err.contains("not found"));
335    }
336
337    #[test]
338    fn delete_removes_and_then_load_returns_none() {
339        let mut b = MemBackend::default();
340        let id = b
341            .seed("Customer", map_of(&[("name", json!("Acme"))]))
342            .unwrap()
343            .id
344            .unwrap();
345        let out = b.delete("Customer", id).unwrap();
346        assert_eq!(out.changed, 1);
347        assert_eq!(out.id, Some(id));
348        assert!(b.load_record("Customer", id).is_none());
349    }
350
351    #[test]
352    fn delete_on_missing_record_errors() {
353        let mut b = MemBackend::default();
354        let id = Uuid::new_v4();
355        assert!(b.delete("Customer", id).is_err());
356    }
357
358    /// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
359    /// (object-safety). Esto permite que el widget tenga
360    /// `Box<dyn MetaBackend>` si el use case requiere borrado de
361    /// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
362    #[test]
363    fn trait_is_object_safe() {
364        let mut b: Box<dyn MetaBackend> = Box::new(MemBackend::default());
365        let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
366        assert_eq!(b.list_records("X").len(), 1);
367    }
368}