1use crate::envelope::{Envelope, CURRENT_SCHEMA_VERSION};
4use crate::op::Op;
5
6#[derive(Clone, Debug)]
12pub struct ChangeSet {
13 pub envelope: Envelope,
15 pub ops: Vec<Op>,
17}
18
19impl ChangeSet {
20 pub fn new(envelope: Envelope, ops: Vec<Op>) -> Self {
22 Self { envelope, ops }
23 }
24}
25
26#[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
48pub 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
73pub 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 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
114fn 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 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 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}