Skip to main content

topodb_json/
batch.rs

1//! The batch-submit DSL: a JSON array of high-level commands (each an object
2//! keyed by `"op"`, whose value equals the corresponding MCP tool name) that
3//! both `topodb-cli submit` and `topodb-mcp submit_batch` consume. Turns the
4//! array into a `Vec<Op>` for one atomic `Db::submit`, resolving `#N`
5//! back-references to ULIDs produced by earlier commands in the same batch.
6//!
7//! Supported ops: `create_memory`, `create_entity`, `create_node` (an
8//! arbitrary-label node, for host-level schemas like episode recording),
9//! `link`, `set_node_props`, `remove_node`, `close_edge`, `set_embedding`.
10
11use crate::{
12    json_to_f32_vec, json_to_prop_changes, json_to_props, merge_required_prop, resolve_scope,
13    ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_PROP, MEMORY_LABEL,
14};
15use serde_json::Value;
16use std::str::FromStr;
17use topodb::{EdgeId, NodeId, Op, PropValue, Props, Scope};
18
19#[derive(Clone, Copy, PartialEq, Eq)]
20enum IdKind {
21    Node,
22    Edge,
23}
24
25fn kind_name(k: IdKind) -> &'static str {
26    match k {
27        IdKind::Node => "node",
28        IdKind::Edge => "edge",
29    }
30}
31
32/// Pulls a required string field, naming the command index on failure.
33fn req_str(obj: &serde_json::Map<String, Value>, key: &str, idx: usize) -> Result<String, String> {
34    obj.get(key)
35        .and_then(|v| v.as_str())
36        .map(|s| s.to_string())
37        .ok_or_else(|| format!("command #{idx}: missing string field {key:?}"))
38}
39
40/// An optional i64 field: absent → None; present-and-integer → Some; present
41/// -but-not-integer → Err.
42fn opt_i64(
43    obj: &serde_json::Map<String, Value>,
44    key: &str,
45    idx: usize,
46) -> Result<Option<i64>, String> {
47    match obj.get(key) {
48        None | Some(Value::Null) => Ok(None),
49        Some(v) => v
50            .as_i64()
51            .map(Some)
52            .ok_or_else(|| format!("command #{idx}: {key:?} must be an integer")),
53    }
54}
55
56/// Resolves a command's `scope` field (create_memory/create_entity/link all
57/// carry one): absent → the batch default; a string → parsed; non-string →
58/// Err.
59fn scope_of(
60    obj: &serde_json::Map<String, Value>,
61    default: Scope,
62    idx: usize,
63) -> Result<Scope, String> {
64    match obj.get("scope") {
65        None | Some(Value::Null) => Ok(default),
66        Some(Value::String(s)) => {
67            resolve_scope(Some(s), default).map_err(|e| format!("command #{idx}: {e}"))
68        }
69        Some(_) => Err(format!("command #{idx}: \"scope\" must be a string")),
70    }
71}
72
73/// Resolves an id-bearing field to a ULID string: a literal ULID passes through
74/// verbatim; `#N` resolves to command N's produced id, requiring N < idx
75/// (backward-only) and a matching id kind (node vs edge).
76fn resolve_ref(
77    raw: &str,
78    expected: IdKind,
79    produced: &[Option<(String, IdKind)>],
80    field: &str,
81    idx: usize,
82) -> Result<String, String> {
83    let Some(rest) = raw.strip_prefix('#') else {
84        return Ok(raw.to_string());
85    };
86    let n: usize = rest
87        .parse()
88        .map_err(|_| format!("command #{idx}: {field} back-ref {raw:?} is not #<number>"))?;
89    if n >= idx {
90        return Err(format!(
91            "command #{idx}: {field} back-ref {raw:?} must point to an earlier command"
92        ));
93    }
94    match &produced[n] {
95        Some((id, kind)) if *kind == expected => Ok(id.clone()),
96        Some((_, kind)) => Err(format!(
97            "command #{idx}: {field} back-ref #{n} refers to a {} id but a {} id is required here",
98            kind_name(*kind),
99            kind_name(expected)
100        )),
101        None => Err(format!(
102            "command #{idx}: {field} back-ref #{n} refers to a command that produces no id"
103        )),
104    }
105}
106
107fn parse_node(s: &str, field: &str, idx: usize) -> Result<NodeId, String> {
108    NodeId::from_str(s).map_err(|e| format!("command #{idx}: invalid {field} node id {s:?}: {e}"))
109}
110
111fn parse_edge(s: &str, field: &str, idx: usize) -> Result<EdgeId, String> {
112    EdgeId::from_str(s).map_err(|e| format!("command #{idx}: invalid {field} edge id {s:?}: {e}"))
113}
114
115pub fn resolve_batch(
116    batch: &Value,
117    default_scope: Scope,
118) -> Result<(Vec<Op>, Vec<Option<String>>), String> {
119    let arr = batch
120        .as_array()
121        .ok_or_else(|| "batch must be a JSON array of command objects".to_string())?;
122    let mut produced: Vec<Option<(String, IdKind)>> = Vec::with_capacity(arr.len());
123    let mut ops: Vec<Op> = Vec::with_capacity(arr.len());
124
125    for (idx, cmd) in arr.iter().enumerate() {
126        let obj = cmd
127            .as_object()
128            .ok_or_else(|| format!("command #{idx}: expected a JSON object"))?;
129        let op_name = obj
130            .get("op")
131            .and_then(|v| v.as_str())
132            .ok_or_else(|| format!("command #{idx}: missing string field \"op\""))?;
133
134        match op_name {
135            "create_memory" => {
136                let content = req_str(obj, "content", idx)?;
137                let scope = scope_of(obj, default_scope, idx)?;
138                let props = merge_required_prop(
139                    MEMORY_CONTENT_PROP,
140                    PropValue::Str(content),
141                    obj.get("props"),
142                )
143                .map_err(|e| format!("command #{idx}: {e}"))?;
144                let id = NodeId::new();
145                produced.push(Some((id.to_string(), IdKind::Node)));
146                ops.push(Op::CreateNode {
147                    id,
148                    scope,
149                    label: MEMORY_LABEL.into(),
150                    props,
151                });
152            }
153            "create_entity" => {
154                let name = req_str(obj, "name", idx)?;
155                let scope = scope_of(obj, default_scope, idx)?;
156                let props =
157                    merge_required_prop(ENTITY_NAME_PROP, PropValue::Str(name), obj.get("props"))
158                        .map_err(|e| format!("command #{idx}: {e}"))?;
159                let id = NodeId::new();
160                produced.push(Some((id.to_string(), IdKind::Node)));
161                ops.push(Op::CreateNode {
162                    id,
163                    scope,
164                    label: ENTITY_LABEL.into(),
165                    props,
166                });
167            }
168            "create_node" => {
169                let label = req_str(obj, "label", idx)?;
170                if label.is_empty() {
171                    return Err(format!("command #{idx}: \"label\" must be non-empty"));
172                }
173                if label == ENTITY_LABEL {
174                    return Err(format!(
175                        "command #{idx}: label {ENTITY_LABEL:?} is reserved — use the create_entity command"
176                    ));
177                }
178                if label == MEMORY_LABEL {
179                    return Err(format!(
180                        "command #{idx}: label {MEMORY_LABEL:?} is reserved — use the create_memory command"
181                    ));
182                }
183                let scope = scope_of(obj, default_scope, idx)?;
184                let props = match obj.get("props") {
185                    Some(v) => json_to_props(v).map_err(|e| format!("command #{idx}: {e}"))?,
186                    None => Props::new(),
187                };
188                let id = NodeId::new();
189                produced.push(Some((id.to_string(), IdKind::Node)));
190                ops.push(Op::CreateNode {
191                    id,
192                    scope,
193                    label: label.into(),
194                    props,
195                });
196            }
197            "link" => {
198                let from_raw = req_str(obj, "from", idx)?;
199                let to_raw = req_str(obj, "to", idx)?;
200                let ty = req_str(obj, "type", idx)?;
201                let scope = scope_of(obj, default_scope, idx)?;
202                let from = parse_node(
203                    &resolve_ref(&from_raw, IdKind::Node, &produced, "from", idx)?,
204                    "from",
205                    idx,
206                )?;
207                let to = parse_node(
208                    &resolve_ref(&to_raw, IdKind::Node, &produced, "to", idx)?,
209                    "to",
210                    idx,
211                )?;
212                let props = match obj.get("props") {
213                    Some(v) => json_to_props(v).map_err(|e| format!("command #{idx}: {e}"))?,
214                    None => Props::new(),
215                };
216                let valid_from = opt_i64(obj, "valid_from", idx)?;
217                let id = EdgeId::new();
218                produced.push(Some((id.to_string(), IdKind::Edge)));
219                ops.push(Op::CreateEdge {
220                    id,
221                    scope,
222                    ty: ty.into(),
223                    from,
224                    to,
225                    props,
226                    valid_from,
227                });
228            }
229            "set_node_props" => {
230                let id_raw = req_str(obj, "id", idx)?;
231                let id = parse_node(
232                    &resolve_ref(&id_raw, IdKind::Node, &produced, "id", idx)?,
233                    "id",
234                    idx,
235                )?;
236                let props_val = obj
237                    .get("props")
238                    .ok_or_else(|| format!("command #{idx}: set_node_props requires \"props\""))?;
239                let props =
240                    json_to_prop_changes(props_val).map_err(|e| format!("command #{idx}: {e}"))?;
241                produced.push(None);
242                ops.push(Op::SetNodeProps { id, props });
243            }
244            "remove_node" => {
245                let id_raw = req_str(obj, "id", idx)?;
246                let id = parse_node(
247                    &resolve_ref(&id_raw, IdKind::Node, &produced, "id", idx)?,
248                    "id",
249                    idx,
250                )?;
251                produced.push(None);
252                ops.push(Op::RemoveNode { id });
253            }
254            "close_edge" => {
255                let id_raw = req_str(obj, "id", idx)?;
256                let id = parse_edge(
257                    &resolve_ref(&id_raw, IdKind::Edge, &produced, "id", idx)?,
258                    "id",
259                    idx,
260                )?;
261                let valid_to = opt_i64(obj, "valid_to", idx)?;
262                produced.push(None);
263                ops.push(Op::CloseEdge { id, valid_to });
264            }
265            "set_embedding" => {
266                let id_raw = req_str(obj, "id", idx)?;
267                let id = parse_node(
268                    &resolve_ref(&id_raw, IdKind::Node, &produced, "id", idx)?,
269                    "id",
270                    idx,
271                )?;
272                let model = req_str(obj, "model", idx)?;
273                let vector_val = obj
274                    .get("vector")
275                    .ok_or_else(|| format!("command #{idx}: set_embedding requires \"vector\""))?;
276                let vector =
277                    json_to_f32_vec(vector_val).map_err(|e| format!("command #{idx}: {e}"))?;
278                produced.push(None);
279                ops.push(Op::SetEmbedding { id, model, vector });
280            }
281            other => return Err(format!("command #{idx}: unknown op {other:?}")),
282        }
283    }
284
285    let produced_ids = produced.into_iter().map(|p| p.map(|(id, _)| id)).collect();
286    Ok((ops, produced_ids))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use topodb::ScopeId;
293
294    fn ids(ops: &[Op]) -> Vec<String> {
295        ops.iter()
296            .map(|op| match op {
297                Op::CreateNode { id, .. } => id.to_string(),
298                Op::CreateEdge { id, .. } => id.to_string(),
299                _ => String::new(),
300            })
301            .collect()
302    }
303
304    #[test]
305    fn create_and_backref_link_resolves_to_generated_ids() {
306        let batch = serde_json::json!([
307            { "op": "create_entity", "name": "Ada" },
308            { "op": "create_memory", "content": "met Ada" },
309            { "op": "link", "from": "#1", "to": "#0", "type": "about" }
310        ]);
311        let (ops, produced) = resolve_batch(&batch, Scope::Shared).unwrap();
312        assert_eq!(ops.len(), 3);
313        let node_ids = ids(&ops);
314        // link's from == command #1's node id, to == command #0's node id.
315        match &ops[2] {
316            Op::CreateEdge { from, to, ty, .. } => {
317                assert_eq!(from.to_string(), node_ids[1]);
318                assert_eq!(to.to_string(), node_ids[0]);
319                assert_eq!(ty.as_str(), "about");
320            }
321            other => panic!("expected CreateEdge, got {other:?}"),
322        }
323        // produced ids: node, node, edge.
324        assert_eq!(produced[0].as_deref(), Some(node_ids[0].as_str()));
325        assert_eq!(produced[1].as_deref(), Some(node_ids[1].as_str()));
326        assert!(produced[2].is_some());
327    }
328
329    #[test]
330    fn set_node_props_null_removes_key() {
331        let batch = serde_json::json!([
332            { "op": "set_node_props", "id": "00000000000000000000000000",
333              "props": { "stale": null, "status": "x" } }
334        ]);
335        let (ops, produced) = resolve_batch(&batch, Scope::Shared).unwrap();
336        match &ops[0] {
337            Op::SetNodeProps { props, .. } => {
338                assert_eq!(props["stale"], None);
339                assert_eq!(props["status"], Some(PropValue::Str("x".into())));
340            }
341            other => panic!("expected SetNodeProps, got {other:?}"),
342        }
343        assert_eq!(produced[0], None);
344    }
345
346    #[test]
347    fn forward_reference_is_rejected() {
348        let batch = serde_json::json!([
349            { "op": "link", "from": "#1", "to": "#1", "type": "x" },
350            { "op": "create_entity", "name": "late" }
351        ]);
352        let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
353        assert!(err.contains("earlier"), "got: {err}");
354    }
355
356    #[test]
357    fn node_slot_rejects_edge_backref() {
358        // command #0 is a link (produces an EDGE id); using #0 in link's
359        // `from` (a NODE slot) must be rejected on kind.
360        let batch = serde_json::json!([
361            { "op": "create_entity", "name": "a" },
362            { "op": "create_entity", "name": "b" },
363            { "op": "link", "from": "#0", "to": "#1", "type": "x" },
364            { "op": "link", "from": "#2", "to": "#0", "type": "y" }
365        ]);
366        let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
367        assert!(err.contains("edge") && err.contains("node"), "got: {err}");
368    }
369
370    #[test]
371    fn close_edge_backref_to_in_batch_link_is_allowed() {
372        let batch = serde_json::json!([
373            { "op": "create_entity", "name": "a" },
374            { "op": "create_entity", "name": "b" },
375            { "op": "link", "from": "#0", "to": "#1", "type": "x" },
376            { "op": "close_edge", "id": "#2" }
377        ]);
378        let (ops, _) = resolve_batch(&batch, Scope::Shared).unwrap();
379        let edge_id = match &ops[2] {
380            Op::CreateEdge { id, .. } => id.to_string(),
381            _ => unreachable!(),
382        };
383        match &ops[3] {
384            Op::CloseEdge { id, valid_to } => {
385                assert_eq!(id.to_string(), edge_id);
386                assert_eq!(*valid_to, None); // applier fills "now"
387            }
388            other => panic!("expected CloseEdge, got {other:?}"),
389        }
390    }
391
392    #[test]
393    fn unknown_op_names_the_index() {
394        let batch = serde_json::json!([{ "op": "nope" }]);
395        let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
396        assert!(err.contains("#0") && err.contains("nope"), "got: {err}");
397    }
398
399    #[test]
400    fn non_array_batch_is_rejected() {
401        let err = resolve_batch(&serde_json::json!({"op": "x"}), Scope::Shared).unwrap_err();
402        assert!(err.contains("array"), "got: {err}");
403    }
404
405    #[test]
406    fn set_embedding_parses_vector() {
407        let batch = serde_json::json!([
408            { "op": "set_embedding", "id": "00000000000000000000000000",
409              "model": "m", "vector": [0.1, 0.2, 0.3] }
410        ]);
411        let (ops, _) = resolve_batch(&batch, Scope::Shared).unwrap();
412        match &ops[0] {
413            Op::SetEmbedding { model, vector, .. } => {
414                assert_eq!(model, "m");
415                assert_eq!(vector.len(), 3);
416            }
417            other => panic!("expected SetEmbedding, got {other:?}"),
418        }
419    }
420
421    #[test]
422    fn create_node_with_arbitrary_label_props_and_backref() {
423        let batch = serde_json::json!([
424            { "op": "create_node", "label": "Episode",
425              "props": { "goal": "fix the bug", "turns": 3 } },
426            { "op": "create_node", "label": "RetrievalEvent",
427              "props": { "query": "bug history" } },
428            { "op": "link", "from": "#0", "to": "#1", "type": "ISSUED" }
429        ]);
430        let (ops, ids) = resolve_batch(&batch, Scope::Shared).unwrap();
431        assert_eq!(ops.len(), 3);
432        assert!(ids[0].is_some() && ids[1].is_some() && ids[2].is_some());
433        match &ops[0] {
434            Op::CreateNode { label, props, .. } => {
435                assert_eq!(label, "Episode");
436                assert_eq!(
437                    props.get("goal"),
438                    Some(&PropValue::Str("fix the bug".into()))
439                );
440                assert_eq!(props.get("turns"), Some(&PropValue::Int(3)));
441            }
442            other => panic!("expected CreateNode, got {other:?}"),
443        }
444        match &ops[2] {
445            Op::CreateEdge { from, to, .. } => {
446                assert_eq!(from.to_string(), ids[0].clone().unwrap());
447                assert_eq!(to.to_string(), ids[1].clone().unwrap());
448            }
449            other => panic!("expected CreateEdge, got {other:?}"),
450        }
451    }
452
453    #[test]
454    fn link_honours_an_explicit_scope() {
455        let other = ScopeId::new();
456        let batch = serde_json::json!([
457            { "op": "create_entity", "name": "a" },
458            { "op": "create_entity", "name": "b" },
459            { "op": "link", "from": "#0", "to": "#1", "type": "x", "scope": other.to_string() }
460        ]);
461        // Default scope is Shared; the link must land in `other`, NOT the default.
462        let (ops, _produced) = resolve_batch(&batch, Scope::Shared).unwrap();
463        match &ops[2] {
464            Op::CreateEdge { scope, .. } => assert_eq!(*scope, Scope::Id(other)),
465            other_op => panic!("expected CreateEdge, got {other_op:?}"),
466        }
467    }
468
469    #[test]
470    fn create_node_requires_nonempty_label() {
471        for batch in [
472            serde_json::json!([{ "op": "create_node" }]),
473            serde_json::json!([{ "op": "create_node", "label": "" }]),
474        ] {
475            assert!(resolve_batch(&batch, Scope::Shared).is_err());
476        }
477    }
478
479    #[test]
480    fn create_node_rejects_reserved_labels() {
481        for (label, dedicated) in [("Entity", "create_entity"), ("Memory", "create_memory")] {
482            let batch = serde_json::json!([{ "op": "create_node", "label": label }]);
483            let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
484            assert!(
485                err.contains("#0") && err.contains("reserved") && err.contains(dedicated),
486                "label {label:?}: got: {err}"
487            );
488        }
489    }
490
491    #[test]
492    fn create_node_scope_field_is_honored() {
493        let batch = serde_json::json!([
494            { "op": "create_node", "label": "Harness", "scope": "shared" }
495        ]);
496        let (ops, _) = resolve_batch(&batch, Scope::Id(topodb::ScopeId::new())).unwrap();
497        match &ops[0] {
498            Op::CreateNode { scope, .. } => assert_eq!(*scope, Scope::Shared),
499            other => panic!("expected CreateNode, got {other:?}"),
500        }
501    }
502
503    #[test]
504    fn link_without_scope_falls_back_to_the_default() {
505        let default_id = ScopeId::new();
506        let batch = serde_json::json!([
507            { "op": "create_entity", "name": "a" },
508            { "op": "create_entity", "name": "b" },
509            { "op": "link", "from": "#0", "to": "#1", "type": "x" }
510        ]);
511        let (ops, _produced) = resolve_batch(&batch, Scope::Id(default_id)).unwrap();
512        match &ops[2] {
513            Op::CreateEdge { scope, .. } => assert_eq!(*scope, Scope::Id(default_id)),
514            other_op => panic!("expected CreateEdge, got {other_op:?}"),
515        }
516    }
517}