tawasuyu

tawasuyu/nakui

> Excel-style reactive engine, on solid principles — exact Decimal, topological cascade, WAL, time-travel — in Rust, with a Llimphi UI.

Rust 82Markdown 14JSON 12TOML 101471 Sutichaykuna

⎇ 7041e0499f8449ae8d7bba2a65695d82cef05a9e 01_yachay/nakui/nakui-core/src/delta.rs Rust · 160 siq'ikuna · 5.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: nakui standalone — front-door ERP/Hoja/Grafo sobre Llimphi 7041e0499 · Sergio ·

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}