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:
@@ -6,6 +6,64 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 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
|
### 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
|
Cierra el primer pending del último round: borrar un record pedía un
|
||||||
solo click en `✕` y se ejecutaba inmediatamente (irreversible —
|
solo click en `✕` y se ejecutaba inmediatamente (irreversible —
|
||||||
|
|||||||
@@ -353,10 +353,6 @@ impl MetaUi {
|
|||||||
Some((i, _)) => *i,
|
Some((i, _)) => *i,
|
||||||
None => return,
|
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 {
|
match action {
|
||||||
Action::OpenView { view, .. } => {
|
Action::OpenView { view, .. } => {
|
||||||
// Salir a otra view cancela el edit pendiente.
|
// Salir a otra view cancela el edit pendiente.
|
||||||
@@ -365,14 +361,29 @@ impl MetaUi {
|
|||||||
}
|
}
|
||||||
Action::SeedEntity { entity, next_view } => {
|
Action::SeedEntity { entity, next_view } => {
|
||||||
match self.commit_seed(mod_idx, &entity, cx) {
|
match self.commit_seed(mod_idx, &entity, cx) {
|
||||||
Ok(id) => {
|
Ok(outcome) => {
|
||||||
let action_label = if was_editing { "actualizado" } else { "creado" };
|
let id = outcome.id();
|
||||||
self.toast = Some(SharedString::from(format!(
|
let toast_msg = match &outcome {
|
||||||
"{action_label} {entity} {}",
|
CommitOutcome::Created(_) => {
|
||||||
short_uuid(&id)
|
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 —
|
// 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;
|
self.editing = None;
|
||||||
if let Some(v) = next_view {
|
if let Some(v) = next_view {
|
||||||
self.select_view(mod_idx, v, cx);
|
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.
|
/// 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(
|
fn commit_seed(
|
||||||
&mut self,
|
&mut self,
|
||||||
mod_idx: usize,
|
mod_idx: usize,
|
||||||
entity: &str,
|
entity: &str,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Result<Uuid, String> {
|
) -> Result<CommitOutcome, String> {
|
||||||
let module = &self.modules[mod_idx];
|
let module = &self.modules[mod_idx];
|
||||||
let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
|
let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
|
||||||
Some((_, view_key)) => match module.views.get(view_key) {
|
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();
|
let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
|
||||||
|
|
||||||
if let Some((_, id)) = editing_match {
|
if let Some((_, id)) = editing_match {
|
||||||
// EDIT path: Morphism { ui.edit_record, ops: [Set...] }
|
// EDIT path: delta-only. Cargar el record actual del store
|
||||||
let ops: Vec<FieldOp> = obj
|
// 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()
|
.iter()
|
||||||
.map(|(field, value)| FieldOp::Set {
|
.map(|(field, value)| FieldOp::Set {
|
||||||
path: FieldPath {
|
path: FieldPath {
|
||||||
@@ -593,7 +631,7 @@ impl MetaUi {
|
|||||||
params: json!({
|
params: json!({
|
||||||
"entity": entity,
|
"entity": entity,
|
||||||
"id": id.to_string(),
|
"id": id.to_string(),
|
||||||
"fields": Value::Object(obj.clone()),
|
"fields": Value::Object(delta.clone()),
|
||||||
}),
|
}),
|
||||||
ops: ops.clone(),
|
ops: ops.clone(),
|
||||||
schema_hash: None,
|
schema_hash: None,
|
||||||
@@ -605,7 +643,10 @@ impl MetaUi {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "store mutex envenenado".to_string())?;
|
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||||
store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
|
store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
|
||||||
Ok(id)
|
Ok(CommitOutcome::Updated {
|
||||||
|
id,
|
||||||
|
changed: delta.len(),
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// SEED path: alta nueva.
|
// SEED path: alta nueva.
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
@@ -629,7 +670,7 @@ impl MetaUi {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "store mutex envenenado".to_string())?;
|
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||||
store.seed(entity, id, data);
|
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> {
|
fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
||||||
match kind {
|
match kind {
|
||||||
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
|
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]
|
#[test]
|
||||||
fn resolve_param_strict_number_parses_i64() {
|
fn resolve_param_strict_number_parses_i64() {
|
||||||
let s = spec("qty", FieldKind::Number, true);
|
let s = spec("qty", FieldKind::Number, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user