gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

feat(nakui-ui): validación cross-field del EntityRef (existence en store)

parse_field_value(EntityRef) sólo validaba forma (UUID parseable).
Un UUID válido pero inexistente pasaba al log/store, dejando
dangling references. Ahora validamos también existencia contra la
entity declarada en FieldSpec.ref_entity.

- Nuevo helper validate_entity_refs<S: Store>(store, refs):
  fail-fast loop sobre (label, target_entity, uuid) tuples; primer
  record ausente → error con label legible + UUID corto.
- commit_seed: durante el parse loop encolamos cada EntityRef +
  ref_entity + UUID parseado. Después del loop, una sola toma del
  store lock valida todos. Falla early: ningún log entry.
- Cobertura: SEED + EDIT. Morphism inputs ya cubierto por
  Executor::compute (load + EntityMissing) — documentado en el doc
  del helper.

5 tests nuevos del helper: happy path, fail-fast con detalles,
label en msg, lista vacía, distingue target entity.

45 tests verdes en nakui-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

ffdfa6f8d77eebf58123228cc8ccf40f7599207a · by Sergio · (5 months ago) · parent f0c0a7186 · browse the tree at this commit

Semantic delta · 7

2 files · +181 −0

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26c96f3..92ff428 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,71 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(nakui-ui): validación cross-field del EntityRef (existence en store)
+Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
+sólo validaba **forma** (UUID parseable + trim de whitespace) — un
+UUID válido pero inexistente en el store pasaba silenciosamente al
+log/store, dejando dangling references. Ahora validamos también
+**existencia** contra la entity declarada en `FieldSpec.ref_entity`.
+
+Cambios:
+- **Nuevo helper `validate_entity_refs<S: Store>(store, refs)`**:
+  - `refs: &[(label, target_entity, uuid)]`.
+  - Loop fail-fast: primer record ausente → error con label
+    legible + UUID corto + target entity en el msg
+    `"campo 'Stock': record abc12345 de 'Stock' no existe en el store"`.
+  - Pure (toma `&S: Store`), totalmente testable sin GPUI.
+- **Wireup en `commit_seed`**:
+  - Durante el parse loop, cuando un field es EntityRef + tiene
+    `ref_entity` declarado + value parseado a UUID, lo encolamos
+    en `entity_refs: Vec<(String, String, Uuid)>`.
+  - Después del parse loop (antes del seed/edit branch), si
+    `entity_refs` no está vacío, una sola toma del store lock
+    para validar todos via el helper.
+  - Falla early: ningún log entry, ningún apply.
+- **Cobertura**:
+  - SEED path: alta nueva con EntityRef → validamos antes de
+    `Seed { data }`.
+  - EDIT path: edit con EntityRef → validamos antes de calcular
+    delta. Una optional empty (que iría a clear) no cuenta como
+    EntityRef (raw vacío skipea el push).
+  - Morphism inputs: NO se duplica acá. `Executor::compute` ya
+    valida cada input via `store.load(...).ok_or(EntityMissing)`
+    antes de correr el script Rhai. Documentado en el doc del
+    helper.
+
+5 tests nuevos:
+- `validate_entity_refs_passes_when_all_records_exist` — happy path.
+- `validate_entity_refs_fails_on_first_missing` — fail-fast con
+  msg que incluye entity + UUID corto.
+- `validate_entity_refs_uses_label_not_entity_in_msg` — el label
+  legible (ej: "Stock origen") aparece en el error, no la entity
+  desnuda.
+- `validate_entity_refs_empty_list_is_ok` — lista vacía es Ok.
+- `validate_entity_refs_distinguishes_target_from_other_entities` —
+  un UUID que existe bajo Customer pero NO Stock falla la
+  validación contra Stock.
+
+45 tests verdes en nakui-ui (+5). Workspace build verde.
+
+Comportamiento esperado:
+- **Selector clickable es happy path**: el dropdown sólo lista
+  records existentes, así que clickearlo nunca debería disparar
+  el error. Sólo dispara con paste manual de UUID que no existe
+  o records borrados después de la selección (timing race).
+- **Optional empty no se valida**: si el field es EntityRef
+  optional y el form lo deja vacío, lo manejamos como "no value"
+  (skipea el push a `entity_refs`); la lógica de Clear se
+  encarga del resto.
+- **Lock contention**: una sola toma del store lock por
+  `commit_seed`, no una por field. La validación es O(refs) reads.
+
+Pendientes restantes:
+- **Validación KCL del record post-edit** antes de emitir Set/Clear
+  (hoy `ui.edit_record` no pasa por `Executor::compute`).
+- **EntityRef cross-module** (referenciar records de OTRO módulo
+  por nombre, no sólo por entity local).
+
 ### feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío
 Cierra el último pendiente de UX del round. El edit no podía
 "borrar" un value vaciando el input — empty optional fields
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 0e45482..0df095a 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -705,6 +705,10 @@ impl MetaUi {
         // current store value tiene algo en ese key). En el SEED path
         // simplemente no se incluyen, igual que antes.
         let mut to_clear: Vec<String> = Vec::new();
+        // EntityRef references a chequear existencia DESPUÉS del parse
+        // loop (necesitan lock del store; lo tomamos una sola vez).
+        // Tuple: (label legible, target entity, parsed UUID).
+        let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
         for f in &spec_fields {
             let raw = self
                 .form_inputs
@@ -720,8 +724,28 @@ impl MetaUi {
             }
             let value = parse_field_value(f.kind, &raw)
                 .map_err(|e| format!("campo '{}': {e}", f.label))?;
+            // Si el field es EntityRef y declara ref_entity, encolamos
+            // el (label, target, uuid) para validar existence en lote.
+            // El UUID ya está bien-formado (parse_field_value lo
+            // validó); ahora chequeamos que el record exista.
+            if f.kind == FieldKind::EntityRef {
+                if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
+                    let id = Uuid::parse_str(uuid_str)
+                        .expect("parse_field_value validated UUID");
+                    entity_refs.push((f.label.clone(), target.clone(), id));
+                }
+            }
             obj.insert(f.name.clone(), value);
         }
+        // Validar EntityRefs contra el store actual. Una sola toma del
+        // lock para todas las refs.
+        if !entity_refs.is_empty() {
+            let store = self
+                .store
+                .lock()
+                .map_err(|_| "store mutex envenenado".to_string())?;
+            validate_entity_refs(&*store, &entity_refs)?;
+        }
         // Ramificación: si `editing` está set para esta entity, es un
         // edit de un record existente — emitimos Morphism con un
         // FieldOp::Set por cada campo del form (sobreescribe). Si no,
@@ -941,6 +965,33 @@ fn maybe_compact_log(
     )))
 }
 
+/// Valida que cada UUID en `refs` apunte a un record que realmente
+/// existe en el store bajo la entity esperada. Devuelve el primer
+/// error encontrado (fail-fast).
+///
+/// `refs` es una lista de `(label, target_entity, uuid)`. El label
+/// va al error message, así que conviene que sea legible (ej:
+/// `FieldSpec.label` en lugar de `FieldSpec.name`).
+///
+/// Sólo se llama desde el SEED path de la UI. Los inputs de morphism
+/// no necesitan este check porque `Executor::compute` ya valida cada
+/// input via `store.load(...).ok_or(EntityMissing)` antes de correr
+/// el script Rhai.
+fn validate_entity_refs<S: Store>(
+    store: &S,
+    refs: &[(String, String, Uuid)],
+) -> Result<(), String> {
+    for (label, target, id) in refs {
+        if store.load(target, *id).is_none() {
+            return Err(format!(
+                "campo '{label}': record {} de '{target}' no existe en el store",
+                short_uuid(id)
+            ));
+        }
+    }
+    Ok(())
+}
+
 /// Decide cuáles fields del `to_clear` candidate list ameritan
 /// realmente un `FieldOp::Clear`: sólo los que existen en el current
 /// con un valor non-null. Para fields ausentes o ya null, Clear es
@@ -2059,6 +2110,71 @@ mod tests {
         let _ = std::fs::remove_file(&snap_path);
     }
 
+    #[test]
+    fn validate_entity_refs_passes_when_all_records_exist() {
+        let mut store = MemoryStore::new();
+        let stock_id = Uuid::new_v4();
+        let caja_id = Uuid::new_v4();
+        store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
+        store.seed("Caja", caja_id, json!({"name": "Principal"}));
+        let refs = vec![
+            ("Stock".into(), "Stock".into(), stock_id),
+            ("Caja".into(), "Caja".into(), caja_id),
+        ];
+        assert!(validate_entity_refs(&store, &refs).is_ok());
+    }
+
+    #[test]
+    fn validate_entity_refs_fails_on_first_missing() {
+        let mut store = MemoryStore::new();
+        let stock_id = Uuid::new_v4();
+        store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
+        let missing_caja = Uuid::new_v4();
+        let refs = vec![
+            ("Stock".into(), "Stock".into(), stock_id),
+            ("Caja".into(), "Caja".into(), missing_caja),
+        ];
+        let err = validate_entity_refs(&store, &refs).unwrap_err();
+        assert!(err.contains("Caja"), "msg debe nombrar la entity: {err}");
+        assert!(
+            err.contains(&short_uuid(&missing_caja)),
+            "msg debe incluir el UUID corto: {err}"
+        );
+    }
+
+    #[test]
+    fn validate_entity_refs_uses_label_not_entity_in_msg() {
+        // Si el FieldSpec.label es distinto de la entity (ej:
+        // "Stock origen" en lugar de "Stock"), el error debería usar
+        // el label legible.
+        let store = MemoryStore::new();
+        let id = Uuid::new_v4();
+        let refs = vec![("Stock origen".into(), "Stock".into(), id)];
+        let err = validate_entity_refs(&store, &refs).unwrap_err();
+        assert!(
+            err.contains("Stock origen"),
+            "msg debe incluir el label: {err}"
+        );
+    }
+
+    #[test]
+    fn validate_entity_refs_empty_list_is_ok() {
+        let store = MemoryStore::new();
+        assert!(validate_entity_refs(&store, &[]).is_ok());
+    }
+
+    #[test]
+    fn validate_entity_refs_distinguishes_target_from_other_entities() {
+        // Sanity: un UUID que existe bajo entity X pero NO bajo Y
+        // debería fallar la validación contra Y.
+        let mut store = MemoryStore::new();
+        let id = Uuid::new_v4();
+        store.seed("Customer", id, json!({"name": "Acme"}));
+        // Mismo UUID, target distinto.
+        let refs = vec![("Stock".into(), "Stock".into(), id)];
+        assert!(validate_entity_refs(&store, &refs).is_err());
+    }
+
     #[test]
     fn clear_fields_skips_absent_and_null() {
         let current = json!({