Skip to main content

khive_changeset/
changeset.rs

1//! The change-set: an envelope plus its ordered op-list, and NDJSON-delta codec.
2
3use crate::envelope::{Envelope, CURRENT_SCHEMA_VERSION};
4use crate::op::Op;
5
6/// Envelope metadata plus an ordered list of staged operations.
7///
8/// Operation order is semantically load-bearing (a `link` may target an
9/// earlier `create` ID) and is preserved exactly by the NDJSON codec.
10/// See `crates/khive-changeset/docs/api/ndjson-codec.md` for the wire contract.
11#[derive(Clone, Debug)]
12pub struct ChangeSet {
13    /// Change-set-wide stage-time provenance.
14    pub envelope: Envelope,
15    /// Operations in semantic application order.
16    pub ops: Vec<Op>,
17}
18
19impl ChangeSet {
20    /// Stores `envelope` and `ops` without reordering or validation.
21    pub fn new(envelope: Envelope, ops: Vec<Op>) -> Self {
22        Self { envelope, ops }
23    }
24}
25
26/// In-memory NDJSON encode/decode errors; no variant represents I/O.
27///
28/// See `crates/khive-changeset/docs/api/ndjson-codec.md` for variant semantics.
29#[derive(Debug, thiserror::Error)]
30pub enum ChangeSetError {
31    #[error("NDJSON-delta input is empty; expected an envelope header line")]
32    Empty,
33    #[error("line {line} is not valid JSON: {source}")]
34    MalformedLine {
35        line: usize,
36        #[source]
37        source: serde_json::Error,
38    },
39    #[error(
40        "envelope schema_version {found} is not supported (this crate emits and \
41         accepts schema_version {expected})"
42    )]
43    UnsupportedSchemaVersion { found: u32, expected: u32 },
44    #[error("failed to serialize change-set: {0}")]
45    Serialize(#[source] serde_json::Error),
46}
47
48/// Encodes the envelope as line one and each op on a following line, in order.
49///
50/// The returned in-memory string ends every line, including the last, with `\n`.
51///
52/// # Errors
53///
54/// Returns [`ChangeSetError::UnsupportedSchemaVersion`] for a stale envelope, or
55/// [`ChangeSetError::Serialize`] if any envelope or op cannot serialize.
56pub fn to_ndjson(changeset: &ChangeSet) -> Result<String, ChangeSetError> {
57    if changeset.envelope.schema_version != CURRENT_SCHEMA_VERSION {
58        return Err(ChangeSetError::UnsupportedSchemaVersion {
59            found: changeset.envelope.schema_version,
60            expected: CURRENT_SCHEMA_VERSION,
61        });
62    }
63    let mut out = String::new();
64    out.push_str(&serde_json::to_string(&changeset.envelope).map_err(ChangeSetError::Serialize)?);
65    out.push('\n');
66    for op in &changeset.ops {
67        out.push_str(&serde_json::to_string(op).map_err(ChangeSetError::Serialize)?);
68        out.push('\n');
69    }
70    Ok(out)
71}
72
73/// Decodes an envelope header and every following line as one ordered [`Op`].
74///
75/// Blank lines and unknown fields are errors; schema version must equal
76/// [`CURRENT_SCHEMA_VERSION`].
77///
78/// # Errors
79///
80/// Returns [`ChangeSetError::Empty`], [`ChangeSetError::MalformedLine`], or
81/// [`ChangeSetError::UnsupportedSchemaVersion`] according to the failed stage.
82/// See `crates/khive-changeset/docs/api/ndjson-codec.md` for line semantics.
83pub fn from_ndjson(input: &str) -> Result<ChangeSet, ChangeSetError> {
84    let mut lines = input.lines().enumerate();
85    let (_, first_line) = lines.next().ok_or(ChangeSetError::Empty)?;
86    reject_repeated_members(first_line)
87        .map_err(|source| ChangeSetError::MalformedLine { line: 1, source })?;
88    let envelope: Envelope = serde_json::from_str(first_line)
89        .map_err(|source| ChangeSetError::MalformedLine { line: 1, source })?;
90    if envelope.schema_version != CURRENT_SCHEMA_VERSION {
91        return Err(ChangeSetError::UnsupportedSchemaVersion {
92            found: envelope.schema_version,
93            expected: CURRENT_SCHEMA_VERSION,
94        });
95    }
96
97    // Parse blank lines too, preserving “every post-header line is an op.”
98    let mut ops = Vec::new();
99    for (idx, line) in lines {
100        reject_repeated_members(line).map_err(|source| ChangeSetError::MalformedLine {
101            line: idx + 1,
102            source,
103        })?;
104        let op: Op =
105            serde_json::from_str(line).map_err(|source| ChangeSetError::MalformedLine {
106                line: idx + 1,
107                source,
108            })?;
109        ops.push(op);
110    }
111    Ok(ChangeSet { envelope, ops })
112}
113
114/// Refuses a line in which any JSON object, at any depth, names the same member twice.
115///
116/// The typed decode keeps only the last of a repeated name, so a caller's scan of the
117/// decoded values would never see the earlier copies while the stored text still holds
118/// them. Names are compared after JSON unescaping, and the error names a position only,
119/// never the member or its value.
120fn reject_repeated_members(line: &str) -> Result<(), serde_json::Error> {
121    serde_json::from_str::<UniqueMembers>(line).map(|_| ())
122}
123
124struct UniqueMembers;
125
126impl<'de> serde::Deserialize<'de> for UniqueMembers {
127    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
128        deserializer.deserialize_any(UniqueMembersVisitor)
129    }
130}
131
132struct UniqueMembersVisitor;
133
134impl<'de> serde::de::Visitor<'de> for UniqueMembersVisitor {
135    type Value = UniqueMembers;
136
137    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        formatter.write_str("a JSON value")
139    }
140
141    fn visit_bool<E>(self, _: bool) -> Result<UniqueMembers, E> {
142        Ok(UniqueMembers)
143    }
144
145    fn visit_i64<E>(self, _: i64) -> Result<UniqueMembers, E> {
146        Ok(UniqueMembers)
147    }
148
149    fn visit_u64<E>(self, _: u64) -> Result<UniqueMembers, E> {
150        Ok(UniqueMembers)
151    }
152
153    fn visit_f64<E>(self, _: f64) -> Result<UniqueMembers, E> {
154        Ok(UniqueMembers)
155    }
156
157    fn visit_str<E>(self, _: &str) -> Result<UniqueMembers, E> {
158        Ok(UniqueMembers)
159    }
160
161    fn visit_unit<E>(self) -> Result<UniqueMembers, E> {
162        Ok(UniqueMembers)
163    }
164
165    fn visit_seq<A: serde::de::SeqAccess<'de>>(
166        self,
167        mut seq: A,
168    ) -> Result<UniqueMembers, A::Error> {
169        while seq.next_element::<UniqueMembers>()?.is_some() {}
170        Ok(UniqueMembers)
171    }
172
173    fn visit_map<A: serde::de::MapAccess<'de>>(
174        self,
175        mut map: A,
176    ) -> Result<UniqueMembers, A::Error> {
177        let mut seen = std::collections::HashSet::new();
178        while let Some(name) = map.next_key::<String>()? {
179            if !seen.insert(name) {
180                return Err(serde::de::Error::custom("an object repeats a member name"));
181            }
182            map.next_value::<UniqueMembers>()?;
183        }
184        Ok(UniqueMembers)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::op::{CreateOp, CreateTarget, EntityCreateFields};
192    use khive_types::{EntityKind, Id128, Namespace, Timestamp};
193
194    fn sample_changeset() -> ChangeSet {
195        let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
196        let create = Op::Create(CreateOp {
197            id: Id128::from_u128(1),
198            namespace: Namespace::local(),
199            target: CreateTarget::Entity(EntityCreateFields {
200                entity_kind: EntityKind::Concept,
201                entity_type: None,
202                name: "X".into(),
203                description: None,
204                properties: Default::default(),
205                tags: vec![],
206            }),
207        });
208        ChangeSet::new(envelope, vec![create])
209    }
210
211    #[test]
212    fn round_trips_envelope_and_ops() {
213        let cs = sample_changeset();
214        let text = to_ndjson(&cs).unwrap();
215        let decoded = from_ndjson(&text).unwrap();
216        assert_eq!(decoded.envelope, cs.envelope);
217        assert_eq!(decoded.ops.len(), 1);
218        let text2 = to_ndjson(&decoded).unwrap();
219        assert_eq!(text, text2, "re-serialization must be byte-identical");
220    }
221
222    #[test]
223    fn envelope_is_line_one() {
224        let cs = sample_changeset();
225        let text = to_ndjson(&cs).unwrap();
226        let first_line = text.lines().next().unwrap();
227        let parsed_env: Envelope = serde_json::from_str(first_line).unwrap();
228        assert_eq!(parsed_env, cs.envelope);
229    }
230
231    #[test]
232    fn envelope_batch_id_round_trips_through_ndjson() {
233        let mut cs = sample_changeset();
234        cs.envelope = cs.envelope.with_batch_id("batch-xyz");
235        let text = to_ndjson(&cs).unwrap();
236        let decoded = from_ndjson(&text).unwrap();
237        assert_eq!(decoded.envelope.batch_id.as_deref(), Some("batch-xyz"));
238        assert_eq!(decoded.envelope, cs.envelope);
239    }
240
241    #[test]
242    fn envelope_without_batch_id_round_trips_through_ndjson() {
243        let cs = sample_changeset();
244        assert_eq!(cs.envelope.batch_id, None);
245        let text = to_ndjson(&cs).unwrap();
246        let decoded = from_ndjson(&text).unwrap();
247        assert_eq!(decoded.envelope.batch_id, None);
248        assert_eq!(decoded.envelope, cs.envelope);
249    }
250
251    #[test]
252    fn empty_input_errors() {
253        let result = from_ndjson("");
254        assert!(matches!(result, Err(ChangeSetError::Empty)));
255    }
256
257    #[test]
258    fn malformed_op_line_reports_correct_line_number() {
259        let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
260        let env_line = serde_json::to_string(&envelope).unwrap();
261        let text = format!("{env_line}\n{{\"op\": \"create\", not json}}\n");
262        let err = from_ndjson(&text).unwrap_err();
263        match err {
264            ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
265            other => panic!("expected MalformedLine, got {other:?}"),
266        }
267    }
268
269    #[test]
270    fn blank_line_between_ops_is_malformed() {
271        let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
272        let env_line = serde_json::to_string(&envelope).unwrap();
273        let text = format!("{env_line}\n\n");
274        let err = from_ndjson(&text).unwrap_err();
275        match err {
276            ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
277            other => panic!("expected MalformedLine, got {other:?}"),
278        }
279    }
280
281    /// One sample create, its properties holding `first` then `second` under one name.
282    fn create_line_with_repeated_property(first: &str, second: &str) -> String {
283        let id = uuid_text(7);
284        format!(
285            "{{\"op\":\"create\",\"id\":\"{id}\",\"namespace\":\"local\",\"target\":{{\
286             \"kind\":\"entity\",\"entity_kind\":\"concept\",\"name\":\"X\",\
287             \"properties\":{{\"{first}\":\"kept-out\",\"{second}\":\"benign\"}}}}}}"
288        )
289    }
290
291    fn uuid_text(n: u128) -> String {
292        serde_json::to_value(Id128::from_u128(n))
293            .unwrap()
294            .as_str()
295            .unwrap()
296            .to_owned()
297    }
298
299    fn env_line() -> String {
300        let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
301        serde_json::to_string(&envelope).unwrap()
302    }
303
304    #[test]
305    fn a_distinct_property_map_decodes() {
306        let text = format!(
307            "{}\n{}\n",
308            env_line(),
309            create_line_with_repeated_property("label", "other")
310        );
311        let decoded = from_ndjson(&text).expect("distinct names decode");
312        match &decoded.ops[0] {
313            Op::Create(CreateOp {
314                target: CreateTarget::Entity(fields),
315                ..
316            }) => assert_eq!(fields.properties.len(), 2),
317            other => panic!("expected an entity create, got {other:?}"),
318        }
319    }
320
321    #[test]
322    fn a_repeated_nested_member_is_malformed_and_the_error_names_no_bytes() {
323        let text = format!(
324            "{}\n{}\n",
325            env_line(),
326            create_line_with_repeated_property("label", "label")
327        );
328        let err = from_ndjson(&text).unwrap_err();
329        let shown = err.to_string();
330        match err {
331            ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
332            other => panic!("expected MalformedLine, got {other:?}"),
333        }
334        assert!(shown.contains("repeats a member name"), "{shown}");
335        assert!(!shown.contains("label"), "{shown}");
336        assert!(!shown.contains("kept-out"), "{shown}");
337        assert!(!shown.contains("benign"), "{shown}");
338    }
339
340    #[test]
341    fn member_names_are_compared_after_unescaping() {
342        // `label` decodes to `label`, so the two spellings are one member name.
343        let text = format!(
344            "{}\n{}\n",
345            env_line(),
346            create_line_with_repeated_property("\\u006cabel", "label")
347        );
348        assert!(matches!(
349            from_ndjson(&text),
350            Err(ChangeSetError::MalformedLine { line: 2, .. })
351        ));
352    }
353
354    #[test]
355    fn a_repeated_top_level_or_envelope_member_is_malformed() {
356        let op = create_line_with_repeated_property("label", "other");
357        let repeated_op = op.replacen(
358            "{\"op\":\"create\",",
359            "{\"op\":\"create\",\"op\":\"create\",",
360            1,
361        );
362        assert_ne!(repeated_op, op);
363        let text = format!("{}\n{repeated_op}\n", env_line());
364        assert!(matches!(
365            from_ndjson(&text),
366            Err(ChangeSetError::MalformedLine { line: 2, .. })
367        ));
368
369        let env = env_line();
370        let repeated_env = env.replacen('{', "{\"schema_version\":1,", 1);
371        assert!(env.contains("\"schema_version\""), "{env}");
372        let text = format!("{repeated_env}\n{op}\n");
373        assert!(matches!(
374            from_ndjson(&text),
375            Err(ChangeSetError::MalformedLine { line: 1, .. })
376        ));
377    }
378
379    #[test]
380    fn unsupported_schema_version_is_rejected() {
381        let mut envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
382        envelope.schema_version = 99;
383        let env_line = serde_json::to_string(&envelope).unwrap();
384        let err = from_ndjson(&env_line).unwrap_err();
385        assert!(matches!(
386            err,
387            ChangeSetError::UnsupportedSchemaVersion {
388                found: 99,
389                expected: CURRENT_SCHEMA_VERSION
390            }
391        ));
392    }
393
394    #[test]
395    fn op_order_is_preserved() {
396        let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
397        let mk_create = |n: u128| {
398            Op::Create(CreateOp {
399                id: Id128::from_u128(n),
400                namespace: Namespace::local(),
401                target: CreateTarget::Entity(EntityCreateFields {
402                    entity_kind: EntityKind::Concept,
403                    entity_type: None,
404                    name: format!("entity-{n}"),
405                    description: None,
406                    properties: Default::default(),
407                    tags: vec![],
408                }),
409            })
410        };
411        let ops = vec![mk_create(3), mk_create(1), mk_create(2)];
412        let cs = ChangeSet::new(envelope, ops);
413        let text = to_ndjson(&cs).unwrap();
414        let decoded = from_ndjson(&text).unwrap();
415        let ids: Vec<u128> = decoded
416            .ops
417            .iter()
418            .map(|op| match op {
419                Op::Create(c) => c.id.to_u128(),
420                _ => unreachable!(),
421            })
422            .collect();
423        assert_eq!(ids, vec![3, 1, 2]);
424    }
425}