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>
This commit is contained in:
Sergio
2026-05-09 21:24:27 +00:00
parent fdb9bbe058
commit 70f8c66548
2 changed files with 245 additions and 17 deletions
+58
View File
@@ -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 —
+186 -16
View File
@@ -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} {}",
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);