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 —