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