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 `. ## 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(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 = 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( + 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!({