gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

feat(nakui-ui): edit delta-only — sólo campos modificados al log/store

Antes: editar un record emitía Set por *todos* los fields del form,
incluso los no tocados. Bloataba el log y oscurecía el intent. Ahora:

- Nuevo helper `compute_field_delta(current, proposed)` — devuelve
  sólo las entries que difieren (PartialEq de Value).
- Nuevo enum `CommitOutcome { Created, Updated{changed}, NoChange }`
  para que el toast sea preciso ("actualizado X (2 campo(s))" vs
  "sin cambios — no log entry").
- `commit_seed` en path EDIT carga current del store, calcula delta,
  return early si vacío (no log entry, no apply). Si no vacío emite
  `Morphism{ ui.edit_record, params.fields=delta }` con sólo los
  campos modificados.

5 tests nuevos del helper: delta vacío, sólo campo cambiado, current
Null = todo nuevo, int vs string, ignora fields ausentes del proposed.

27 tests verdes. SEED path inalterado, E2E del morphism real verde.

Limitación: edit no puede *clearear* un value vaciando el input
(empty optional fields ya hacían `continue` antes del delta). Para
soportar eso haría falta `FieldOp::Clear`, no necesario hoy.

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

70f8c66548a71db363a4248627ae2c2bf62ca2de · by Sergio · (5 months ago) · parent fdb9bbe05 · browse the tree at this commit

Semantic delta · 11

2 files · +245 −17

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 102972f..1ca4098 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,64 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(nakui-ui): edit delta-only — sólo campos modificados al log/store
+Antes de este cambio, editar un record emitía un `FieldOp::Set` por
+**cada field del form**, incluso los no tocados. Eso bloata el log
+(replay tenía que aplicar N ops cuando 1 alcanzaba) y oscurece el
+intent en una auditoría posterior. Con delta-only, el edit emite
+sólo los Sets cuyo value nuevo difiere del actual; un edit que no
+cambia nada deja el log intacto.
+
+Cambios:
+- **Nuevo helper `compute_field_delta(current, proposed)`** — toma
+  el record actual del store (un `Value`, posible `Null` si el
+  record no existe) y el `Map` propuesto desde el form, y devuelve
+  sólo las entries que difieren. Comparación: `PartialEq` estructural
+  de `serde_json::Value` (un `Null` en current = todos los proposed
+  son nuevos).
+- **Nuevo enum `CommitOutcome`**:
+  - `Created(Uuid)` — alta nueva.
+  - `Updated { id, changed }` — edit con N campos modificados.
+  - `NoChange(Uuid)` — edit sin diferencias (el toast lo refleja
+    como "X sin cambios — no log entry").
+- **`commit_seed` en path EDIT**:
+  - Carga current via `store.load(entity, id)` con fallback a
+    `Value::Null`.
+  - Calcula delta. Si vacío → return early sin tocar log ni store.
+  - Si no vacío → emite `Morphism { ui.edit_record, ops: [Set...] }`
+    con `params.fields` reflejando el delta (no todo el form),
+    haciendo la auditoría grep-able por field cambiado.
+- **Toast del callsite**:
+  - `creado X uuid` (Created)
+  - `actualizado X uuid (N campo(s))` (Updated)
+  - `X uuid sin cambios — no log entry` (NoChange)
+- **`editing` se limpia incluso en NoChange** — el modo edit cierra,
+  el form vuelve al state limpio.
+
+5 tests nuevos del helper:
+- delta vacío cuando todo coincide.
+- delta sólo con el field cambiado.
+- delta full cuando current = Null (record no existe).
+- distingue int 100 de string "100".
+- ignora fields del current que no están en proposed.
+
+27 tests verdes (+5). El path SEED no cambió; el E2E del morphism
+real sigue verde.
+
+Limitación conocida (consistente con pre-delta): el form no puede
+**borrar** un value vaciando el input — empty optional fields hacen
+`continue` antes de llegar al delta. Para clearear un value hay que
+declarar el field como required, o esperar a un `FieldOp::Clear`
+futuro (no necesario hoy: ningún demo lo requiere).
+
+Pendientes restantes:
+- **Snapshot/compaction** del log (replay full cada startup escala
+  mal con repos grandes).
+- **EntityRef validation post-submit** — validar UUID parseable al
+  submit en lugar de al execute del morphism.
+- **Atajo Esc para Cancelar** del modal de delete.
+- **`FieldOp::Clear`** — para soportar borrar un value vía form.
+
 ### feat(nakui-ui): confirmación de delete vía banner modal antes de borrar
 Cierra el primer pending del último round: borrar un record pedía un
 solo click en `✕` y se ejecutaba inmediatamente (irreversible —
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 63b39bd..8cbc8ac 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -353,10 +353,6 @@ impl MetaUi {
             Some((i, _)) => *i,
             None => return,
         };
-        // Snapshot del editing al entrar — si commit_seed modifica
-        // self.editing antes del toast, el mensaje refleja el modo
-        // correcto.
-        let was_editing = self.editing.is_some();
         match action {
             Action::OpenView { view, .. } => {
                 // Salir a otra view cancela el edit pendiente.
@@ -365,14 +361,29 @@ impl MetaUi {
             }
             Action::SeedEntity { entity, next_view } => {
                 match self.commit_seed(mod_idx, &entity, cx) {
-                    Ok(id) => {
-                        let action_label = if was_editing { "actualizado" } else { "creado" };
-                        self.toast = Some(SharedString::from(format!(
-                            "{action_label} {entity} {}",
-                            short_uuid(&id)
-                        )));
+                    Ok(outcome) => {
+                        let id = outcome.id();
+                        let toast_msg = match &outcome {
+                            CommitOutcome::Created(_) => {
+                                format!("creado {entity} {}", short_uuid(&id))
+                            }
+                            CommitOutcome::Updated { changed, .. } => {
+                                format!(
+                                    "actualizado {entity} {} ({changed} campo(s))",
+                                    short_uuid(&id)
+                                )
+                            }
+                            CommitOutcome::NoChange(_) => {
+                                format!(
+                                    "{entity} {} sin cambios — no log entry",
+                                    short_uuid(&id)
+                                )
+                            }
+                        };
+                        self.toast = Some(SharedString::from(toast_msg));
                         // Limpia editing tras un commit exitoso —
-                        // el record ya está sincronizado.
+                        // el record ya está sincronizado (incluso
+                        // un NoChange cierra el modo edit).
                         self.editing = None;
                         if let Some(v) = next_view {
                             self.select_view(mod_idx, v, cx);
@@ -530,12 +541,14 @@ impl MetaUi {
     }
 
     /// Construye un Value desde los TextInput vivos y lo seedea al store.
+    /// Resultado de `commit_seed`. Distingue alta nueva vs edit
+    /// efectivo vs no-op para que el toast sea preciso.
     fn commit_seed(
         &mut self,
         mod_idx: usize,
         entity: &str,
         cx: &mut Context<Self>,
-    ) -> Result<Uuid, String> {
+    ) -> Result<CommitOutcome, String> {
         let module = &self.modules[mod_idx];
         let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
             Some((_, view_key)) => match module.views.get(view_key) {
@@ -568,8 +581,33 @@ impl MetaUi {
         let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
 
         if let Some((_, id)) = editing_match {
-            // EDIT path: Morphism { ui.edit_record, ops: [Set...] }
-            let ops: Vec<FieldOp> = obj
+            // EDIT path: delta-only. Cargar el record actual del store
+            // y emitir `FieldOp::Set` sólo para los campos cuyo valor
+            // nuevo difiere del actual. Si nada cambió, ningún log
+            // entry y ningún apply — el toast lo refleja.
+            //
+            // Nota: campos que el form deja vacíos *no* se incluyen
+            // en `obj` (skip arriba), así que no se pueden "limpiar"
+            // borrando el input. Esto es consistente con el comportamiento
+            // pre-delta y con el seed path. Para clearear hay que
+            // declarar el field como required y forzar un value, o
+            // implementar un FieldOp::Clear futuro.
+            let current: Value = {
+                let store = self
+                    .store
+                    .lock()
+                    .map_err(|_| "store mutex envenenado".to_string())?;
+                store.load(entity, id).unwrap_or(Value::Null)
+            };
+            let delta = compute_field_delta(&current, &obj);
+
+            if delta.is_empty() {
+                // No-op edit: no entry al log, no apply. Limpia
+                // editing en el caller via toast diferente.
+                return Ok(CommitOutcome::NoChange(id));
+            }
+
+            let ops: Vec<FieldOp> = delta
                 .iter()
                 .map(|(field, value)| FieldOp::Set {
                     path: FieldPath {
@@ -593,7 +631,7 @@ impl MetaUi {
                     params: json!({
                         "entity": entity,
                         "id": id.to_string(),
-                        "fields": Value::Object(obj.clone()),
+                        "fields": Value::Object(delta.clone()),
                     }),
                     ops: ops.clone(),
                     schema_hash: None,
@@ -605,7 +643,10 @@ impl MetaUi {
                 .lock()
                 .map_err(|_| "store mutex envenenado".to_string())?;
             store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
-            Ok(id)
+            Ok(CommitOutcome::Updated {
+                id,
+                changed: delta.len(),
+            })
         } else {
             // SEED path: alta nueva.
             let id = Uuid::new_v4();
@@ -629,7 +670,7 @@ impl MetaUi {
                 .lock()
                 .map_err(|_| "store mutex envenenado".to_string())?;
             store.seed(entity, id, data);
-            Ok(id)
+            Ok(CommitOutcome::Created(id))
         }
     }
 
@@ -649,6 +690,44 @@ impl MetaUi {
     }
 }
 
+/// Resultado de `commit_seed`. Distingue alta nueva, edit efectivo
+/// con N campos modificados, y no-op (delta vacío en el path de edit).
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum CommitOutcome {
+    Created(Uuid),
+    Updated { id: Uuid, changed: usize },
+    NoChange(Uuid),
+}
+
+impl CommitOutcome {
+    fn id(&self) -> Uuid {
+        match self {
+            Self::Created(id) | Self::Updated { id, .. } | Self::NoChange(id) => *id,
+        }
+    }
+}
+
+/// 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 el comentario en commit_seed sobre por qué
+/// no clearemos campos vacíos).
+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()
+}
+
 fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
     match kind {
         FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
@@ -1519,6 +1598,97 @@ mod tests {
         }
     }
 
+    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")),
+        ]);
+        let delta = compute_field_delta(&current, &proposed);
+        assert!(delta.is_empty(), "no-op edit debería dar delta vacío");
+    }
+
+    #[test]
+    fn delta_includes_only_changed_field() {
+        let current = json!({
+            "name": "Acme",
+            "saldo": 100_i64,
+            "currency": "USD",
+        });
+        // El usuario sólo cambió saldo.
+        let proposed = map(&[
+            ("name", json!("Acme")),
+            ("saldo", json!(200_i64)),
+            ("currency", json!("USD")),
+        ]);
+        let delta = compute_field_delta(&current, &proposed);
+        assert_eq!(delta.len(), 1, "sólo saldo debería estar en delta");
+        assert_eq!(delta.get("saldo"), Some(&json!(200_i64)));
+        assert!(!delta.contains_key("name"));
+        assert!(!delta.contains_key("currency"));
+    }
+
+    #[test]
+    fn delta_treats_missing_record_as_all_new() {
+        // Record no existe en el store (load → None → Value::Null).
+        // Todos los campos del proposed deberían entrar al delta.
+        let current = Value::Null;
+        let proposed = map(&[
+            ("name", json!("Acme")),
+            ("saldo", json!(0_i64)),
+        ]);
+        let delta = compute_field_delta(&current, &proposed);
+        assert_eq!(delta.len(), 2);
+    }
+
+    #[test]
+    fn delta_distinguishes_int_from_string_repr() {
+        // Sanity: si el form devuelve "100" como Number → json!(100_i64)
+        // y el store tiene json!(100), comparan iguales (PartialEq de
+        // Value normaliza). Si el store tuviera "100" string, NO igualan.
+        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,
+            "string '100' vs int 100 sí debería contar como cambio"
+        );
+    }
+
+    #[test]
+    fn delta_skips_fields_absent_from_proposed() {
+        // Si el form omite un field (porque el FieldSpec no lo
+        // declara), no lo deberíamos mencionar en el delta — el edit
+        // sólo toca los fields del form.
+        let current = json!({
+            "name": "Acme",
+            "saldo": 100_i64,
+            "internal_marker": "x",
+        });
+        let proposed = map(&[
+            ("name", json!("Acme")),
+            ("saldo", json!(150_i64)),
+        ]);
+        let delta = compute_field_delta(&current, &proposed);
+        assert_eq!(delta.len(), 1);
+        assert_eq!(delta.get("saldo"), Some(&json!(150_i64)));
+        assert!(!delta.contains_key("internal_marker"));
+    }
+
     #[test]
     fn resolve_param_strict_number_parses_i64() {
         let s = spec("qty", FieldKind::Number, true);