⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f
crates/modules/nahual/libs/meta-runtime/src/backend.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
1//! `MetaBackend` trait — la frontera entre el widget metainterfaz
2//! (nahual) 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 (nahual) 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/// (nahual-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 [`crate::testing::MockBackend`]. Verifican
130 //! el contrato genérico (object-safety, semantica de seed/update/
131 //! delete) sin atar a un backend concreto. Los tests del mock en
132 //! sí (constructores, with_morphism, etc.) viven en
133 //! `crate::testing::tests`.
134
135 use super::*;
136 use crate::testing::MockBackend;
137 use serde_json::json;
138
139 fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
140 items
141 .iter()
142 .map(|(k, v)| (k.to_string(), v.clone()))
143 .collect()
144 }
145
146 #[test]
147 fn seed_then_load_round_trip() {
148 let mut b = MockBackend::new();
149 let out = b
150 .seed("Customer", map_of(&[("name", json!("Acme"))]))
151 .unwrap();
152 let id = out.id.expect("seed devuelve id");
153 assert_eq!(out.changed, 1);
154 assert!(out.post_status.is_none());
155
156 let rec = b.load_record("Customer", id).unwrap();
157 assert_eq!(rec.get("name"), Some(&json!("Acme")));
158 }
159
160 #[test]
161 fn list_records_filters_by_entity_and_orders_stably() {
162 let mut b = MockBackend::new();
163 let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
164 let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
165 let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
166
167 let a = b.list_records("A");
168 assert_eq!(a.len(), 2);
169 let b_only = b.list_records("B");
170 assert_eq!(b_only.len(), 1);
171 let none = b.list_records("Missing");
172 assert!(none.is_empty());
173
174 // Orden estable: re-llamadas devuelven mismo orden.
175 let a_again = b.list_records("A");
176 assert_eq!(
177 a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
178 a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
179 );
180 }
181
182 #[test]
183 fn update_with_set_changes_field() {
184 let mut b = MockBackend::new();
185 let id = b
186 .seed(
187 "Customer",
188 map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
189 )
190 .unwrap()
191 .id
192 .unwrap();
193
194 let out = b
195 .update(
196 "Customer",
197 id,
198 map_of(&[("name", json!("Acme S.A."))]),
199 vec![],
200 )
201 .unwrap();
202 assert_eq!(out.changed, 1);
203 assert_eq!(out.id, Some(id));
204
205 let rec = b.load_record("Customer", id).unwrap();
206 assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
207 assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
208 }
209
210 #[test]
211 fn update_with_clear_removes_key() {
212 let mut b = MockBackend::new();
213 let id = b
214 .seed(
215 "Customer",
216 map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
217 )
218 .unwrap()
219 .id
220 .unwrap();
221
222 let out = b
223 .update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
224 .unwrap();
225 assert_eq!(out.changed, 1);
226
227 let rec = b.load_record("Customer", id).unwrap();
228 assert_eq!(rec.get("name"), Some(&json!("Acme")));
229 assert!(rec.get("notes").is_none(), "notes debería estar borrado");
230 }
231
232 #[test]
233 fn update_with_empty_set_and_clear_returns_no_change() {
234 let mut b = MockBackend::new();
235 let id = b
236 .seed("Customer", map_of(&[("name", json!("Acme"))]))
237 .unwrap()
238 .id
239 .unwrap();
240
241 let out = b
242 .update("Customer", id, serde_json::Map::new(), vec![])
243 .unwrap();
244 assert_eq!(out, WriteOutcome::no_change(id));
245 }
246
247 #[test]
248 fn update_on_missing_record_errors() {
249 let mut b = MockBackend::new();
250 let id = Uuid::new_v4();
251 let err = b
252 .update("Customer", id, map_of(&[("x", json!(1))]), vec![])
253 .unwrap_err();
254 assert!(err.contains("not found"));
255 }
256
257 #[test]
258 fn delete_removes_and_then_load_returns_none() {
259 let mut b = MockBackend::new();
260 let id = b
261 .seed("Customer", map_of(&[("name", json!("Acme"))]))
262 .unwrap()
263 .id
264 .unwrap();
265 let out = b.delete("Customer", id).unwrap();
266 assert_eq!(out.changed, 1);
267 assert_eq!(out.id, Some(id));
268 assert!(b.load_record("Customer", id).is_none());
269 }
270
271 #[test]
272 fn delete_on_missing_record_errors() {
273 let mut b = MockBackend::new();
274 let id = Uuid::new_v4();
275 assert!(b.delete("Customer", id).is_err());
276 }
277
278 /// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
279 /// (object-safety). Esto permite que el widget tenga
280 /// `Box<dyn MetaBackend>` si el use case requiere borrado de
281 /// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
282 #[test]
283 fn trait_is_object_safe() {
284 let mut b: Box<dyn MetaBackend> = Box::new(MockBackend::new());
285 let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
286 assert_eq!(b.list_records("X").len(), 1);
287 }
288}