feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío

Nueva variante semántica del kernel: Clear { path } remueve la key
del record, distinta de Set { value: Null } (que deja la key con
valor literal null). Habilita "limpiar" un field optional vaciando
el input en la UI.

nakui-core:
- delta::FieldOp::Clear + simulate_on + capability_token (mismo
  shape que Set: "entity.field").
- MemoryStore::apply_dry_run y apply: Set/Clear comparten
  pre-condition (record existe + es objeto). Clear de field
  ausente = no-op silencioso.
- SurrealStore: equivalente con `UPDATE ... UNSET <field>`.
- Executor capability check: Set/Clear comparten match.
- Conservation rules NO consideran Clear (sólo Set) — documentado
  como morphism-author responsibility.

nakui-ui:
- commit_seed acumula `to_clear: Vec<String>` con optional empties
  en lugar de `continue` silencioso.
- EDIT branch: nuevo helper compute_clear_fields filtra a sólo los
  fields con current value non-null. Combina Set + Clear ops.
  NoChange ahora requiere ambos vacíos. Log entry incluye
  `cleared: [...]` sólo si non-empty. Updated.changed cuenta
  sets+clears.

Tests: +7 en nakui-core (4 store + 3 delta), +3 en nakui-ui.
Suites: 34/34 nakui-core, 40/40 nakui-ui. Workspace build verde.
E2E del morphism real intacto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 22:15:42 +00:00
parent 613f4f299e
commit f0c0a71860
6 changed files with 438 additions and 45 deletions
+91
View File
@@ -16,6 +16,17 @@ pub enum FieldOp {
path: FieldPath,
value: Value,
},
/// Remove a single field key from a record. Distinto de `Set { value: Null }`:
/// `Clear` borra la clave del map; un load posterior no encuentra el
/// campo (`None`/`Value::Null` semantically). `Set Null` por contraste
/// deja la clave con valor literal `null`. La distinción importa para
/// downstream code que diferencia "ausente" de "presente como null"
/// (ej: serialize que `skip_serializing_if = "Option::is_none"`).
///
/// Capability token: `entity.field` (mismo shape que Set).
Clear {
path: FieldPath,
},
Create {
entity: String,
id: Uuid,
@@ -33,12 +44,87 @@ impl FieldOp {
pub fn capability_token(&self) -> String {
match self {
FieldOp::Set { path, .. } => format!("{}.{}", path.entity, path.field),
FieldOp::Clear { path } => format!("{}.{}", path.entity, path.field),
FieldOp::Create { entity, .. } => entity.clone(),
FieldOp::Delete { entity, .. } => entity.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn simulate_clear_removes_field() {
let id = Uuid::new_v4();
let state = json!({"name": "Acme", "notes": "lorem"});
let op = FieldOp::Clear {
path: FieldPath {
entity: "Customer".into(),
id,
field: "notes".into(),
},
};
let after = simulate_on(&state, "Customer", id, &[op]).unwrap();
let map = after.as_object().unwrap();
assert!(!map.contains_key("notes"));
assert_eq!(map.get("name"), Some(&json!("Acme")));
}
#[test]
fn simulate_clear_then_set_same_field_keeps_set() {
let id = Uuid::new_v4();
let state = json!({"name": "Acme", "notes": "lorem"});
let ops = vec![
FieldOp::Clear {
path: FieldPath {
entity: "Customer".into(),
id,
field: "notes".into(),
},
},
FieldOp::Set {
path: FieldPath {
entity: "Customer".into(),
id,
field: "notes".into(),
},
value: json!("nuevo"),
},
];
let after = simulate_on(&state, "Customer", id, &ops).unwrap();
assert_eq!(after.get("notes"), Some(&json!("nuevo")));
}
#[test]
fn clear_capability_token_matches_set_shape() {
let id = Uuid::new_v4();
let set = FieldOp::Set {
path: FieldPath {
entity: "Customer".into(),
id,
field: "notes".into(),
},
value: json!("x"),
};
let clear = FieldOp::Clear {
path: FieldPath {
entity: "Customer".into(),
id,
field: "notes".into(),
},
};
assert_eq!(set.capability_token(), "Customer.notes");
assert_eq!(
clear.capability_token(),
set.capability_token(),
"Clear y Set comparten token shape para el capability check"
);
}
}
/// Apply only the ops that target `(entity, id)` to `state` and return the
/// new value. Returns `None` if a Delete op removes the target — callers
/// should skip post-checks against a deleted entity rather than running
@@ -52,6 +138,11 @@ pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Op
map.insert(path.field.clone(), value.clone());
}
}
FieldOp::Clear { path } if path.entity == entity && path.id == id => {
if let Some(Value::Object(map)) = s.as_mut() {
map.remove(&path.field);
}
}
FieldOp::Create {
entity: e,
id: i,