refactor(yahweh): Fase 2 — extraer helpers puros a yahweh-meta-runtime

Sigue de la Fase 1 (lift del schema). Ahora los helpers puros que
cualquier widget renderer o backend ejecutor consume sobre el schema
viven en yahweh-meta-runtime. Sin GPUI, sin nakui — usa cierres en
lugar de traits para decoupling máximo.

Crate nuevo crates/modules/ui_engine/libs/meta-runtime:
- parse.rs: parse_field_value, infer_param_value, resolve_param_value.
- delta.rs: compute_field_delta, compute_clear_fields.
- refs.rs: validate_entity_refs(load: F, refs) con cierre
  Fn(&str, Uuid) -> Option<Value> en vez de trait Store.
- format.rs: human_label_for_record, render_value, value_to_input_text,
  short_uuid.
- 33 tests propios.

nakui-ui:
- Nueva dep yahweh-meta-runtime.
- Borrado código local equivalente (~200 líneas) + 34 tests
  duplicados.
- validate_entity_refs callsite usa cierre:
  validate_entity_refs(|e, id| store.load(e, id), &refs).
- 14 tests runtime-específicos quedan (compact/snapshot/event-log/
  morphism pipeline/load_ui_modules).

Distribución tests: 48 → 14 nakui-ui; +33 yahweh-meta-runtime.
Cada crate afectado builds + tests limpio individualmente. Workspace
build full no completó esta corrida por OOM al compilar
surrealdb-core (ambiental, no relacionado).

Fase 2b pendiente: extraer render widgets (form/list/modal/
EntityRef selector) a yahweh — requiere diseñar MetaBackend trait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-10 01:17:17 +00:00
parent f5987d9cfc
commit 6104484498
11 changed files with 755 additions and 592 deletions
@@ -0,0 +1,134 @@
//! Cálculo del delta entre el record actual y la propuesta del form.
//!
//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
//! cambian algo: log + apply minimales, no-op edits = 0 entries.
use serde_json::Value;
/// Calcula el delta entre el record actual y los valores propuestos
/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
///
/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
/// `current=Value::Null` (record no encontrado) hace que todos los
/// campos del `proposed` sean considerados nuevos. Un campo del
/// proposed que coincide con el del current se omite. Campos que
/// están en current pero NO en proposed se preservan tal cual (el
/// edit no los toca; ver [`compute_clear_fields`] para borrar
/// explícito desde un input vacío).
pub fn compute_field_delta(
current: &Value,
proposed: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
proposed
.iter()
.filter(|(field, value)| current.get(field.as_str()) != Some(*value))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
/// 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
/// no-op semántico (el post-state es el mismo) y dropearlos
/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
///
/// Preserva el orden del input para que el log entry sea estable.
pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
to_clear
.iter()
.filter(|f| match current.get(f.as_str()) {
None | Some(Value::Null) => false,
Some(_) => true,
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn delta_empty_when_all_fields_match() {
let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
let proposed = map(&[
("name", json!("Acme")),
("saldo", json!(100_i64)),
("currency", json!("USD")),
]);
assert!(compute_field_delta(&current, &proposed).is_empty());
}
#[test]
fn delta_includes_only_changed_field() {
let current = json!({"name": "Acme", "saldo": 100_i64});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
}
#[test]
fn delta_treats_missing_record_as_all_new() {
let current = Value::Null;
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
}
#[test]
fn delta_distinguishes_int_from_string_repr() {
let current = json!({"qty": 100_i64});
let proposed = map(&[("qty", json!(100_i64))]);
assert!(compute_field_delta(&current, &proposed).is_empty());
let current_str = json!({"qty": "100"});
let proposed_int = map(&[("qty", json!(100_i64))]);
assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
}
#[test]
fn delta_skips_fields_absent_from_proposed() {
let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert!(!d.contains_key("extra"));
}
#[test]
fn clear_fields_skips_absent_and_null() {
let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
let to_clear = vec![
"name".into(),
"notes".into(),
"tag".into(),
"missing".into(),
];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["name".to_string(), "notes".to_string()]
);
}
#[test]
fn clear_fields_preserves_input_order() {
let current = json!({"a": 1, "b": 2, "c": 3});
let to_clear = vec!["c".into(), "a".into(), "b".into()];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["c", "a", "b"]
);
}
#[test]
fn clear_fields_empty_when_current_is_null() {
let current = Value::Null;
let to_clear = vec!["name".into()];
assert!(compute_clear_fields(&current, &to_clear).is_empty());
}
}