tawasuyu/nakui
> Excel-style reactive engine, on solid principles — exact Decimal, topological cascade, WAL, time-travel — in Rust, with a Llimphi UI.
⎇ 7041e0499f8449ae8d7bba2a65695d82cef05a9e
01_yachay/nakui/nakui-core/src/delta.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat: nakui standalone — front-door ERP/Hoja/Grafo sobre Llimphi
1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use uuid::Uuid;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct FieldPath {
7 pub entity: String,
8 pub id: Uuid,
9 pub field: String,
10}
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "op", rename_all = "snake_case")]
14pub enum FieldOp {
15 Set {
16 path: FieldPath,
17 value: Value,
18 },
19 /// Remove a single field key from a record. Distinto de `Set { value: Null }`:
20 /// `Clear` borra la clave del map; un load posterior no encuentra el
21 /// campo (`None`/`Value::Null` semantically). `Set Null` por contraste
22 /// deja la clave con valor literal `null`. La distinción importa para
23 /// downstream code que diferencia "ausente" de "presente como null"
24 /// (ej: serialize que `skip_serializing_if = "Option::is_none"`).
25 ///
26 /// Capability token: `entity.field` (mismo shape que Set).
27 Clear {
28 path: FieldPath,
29 },
30 Create {
31 entity: String,
32 id: Uuid,
33 data: Value,
34 },
35 Delete {
36 entity: String,
37 id: Uuid,
38 },
39}
40
41impl FieldOp {
42 /// Token a manifest's `writes` list matches against.
43 /// "Caja.saldo" for field updates, "Movimiento" for whole-record ops.
44 pub fn capability_token(&self) -> String {
45 match self {
46 FieldOp::Set { path, .. } => format!("{}.{}", path.entity, path.field),
47 FieldOp::Clear { path } => format!("{}.{}", path.entity, path.field),
48 FieldOp::Create { entity, .. } => entity.clone(),
49 FieldOp::Delete { entity, .. } => entity.clone(),
50 }
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use serde_json::json;
58
59 #[test]
60 fn simulate_clear_removes_field() {
61 let id = Uuid::new_v4();
62 let state = json!({"name": "Acme", "notes": "lorem"});
63 let op = FieldOp::Clear {
64 path: FieldPath {
65 entity: "Customer".into(),
66 id,
67 field: "notes".into(),
68 },
69 };
70 let after = simulate_on(&state, "Customer", id, &[op]).unwrap();
71 let map = after.as_object().unwrap();
72 assert!(!map.contains_key("notes"));
73 assert_eq!(map.get("name"), Some(&json!("Acme")));
74 }
75
76 #[test]
77 fn simulate_clear_then_set_same_field_keeps_set() {
78 let id = Uuid::new_v4();
79 let state = json!({"name": "Acme", "notes": "lorem"});
80 let ops = vec![
81 FieldOp::Clear {
82 path: FieldPath {
83 entity: "Customer".into(),
84 id,
85 field: "notes".into(),
86 },
87 },
88 FieldOp::Set {
89 path: FieldPath {
90 entity: "Customer".into(),
91 id,
92 field: "notes".into(),
93 },
94 value: json!("nuevo"),
95 },
96 ];
97 let after = simulate_on(&state, "Customer", id, &ops).unwrap();
98 assert_eq!(after.get("notes"), Some(&json!("nuevo")));
99 }
100
101 #[test]
102 fn clear_capability_token_matches_set_shape() {
103 let id = Uuid::new_v4();
104 let set = FieldOp::Set {
105 path: FieldPath {
106 entity: "Customer".into(),
107 id,
108 field: "notes".into(),
109 },
110 value: json!("x"),
111 };
112 let clear = FieldOp::Clear {
113 path: FieldPath {
114 entity: "Customer".into(),
115 id,
116 field: "notes".into(),
117 },
118 };
119 assert_eq!(set.capability_token(), "Customer.notes");
120 assert_eq!(
121 clear.capability_token(),
122 set.capability_token(),
123 "Clear y Set comparten token shape para el capability check"
124 );
125 }
126}
127
128/// Apply only the ops that target `(entity, id)` to `state` and return the
129/// new value. Returns `None` if a Delete op removes the target — callers
130/// should skip post-checks against a deleted entity rather than running
131/// them against the stale prior state.
132pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Option<Value> {
133 let mut s: Option<Value> = Some(state.clone());
134 for op in ops {
135 match op {
136 FieldOp::Set { path, value } if path.entity == entity && path.id == id => {
137 if let Some(Value::Object(map)) = s.as_mut() {
138 map.insert(path.field.clone(), value.clone());
139 }
140 }
141 FieldOp::Clear { path } if path.entity == entity && path.id == id => {
142 if let Some(Value::Object(map)) = s.as_mut() {
143 map.remove(&path.field);
144 }
145 }
146 FieldOp::Create {
147 entity: e,
148 id: i,
149 data,
150 } if e == entity && *i == id => {
151 s = Some(data.clone());
152 }
153 FieldOp::Delete { entity: e, id: i } if e == entity && *i == id => {
154 s = None;
155 }
156 _ => {}
157 }
158 }
159 s
160}