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>
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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &proposed).is_empty());
+
+ let current_str = json!({"qty": "100"});
+ let proposed_int = map(&[("qty", json!(100_i64))]);
+ assert_eq!(
+ compute_field_delta(¤t_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(¤t, &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);