Skip to main content

eui_core/host/
record_migrations.rs

1//! eApps R8 (VAPP-233, docs/spec/apps.md §3.6/§2.3): declarative record
2//! migrations between package versions. A line-by-line port of
3//! `src/host/record-migrations.ts`; `fixtures/record-migrations.json` is the
4//! spec (`tests/record_migrations.rs` replays it).
5
6use serde::Serialize;
7use serde_json::{Map, Value};
8
9use super::package::PackageIssue;
10use super::records::is_record_kind;
11
12pub const RECORD_MIGRATIONS_MAX: usize = 64;
13pub const RECORD_MIGRATION_OPS_MAX: usize = 32;
14pub const MIGRATION_OP_NAMES: &[&str] = &["rename", "default", "drop", "map", "renameKind"];
15
16type Version = [u64; 3];
17
18fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
19    PackageIssue { path: path.into(), message: message.into() }
20}
21
22fn num_part(s: &str) -> Option<u64> {
23    if s == "0" || (!s.is_empty() && !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit())) {
24        s.parse().ok()
25    } else {
26        None
27    }
28}
29
30/// `MAJOR.MINOR.PATCH`, nothing else.
31fn version_of(v: &str) -> Option<Version> {
32    let parts: Vec<&str> = v.trim().split('.').collect();
33    if parts.len() != 3 {
34        return None;
35    }
36    Some([num_part(parts[0])?, num_part(parts[1])?, num_part(parts[2])?])
37}
38
39/// `1`, `1.2`, `1.2.3` (missing parts = 0): what an operator compares against.
40fn numeric_version(v: &str) -> Option<Version> {
41    let parts: Vec<&str> = v.split('.').collect();
42    if parts.is_empty() || parts.len() > 3 {
43        return None;
44    }
45    let mut out = [0u64; 3];
46    for (i, p) in parts.iter().enumerate() {
47        out[i] = num_part(p)?;
48    }
49    Some(out)
50}
51
52/// One comparator of a range against a version; `None` = not a comparator.
53fn comparator_holds(token: &str, v: &Version) -> Option<bool> {
54    let (op, rest) = [">=", "<=", ">", "<", "=", "^", "~"].iter().find_map(|op| token.strip_prefix(op).map(|r| (*op, r))).unwrap_or(("", token));
55    if rest.is_empty() {
56        return None;
57    }
58    if op.is_empty() || op == "=" {
59        let parts: Vec<&str> = rest.split('.').collect();
60        if parts.len() > 3 {
61            return None;
62        }
63        let mut holds = true;
64        for (i, p) in parts.iter().enumerate() {
65            if *p == "x" || *p == "*" {
66                continue;
67            }
68            holds &= num_part(p)? == v[i];
69        }
70        return Some(holds);
71    }
72    let want = numeric_version(rest)?;
73    let c = v.cmp(&want);
74    Some(match op {
75        ">=" => c.is_ge(),
76        ">" => c.is_gt(),
77        "<=" => c.is_le(),
78        "<" => c.is_lt(),
79        "~" => v[0] == want[0] && v[1] == want[1] && c.is_ge(),
80        _ => {
81            if want[0] > 0 {
82                v[0] == want[0] && c.is_ge()
83            } else if want[1] > 0 {
84                v[0] == 0 && v[1] == want[1] && c.is_ge()
85            } else {
86                c.is_eq()
87            }
88        }
89    })
90}
91
92/// A range's alternatives (`||`), each comparators (and); `None` = no range.
93fn range_parts(range: &str) -> Option<Vec<Vec<&str>>> {
94    if range.trim().is_empty() {
95        return None;
96    }
97    let alts: Vec<Vec<&str>> = range.split("||").map(|a| a.split_whitespace().collect()).collect();
98    if alts.iter().any(Vec::is_empty) {
99        return None;
100    }
101    for a in &alts {
102        for t in a {
103            comparator_holds(t, &[0, 0, 0])?;
104        }
105    }
106    Some(alts)
107}
108
109/// Is `range` a SemVer range this rule reads?
110pub fn is_version_range(range: &str) -> bool {
111    range_parts(range).is_some()
112}
113
114/// Does `version` satisfy `range`?
115pub fn range_holds(version: &str, range: &str) -> bool {
116    let (Some(v), Some(alts)) = (version_of(version), range_parts(range)) else { return false };
117    alts.iter().any(|a| a.iter().all(|t| comparator_holds(t, &v) == Some(true)))
118}
119
120/// A package's migrations, the well-formed ones (`{from, kind, ops}`).
121pub fn package_migrations(pkg: &Value) -> Vec<&Map<String, Value>> {
122    pkg.get("migrations")
123        .and_then(Value::as_array)
124        .into_iter()
125        .flatten()
126        .filter_map(Value::as_object)
127        .filter(|m| m.get("from").is_some_and(Value::is_string) && m.get("kind").is_some_and(Value::is_string) && m.get("ops").is_some_and(Value::is_array))
128        .collect()
129}
130
131fn op_name(op: &Value) -> Option<&'static str> {
132    let o = op.as_object()?;
133    if o.len() != 1 {
134        return None;
135    }
136    let key = o.keys().next()?;
137    MIGRATION_OP_NAMES.iter().copied().find(|n| n == key)
138}
139
140/// A record migrated: its kind, data and how many migrations ran.
141#[derive(Clone, Debug, PartialEq, Serialize)]
142#[serde(untagged)]
143pub enum MigrateResult {
144    Done { kind: String, data: Value, applied: usize },
145    Failed { error: String },
146}
147
148fn s<'a>(v: &'a Value, key: &str) -> &'a str {
149    v.get(key).and_then(Value::as_str).unwrap_or("")
150}
151
152/// A record written under `version` as `kind` through `migrations` (see the TS twin).
153pub fn migrate_record(migrations: &[&Map<String, Value>], version: &str, kind: &str, data: &Value, now: f64) -> MigrateResult {
154    let mut k = kind.to_string();
155    let mut d: Map<String, Value> = data.as_object().cloned().unwrap_or_default();
156    let mut applied = 0;
157    for (i, m) in migrations.iter().enumerate() {
158        if m["kind"].as_str() != Some(k.as_str()) || !range_holds(version, m["from"].as_str().unwrap_or("")) {
159            continue;
160        }
161        applied += 1;
162        for (j, op) in m["ops"].as_array().into_iter().flatten().enumerate() {
163            let at = format!("migration {i} op {j}");
164            match op_name(op) {
165                Some("rename") => {
166                    let r = &op["rename"];
167                    let (from, to) = (s(r, "from"), s(r, "to"));
168                    if let Some(v) = d.remove(from) {
169                        d.insert(to.to_string(), v);
170                    }
171                }
172                Some("default") => {
173                    let r = &op["default"];
174                    let f = s(r, "field");
175                    if d.get(f).is_none_or(Value::is_null) {
176                        d.insert(f.to_string(), r.get("value").cloned().unwrap_or(Value::Null));
177                    }
178                }
179                Some("drop") => {
180                    d.remove(s(&op["drop"], "field"));
181                }
182                Some("map") => {
183                    let r = &op["map"];
184                    let ast = match crate::cel::parse_expression(s(r, "expr")) {
185                        Ok(ast) => ast,
186                        Err(e) => return MigrateResult::Failed { error: format!("{at}: {e}") },
187                    };
188                    let mut vars = d.clone();
189                    for name in crate::cel::expression_names(&ast) {
190                        vars.entry(name).or_insert(Value::Null);
191                    }
192                    let env = crate::cel::ExpressionEnv { now: Some(now), offset: None, max_steps: None };
193                    match crate::cel::evaluate_node(&ast, &vars, &env) {
194                        Ok(v) => {
195                            d.insert(s(r, "field").to_string(), v);
196                        }
197                        Err(e) => return MigrateResult::Failed { error: format!("{at}: {e}") },
198                    }
199                }
200                Some("renameKind") => k = op["renameKind"].as_str().unwrap_or("").to_string(),
201                _ => return MigrateResult::Failed { error: format!("{at}: unknown op") },
202            }
203        }
204    }
205    MigrateResult::Done { kind: k, data: Value::Object(d), applied }
206}
207
208fn sorted_keys(o: &Map<String, Value>) -> Vec<&String> {
209    let mut v: Vec<&String> = o.keys().collect();
210    v.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
211    v
212}
213
214/// eApps R8: the issues of a package's `migrations` (see the TS twin).
215pub fn validate_migrations(pkg: &Value) -> Vec<PackageIssue> {
216    let Some(list) = pkg.get("migrations") else { return Vec::new() };
217    let Some(list) = list.as_array() else { return vec![issue("/migrations", "migrations is a list of {from, kind, ops}")] };
218    let mut issues = Vec::new();
219    if list.len() > RECORD_MIGRATIONS_MAX {
220        issues.push(issue("/migrations", format!("at most {RECORD_MIGRATIONS_MAX} migrations")));
221    }
222    let empty = Map::new();
223    let records = pkg.get("records").and_then(Value::as_object).unwrap_or(&empty);
224    let field = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty());
225    for (i, m) in list.iter().enumerate() {
226        let at = format!("/migrations/{i}");
227        let Some(m) = m.as_object() else {
228            issues.push(issue(at, "a migration is {from, kind, ops}"));
229            continue;
230        };
231        for key in sorted_keys(m) {
232            if !["from", "kind", "ops"].contains(&key.as_str()) {
233                issues.push(issue(format!("{at}/{key}"), format!("unknown key {key}")));
234            }
235        }
236        if !m.get("from").and_then(Value::as_str).is_some_and(is_version_range) {
237            issues.push(issue(format!("{at}/from"), "from is a version or a range (1.2.3, 1.x, ^1.2.0, >=1.0.0 <2.0.0)"));
238        }
239        let mut kind: Option<String> = m.get("kind").and_then(Value::as_str).filter(|k| is_record_kind(k)).map(str::to_string);
240        if kind.is_none() {
241            issues.push(issue(format!("{at}/kind"), "kind is a kind name"));
242        }
243        let Some(ops) = m.get("ops").and_then(Value::as_array) else {
244            issues.push(issue(format!("{at}/ops"), "ops is a list"));
245            continue;
246        };
247        if ops.len() > RECORD_MIGRATION_OPS_MAX {
248            issues.push(issue(format!("{at}/ops"), format!("at most {RECORD_MIGRATION_OPS_MAX} ops")));
249        }
250        for (j, op) in ops.iter().enumerate() {
251            let op_at = format!("{at}/ops/{j}");
252            let Some(name) = op_name(op) else {
253                issues.push(issue(op_at, format!("an op is one of {}", MIGRATION_OP_NAMES.join(", "))));
254                continue;
255            };
256            let arg = &op[name];
257            let obj = arg.as_object();
258            let len = obj.map_or(0, Map::len);
259            let bad = |shape: &str| issue(format!("{op_at}/{name}"), format!("{name} is {shape}"));
260            match name {
261                "rename" if !(obj.is_some() && field(arg.get("from")) && field(arg.get("to")) && len == 2) => issues.push(bad("{from, to}")),
262                "default" if !(obj.is_some() && field(arg.get("field")) && arg.get("value").is_some() && len == 2) => issues.push(bad("{field, value}")),
263                "drop" if !(obj.is_some() && field(arg.get("field")) && len == 1) => issues.push(bad("{field}")),
264                "map" => {
265                    if !(obj.is_some() && field(arg.get("field")) && arg.get("expr").is_some_and(Value::is_string) && len == 2) {
266                        issues.push(bad("{field, expr}"));
267                    } else if let Err(e) = crate::cel::parse_expression(s(arg, "expr")) {
268                        issues.push(issue(format!("{op_at}/map/expr"), e));
269                    }
270                }
271                "renameKind" => match arg.as_str().filter(|k| is_record_kind(k)) {
272                    None => issues.push(bad("a kind name")),
273                    Some(k) => kind = Some(k.to_string()),
274                },
275                _ => {}
276            }
277        }
278        if let Some(k) = kind {
279            if !records.contains_key(&k) {
280                issues.push(issue(format!("{at}/kind"), format!("the migration ends in {k}, which the package does not declare")));
281            }
282        }
283    }
284    issues
285}
286
287/// One incompatible change of a kind between two packages.
288#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
289pub struct KindChange {
290    pub change: &'static str,
291    pub field: Option<String>,
292}
293
294fn props_of(kind: Option<&Value>) -> Map<String, Value> {
295    kind.and_then(|k| k.get("schema")).and_then(|s| s.get("properties")).and_then(Value::as_object).cloned().unwrap_or_default()
296}
297fn required_of(kind: Option<&Value>) -> Vec<String> {
298    kind.and_then(|k| k.get("schema")).and_then(|s| s.get("required")).and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
299}
300fn js_text(v: Option<&Value>) -> String {
301    match v {
302        None => "undefined".into(),
303        Some(Value::String(s)) => s.clone(),
304        Some(Value::Null) => "null".into(),
305        Some(Value::Bool(b)) => b.to_string(),
306        Some(Value::Number(n)) => n.to_string(),
307        Some(Value::Array(_)) => "[array]".into(),
308        Some(Value::Object(_)) => "[object Object]".into(),
309    }
310}
311fn type_of(f: &Value) -> String {
312    let t = f.get("type");
313    let items = if t.and_then(Value::as_str) == Some("array") { f.get("items").filter(|i| i.is_object()).map(|i| format!("<{}>", js_text(i.get("type")))) } else { None };
314    format!("{}{}", js_text(t), items.unwrap_or_default())
315}
316fn enum_of(f: &Value) -> Option<Vec<Value>> {
317    let e = if f.get("type").and_then(Value::as_str) == Some("array") && f.get("items").is_some_and(Value::is_object) { f["items"].get("enum") } else { f.get("enum") };
318    e.and_then(Value::as_array).cloned()
319}
320
321/// The incompatible changes of each kind from `before` to `after` (see the TS twin).
322pub fn kind_changes(before: &Value, after: &Value) -> Vec<(String, Vec<KindChange>)> {
323    let empty = Map::new();
324    let b = before.get("records").and_then(Value::as_object).unwrap_or(&empty);
325    let a = after.get("records").and_then(Value::as_object).unwrap_or(&empty);
326    let mut out = Vec::new();
327    for kind in sorted_keys(b) {
328        let mut changes = Vec::new();
329        if !a.contains_key(kind) {
330            changes.push(KindChange { change: "removed", field: None });
331        } else {
332            let bp = props_of(b.get(kind));
333            let ap = props_of(a.get(kind));
334            let breq = required_of(b.get(kind));
335            for f in sorted_keys(&bp) {
336                let push = |c: &'static str| KindChange { change: c, field: Some(f.clone()) };
337                match ap.get(f) {
338                    None => changes.push(push("drops")),
339                    Some(af) if type_of(&bp[f]) != type_of(af) => changes.push(push("retypes")),
340                    Some(af) => {
341                        if let Some(ae) = enum_of(af) {
342                            let narrows = match enum_of(&bp[f]) {
343                                None => true,
344                                Some(be) => be.iter().any(|v| !ae.contains(v)),
345                            };
346                            if narrows {
347                                changes.push(push("narrows"));
348                            }
349                        }
350                    }
351                }
352            }
353            let mut areq = required_of(a.get(kind));
354            areq.sort_by(|x, y| x.encode_utf16().cmp(y.encode_utf16()));
355            for f in areq {
356                if !breq.contains(&f) && ap.contains_key(&f) && !changes.iter().any(|c| c.field.as_deref() == Some(f.as_str())) {
357                    changes.push(KindChange { change: "requires", field: Some(f) });
358                }
359            }
360        }
361        if !changes.is_empty() {
362            out.push((kind.clone(), changes));
363        }
364    }
365    out
366}
367
368/// [`kind_changes`] as the JSON object the fixture spells.
369pub fn kind_changes_json(before: &Value, after: &Value) -> Value {
370    Value::Object(kind_changes(before, after).into_iter().map(|(k, c)| (k, serde_json::to_value(c).unwrap_or(Value::Null))).collect())
371}
372
373fn covered(migrations: &[&Map<String, Value>], version: &str, kind: &str, c: &KindChange) -> bool {
374    let f = c.field.as_deref();
375    for m in migrations {
376        if m["kind"].as_str() != Some(kind) || !range_holds(version, m["from"].as_str().unwrap_or("")) {
377            continue;
378        }
379        for op in m["ops"].as_array().into_iter().flatten() {
380            let name = op_name(op);
381            let arg = |n: &str, k: &str| op.get(n).and_then(|a| a.get(k)).and_then(Value::as_str);
382            let hit = match c.change {
383                "removed" => name == Some("renameKind"),
384                "drops" => (name == Some("drop") && arg("drop", "field") == f) || (name == Some("rename") && arg("rename", "from") == f),
385                "retypes" | "narrows" => name == Some("map") && arg("map", "field") == f,
386                _ => (name == Some("default") && arg("default", "field") == f) || (name == Some("map") && arg("map", "field") == f) || (name == Some("rename") && arg("rename", "to") == f),
387            };
388            if hit {
389                return true;
390            }
391        }
392    }
393    false
394}
395
396fn change_text(c: &str) -> &'static str {
397    match c {
398        "removed" => "is removed",
399        "drops" => "drops",
400        "retypes" => "changes the type of",
401        "narrows" => "narrows the values of",
402        _ => "requires",
403    }
404}
405
406/// Why `after` may not replace `before` (see the TS twin); `None` = it may.
407pub fn migration_refusal(before: &Value, after: &Value) -> Option<String> {
408    let bvs = before.get("version").and_then(Value::as_str)?;
409    let avs = after.get("version").and_then(Value::as_str)?;
410    let (bv, av) = (version_of(bvs)?, version_of(avs)?);
411    if av[0] <= bv[0] {
412        return None;
413    }
414    let migrations = package_migrations(after);
415    for (kind, changes) in kind_changes(before, after) {
416        for c in &changes {
417            if covered(&migrations, bvs, &kind, c) {
418                continue;
419            }
420            let what = match &c.field {
421                None => format!("{kind} {}", change_text(c.change)),
422                Some(f) => format!("{kind} {} {f}", change_text(c.change)),
423            };
424            return Some(format!("{what} in {avs}: add a migration from {bvs} that covers it"));
425        }
426    }
427    None
428}