mushroomdb-server 0.10.4

HTTP/WebSocket server and MCP adapter for mushroomdb
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Shared JSON wire shapes for HTTP (`?format=json`) and MCP tool payloads.
//!
//! Cells are untagged JSON scalars (the inverse of [`mushroomdb::json_to_value`]),
//! not `Value`'s internally-tagged serde form.

use mushroomdb::{
    default_max_edges, json_to_value, tagged_json, EdgeEvent, EdgeHistoryEvent, EdgeInfo,
    HistoryChange, HistoryEntry, HistoryResult, MaskedEdge, NodeInfo, ResultSet, RuleDef, Value,
};
use serde_json::{json, Value as Js};
use std::collections::BTreeMap;

pub(crate) fn value_to_json(v: &Value) -> Js {
    match v {
        Value::Int(i) => json!(i),
        Value::Float(f) => serde_json::Number::from_f64(*f)
            .map(Js::Number)
            .unwrap_or(Js::Null),
        Value::Str(s) => json!(s),
        Value::Bool(b) => json!(b),
        Value::List(xs) => Js::Array(xs.iter().map(value_to_json).collect()),
        Value::Map(m) => {
            let obj: serde_json::Map<String, Js> = m
                .iter()
                .map(|(k, v)| (k.clone(), value_to_json(v)))
                .collect();
            Js::Object(obj)
        }
        Value::Date(_)
        | Value::LocalDateTime(_)
        | Value::DateTime { .. }
        | Value::Duration { .. }
        // `Err` is a DateTime offset outside ±18:00, which no value reaching
        // the server can carry: params are parsed from JSON, which refuses one,
        // and the store holds no format-V11 value. No tag is written for it.
        | Value::Binary(_) => tagged_json(v).ok().flatten().unwrap_or(Js::Null),
        // A null list element or map value.
        Value::Null => Js::Null,
    }
}

pub(crate) fn node_info_json(info: &NodeInfo) -> Js {
    let props: serde_json::Map<String, Js> = info
        .props
        .iter()
        .map(|(k, v)| (k.clone(), value_to_json(v)))
        .collect();
    json!({
        "key": info.key,
        "label": info.label,
        "props": props,
    })
}

pub(crate) fn node_edges_json(edges: &[EdgeInfo]) -> Js {
    json!({
        "edges": edges.iter().map(|e| json!({
            "edge_type": e.edge_type,
            "src_key": e.src_key,
            "dst_key": e.dst_key,
            "derived": e.derived,
        })).collect::<Vec<_>>()
    })
}

/// Stub JSON for a node that exists but is hidden from a mask.
/// Shape: `{"key": "<key>", "restricted": true}` — no other fields.
pub(crate) fn stub_node_json(key: &str) -> Js {
    json!({"key": key, "restricted": true})
}

/// Render a [`MaskedEdge`] list to the `node_edges` wire shape.
///
/// Hidden endpoints are rendered as `{"key": "<key>", "restricted": true}`;
/// visible endpoints are rendered as plain key strings.
pub(crate) fn masked_edges_json(edges: &[MaskedEdge]) -> Js {
    let items: Vec<Js> = edges
        .iter()
        .map(|e| {
            let src = if e.src_restricted {
                json!({"key": e.src_key, "restricted": true})
            } else {
                json!(e.src_key)
            };
            let dst = if e.dst_restricted {
                json!({"key": e.dst_key, "restricted": true})
            } else {
                json!(e.dst_key)
            };
            json!({
                "edge_type": e.edge_type,
                "src_key": src,
                "dst_key": dst,
                "derived": e.derived,
            })
        })
        .collect();
    json!({"edges": items})
}

pub(crate) fn result_set_json(rs: &ResultSet) -> Js {
    let columns: Vec<&str> = rs.columns().iter().map(String::as_str).collect();
    let rows: Vec<Vec<Js>> = (0..rs.len())
        .map(|i| {
            rs.row(i)
                .iter()
                .map(|cell| cell.as_ref().map(value_to_json).unwrap_or(Js::Null))
                .collect()
        })
        .collect();
    json!({ "columns": columns, "rows": rows })
}

/// Map JSON object values to query parameters. Scalars, arrays and objects are
/// all accepted, and a JSON `null` binds as [`Value::Null`] at the top level and
/// inside lists and maps, matching Bolt (QL-17i).
pub(crate) fn params_from_json(v: Option<&Js>) -> Result<BTreeMap<String, Value>, String> {
    let Some(v) = v else {
        return Ok(BTreeMap::new());
    };
    let obj = v
        .as_object()
        .ok_or_else(|| "params must be an object".to_string())?;
    let mut out = BTreeMap::new();
    for (k, val) in obj {
        let mapped = param_from_json(val).map_err(|e| format!("param {k:?}: {e}"))?;
        out.insert(k.clone(), mapped);
    }
    Ok(out)
}

/// One parameter value. [`json_to_value`] drops nulls (right for stored
/// fields), so only the shapes that can hold a null are walked here; a typed
/// object (`{"$date": …}`) and every scalar go through [`json_to_value`].
fn param_from_json(v: &Js) -> Result<Value, String> {
    match v {
        Js::Null => Ok(Value::Null),
        Js::Array(items) => items
            .iter()
            .enumerate()
            .map(|(i, item)| param_from_json(item).map_err(|e| format!("item {i}: {e}")))
            .collect::<Result<Vec<_>, _>>()
            .map(Value::List),
        Js::Object(obj) => {
            // A single `$`-keyed object may be a typed value; any other result
            // is a map, which is rebuilt below so its nulls are kept.
            if obj.len() == 1 && obj.keys().all(|k| k.starts_with('$')) {
                if let Some(typed) = json_to_value(v.clone())? {
                    if !matches!(typed, Value::Map(_)) {
                        return Ok(typed);
                    }
                }
            }
            obj.iter()
                .map(|(k, item)| {
                    param_from_json(item)
                        .map(|val| (k.clone(), val))
                        .map_err(|e| format!("key {k:?}: {e}"))
                })
                .collect::<Result<BTreeMap<_, _>, _>>()
                .map(Value::Map)
        }
        scalar => {
            json_to_value(scalar.clone())?.ok_or_else(|| "value cannot be a parameter".to_string())
        }
    }
}

/// User edges for POST /ingest and MCP `ingest_json`.
///
/// Shape: `[{edge_type, src, dst}]`. Missing fields are a 400 string;
/// unknown endpoints fail later via `insert_edge` (`KeyNotFound` → 400).
pub(crate) fn parse_ingest_edges(v: &Js) -> Result<Vec<(String, String, String)>, String> {
    let arr = v
        .as_array()
        .ok_or_else(|| "edges must be an array".to_string())?;
    let mut out = Vec::with_capacity(arr.len());
    for (i, item) in arr.iter().enumerate() {
        let obj = item
            .as_object()
            .ok_or_else(|| format!("edges[{i}] must be an object"))?;
        let edge_type = obj
            .get("edge_type")
            .and_then(Js::as_str)
            .ok_or_else(|| format!("edges[{i}] missing edge_type"))?;
        let src = obj
            .get("src")
            .and_then(Js::as_str)
            .ok_or_else(|| format!("edges[{i}] missing src"))?;
        let dst = obj
            .get("dst")
            .and_then(Js::as_str)
            .ok_or_else(|| format!("edges[{i}] missing dst"))?;
        out.push((edge_type.to_string(), src.to_string(), dst.to_string()));
    }
    Ok(out)
}

/// Serialize a [`HistoryChange`] variant to an untagged JSON object.
///
/// The `"type"` field names the variant; payload fields follow inline.
pub(crate) fn history_change_json(change: &HistoryChange) -> Js {
    match change {
        HistoryChange::NodeInserted { label } => json!({"type": "NodeInserted", "label": label}),
        HistoryChange::PropSet { field, value } => json!({
            "type": "PropSet",
            "field": field,
            "value": value_to_json(value),
        }),
        HistoryChange::PropRemoved { field } => json!({"type": "PropRemoved", "field": field}),
        HistoryChange::EdgeAdded {
            edge_type,
            other,
            outgoing,
        } => json!({
            "type": "EdgeAdded",
            "edge_type": edge_type,
            "other": other,
            "outgoing": outgoing,
        }),
        HistoryChange::EdgeRemoved {
            edge_type,
            other,
            outgoing,
        } => json!({
            "type": "EdgeRemoved",
            "edge_type": edge_type,
            "other": other,
            "outgoing": outgoing,
        }),
        HistoryChange::NodeDeleted => json!({"type": "NodeDeleted"}),
    }
}

pub(crate) fn history_entry_json(entry: &HistoryEntry) -> Js {
    json!({
        "commit": entry.commit,
        "change": history_change_json(&entry.change),
    })
}

pub(crate) fn edge_history_result_json(
    a: &str,
    b: &str,
    result: &HistoryResult<EdgeHistoryEvent>,
) -> Js {
    let events: Vec<Js> = result
        .items
        .iter()
        .map(|ev| {
            let event_str = match ev.event {
                EdgeEvent::Added => "Added",
                EdgeEvent::Retracted => "Retracted",
            };
            json!({
                "edge_type": ev.edge_type,
                "commit": ev.commit,
                "event": event_str,
                "rule": ev.rule,
            })
        })
        .collect();
    json!({
        "a": a,
        "b": b,
        "events": events,
        "total_commits": result.total_commits,
        "horizon": result.horizon,
    })
}

pub(crate) fn node_history_json(key: &str, result: &HistoryResult<HistoryEntry>) -> Js {
    let history: Vec<Js> = result.items.iter().map(history_entry_json).collect();
    json!({
        "key": key,
        "history": history,
        "total_commits": result.total_commits,
        "horizon": result.horizon,
    })
}

/// Deserialize a `RuleDef` from HTTP/MCP JSON. Omitted or null `max_edges`
/// fills `default_max_edges`; omitted or null `weight_prop` fills `"weight"`
/// so a rule created over the wire always stores its score somewhere
/// queryable. Do not add `#[serde(default)]` on `RuleDef.max_edges` — bincode
/// is positional.
/// A `namespace` argument, from an MCP tool's `arguments` or an HTTP body.
///
/// Absent or `null` is no namespace restriction — not the `default` namespace,
/// which is what `"default"` asks for. Anything that is not a string, and any
/// string that is not a namespace name, is refused here rather than resolved to
/// an empty mask: a typo that silently answers "nothing" reads like an empty
/// store.
///
/// One parser for every surface, so `query`, the write tools and the HTTP
/// handlers all say the same thing about the same input.
pub(crate) fn namespace_arg(v: Option<&Js>) -> Result<Option<String>, String> {
    match v {
        None | Some(Js::Null) => Ok(None),
        Some(Js::String(s)) if mushroomdb::valid_namespace(s) => Ok(Some(s.clone())),
        Some(Js::String(s)) => Err(format!(
            "namespace {s:?} is not a valid namespace name — 1 to {} characters of [A-Za-z0-9_.-]",
            mushroomdb::NS_MAX_LEN
        )),
        Some(_) => Err("namespace must be a string naming a namespace".into()),
    }
}

/// Stamp `namespace` onto every row an ingest-shaped write will create.
///
/// A row that already names its own `ns` must name the same one: the call and
/// the row disagreeing is a caller that has not decided which namespace it
/// meant, and silently letting either win would put a node somewhere nobody
/// asked for. Refused before anything is written, naming the row.
///
/// `None` leaves the rows exactly as they came, so a call that passes no
/// namespace behaves as it did before namespaces existed.
pub(crate) fn stamp_namespace(
    rows: &mut [BTreeMap<String, Value>],
    namespace: Option<&str>,
) -> Result<(), String> {
    let Some(ns) = namespace else {
        return Ok(());
    };
    for (i, row) in rows.iter_mut().enumerate() {
        stamp_namespace_row(row, ns).map_err(|e| format!("row {i}: {e}"))?;
    }
    Ok(())
}

/// [`stamp_namespace`] for a write that creates one node.
pub(crate) fn stamp_namespace_row(
    row: &mut BTreeMap<String, Value>,
    namespace: &str,
) -> Result<(), String> {
    match row.get(mushroomdb::NS_PROP) {
        Some(Value::Str(s)) if s == namespace => Ok(()),
        Some(existing) => Err(format!(
            "ns is {existing:?} but the call names namespace {namespace:?}; a node is created in \
             one namespace, so pass one or the other"
        )),
        None => {
            row.insert(
                mushroomdb::NS_PROP.to_string(),
                Value::Str(namespace.to_string()),
            );
            Ok(())
        }
    }
}

pub(crate) fn rule_def_from_json(v: Js) -> Result<RuleDef, String> {
    let mut def: RuleDef = serde_json::from_value(v).map_err(|e| e.to_string())?;
    if def.max_edges.is_none() {
        def.max_edges = Some(default_max_edges(&def.predicate));
    }
    if def.weight_prop.is_none() {
        def.weight_prop = Some("weight".into());
    }
    Ok(def)
}

#[cfg(test)]
mod tests {
    use super::value_to_json;
    use mushroomdb::{json_to_value, Value};
    use serde_json::json;

    /// Every format-V11 type prints in its tagged wire form, and that form
    /// parses back to the same value.
    #[test]
    fn test_value_json_roundtrip() {
        let cases = [
            (Value::Date(19_737), json!({"$date": "2024-01-15"})),
            (
                Value::LocalDateTime(1_705_322_096_789_012),
                json!({"$ldt": "2024-01-15T12:34:56.789012"}),
            ),
            (
                Value::DateTime {
                    micros_utc: 1_705_302_296_789_012,
                    offset_seconds: 19_800,
                },
                json!({"$dt": "2024-01-15T12:34:56.789012+05:30"}),
            ),
            (
                Value::Duration {
                    months: 14,
                    days: 3,
                    nanos: 14_706_789_000_000,
                },
                json!({"$dur": "P1Y2M3DT4H5M6.789S"}),
            ),
            (
                Value::Binary(vec![0xDE, 0xAD, 0xBE, 0xEF]),
                json!({"$binary": "3q2+7w=="}),
            ),
        ];
        for (value, wire) in cases {
            assert_eq!(value_to_json(&value), wire);
            assert_eq!(json_to_value(wire), Ok(Some(value)));
        }
        // Nested in a list, each keeps its form.
        let list = Value::List(vec![Value::Date(0), Value::Binary(vec![])]);
        assert_eq!(
            value_to_json(&list),
            json!([{"$date": "1970-01-01"}, {"$binary": ""}])
        );
    }
}