salamander/json.rs
1//! WP-5 seam — the dynamic-JSON payload surface the Python bindings bind to.
2//!
3//! A Python event is a `dict` ⇒ a JSON object, so the natural payload is
4//! `serde_json::Value`. But there is a sharp edge worth understanding:
5//! **`serde_json::Value` cannot be deserialized by the engine's `bincode`
6//! payload codec.** `Value` decodes via serde's `deserialize_any` (it works
7//! out its shape from the input), and bincode is *not* a self-describing
8//! format — it has no shape markers in the bytes, so it returns an error
9//! rather than guess. `Salamander<serde_json::Value>` would therefore
10//! compile but fail at replay.
11//!
12//! [`Json`] sidesteps this with no engine change: it wraps a `Value` but
13//! serializes *through JSON text* — as a length-prefixed string, which
14//! bincode round-trips fine. So [`JsonDb`] = `Salamander<Json>` gives Python
15//! dynamic JSON payloads over the whole existing stack (log, projections,
16//! time-travel, query layer, group commit). The payload generalization from
17//! WP-1 is what makes this a ~40-line newtype rather than a rewrite.
18//!
19//! (A future *self-describing* payload-format version could store `Value`
20//! natively and drop the text round-trip — WP-2's payload-format versioning
21//! left that door open. `Json` is the ships-today answer; see
22//! `docs/phase-1.5.md` §4.1.)
23//!
24//! Note `fork` / `session_view` are agent-vocabulary operations on
25//! `Salamander<agent::EventBody>` and are *not* available on a `JsonDb`;
26//! dynamic-JSON users get the generic engine surface and layer any agent
27//! semantics on top in Python.
28//!
29//! ```
30//! use salamander::{Json, JsonDb};
31//! use serde_json::json;
32//!
33//! # fn main() -> salamander::Result<()> {
34//! let dir = tempfile::tempdir().unwrap();
35//!
36//! let mut db: JsonDb = JsonDb::open(dir.path())?;
37//! db.append("session-1", Json(json!({ "kind": "user_msg", "text": "hi" })))?;
38//! db.append("session-1", Json(json!({ "kind": "tool_call", "tool": "search" })))?;
39//! db.commit()?;
40//! drop(db); // release the single-writer lock before reopening
41//!
42//! // Reopen from disk and read the JSON payloads straight back out.
43//! let db: JsonDb = JsonDb::open(dir.path())?;
44//! let mut kinds = Vec::new();
45//! db.replay("session-1", 0..db.head(), |e| {
46//! kinds.push(e.body.get("kind").unwrap().as_str().unwrap().to_string());
47//! })?;
48//! assert_eq!(kinds, ["user_msg", "tool_call"]);
49//! # Ok(())
50//! # }
51//! ```
52
53use std::ops::Deref;
54
55use serde::{Deserialize, Deserializer, Serialize, Serializer};
56use serde_json::Value;
57
58use crate::Salamander;
59
60/// A dynamic-JSON payload: a `serde_json::Value` that serializes through
61/// JSON text so it round-trips under the engine's bincode codec (see the
62/// [module docs](self) for why the bare `Value` does not). `Deref`s to the
63/// inner `Value`, so `payload.get("field")`, `.as_str()`, etc. work directly.
64#[derive(Debug, Clone, PartialEq)]
65pub struct Json(pub Value);
66
67impl Json {
68 /// Consume the wrapper, yielding the inner `serde_json::Value`.
69 pub fn into_inner(self) -> Value {
70 self.0
71 }
72}
73
74impl From<Value> for Json {
75 fn from(value: Value) -> Self {
76 Json(value)
77 }
78}
79
80impl Deref for Json {
81 type Target = Value;
82 fn deref(&self) -> &Value {
83 &self.0
84 }
85}
86
87impl Serialize for Json {
88 /// Serialize as a JSON *string* — bincode encodes that as a
89 /// length-prefixed byte run, which its deserializer can read back
90 /// without any self-describing shape markers.
91 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
92 let text = serde_json::to_string(&self.0).map_err(serde::ser::Error::custom)?;
93 serializer.serialize_str(&text)
94 }
95}
96
97impl<'de> Deserialize<'de> for Json {
98 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
99 // `String::deserialize` asks the format for a string, not
100 // `deserialize_any` — so bincode can satisfy it. Then re-parse the
101 // JSON text into a `Value`.
102 let text = String::deserialize(deserializer)?;
103 serde_json::from_str(&text)
104 .map(Json)
105 .map_err(serde::de::Error::custom)
106 }
107}
108
109/// A `Salamander` whose payload is dynamic JSON (via [`Json`]) — the
110/// FFI-facing surface for the Phase-1.5 Python bindings (WP-5). A Python
111/// `dict` maps to a `Json`, so events cross the boundary without schema
112/// codegen. See the [module docs](self).
113pub type JsonDb = Salamander<Json>;