1use std::collections::BTreeMap;
4
5use khive_types::{
6 EdgeRelation, Entity, EntityKind, Id128, Link, Namespace, Note, NoteStatus, PropertyValue,
7};
8use serde::{Deserialize, Deserializer, Serialize};
9
10use crate::strict::{StrictEntity, StrictLink, StrictNote};
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
16#[serde(tag = "op", rename_all = "snake_case")]
17pub enum Op {
18 Create(CreateOp),
19 Link(LinkOp),
20 Update(UpdateOp),
21 Delete(DeleteOp),
22 Restore(RestoreOp),
23 Merge(MergeOp),
24}
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct CreateOp {
32 pub id: Id128,
33 pub namespace: Namespace,
34 pub target: CreateTarget,
35}
36
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40pub enum CreateTarget {
41 Entity(EntityCreateFields),
42 Note(NoteCreateFields),
43}
44
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct EntityCreateFields {
49 pub entity_kind: EntityKind,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub entity_type: Option<String>,
52 pub name: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub description: Option<String>,
55 #[serde(default)]
56 pub properties: BTreeMap<String, PropertyValue>,
57 #[serde(default)]
58 pub tags: Vec<String>,
59}
60
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct NoteCreateFields {
65 pub note_kind: String,
67 pub content: String,
68 #[serde(default)]
69 pub properties: BTreeMap<String, PropertyValue>,
70 #[serde(default)]
71 pub tags: Vec<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub salience: Option<f64>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub decay_factor: Option<f64>,
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize)]
84#[serde(into = "LinkOpRaw")]
85pub struct LinkOp {
86 pub id: Id128,
87 pub namespace: Namespace,
88 pub source: Id128,
89 pub target: Id128,
90 pub relation: EdgeRelation,
91 pub weight: f64,
92 pub properties: BTreeMap<String, PropertyValue>,
93}
94
95#[derive(Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97struct LinkOpRaw {
98 id: Id128,
99 namespace: Namespace,
100 source: Id128,
101 target: Id128,
102 relation: EdgeRelation,
103 weight: f64,
104 #[serde(default)]
105 properties: BTreeMap<String, PropertyValue>,
106}
107
108impl From<LinkOp> for LinkOpRaw {
109 fn from(l: LinkOp) -> Self {
110 Self {
111 id: l.id,
112 namespace: l.namespace,
113 source: l.source,
114 target: l.target,
115 relation: l.relation,
116 weight: l.weight,
117 properties: l.properties,
118 }
119 }
120}
121
122impl TryFrom<LinkOpRaw> for LinkOp {
123 type Error = String;
124
125 fn try_from(raw: LinkOpRaw) -> Result<Self, Self::Error> {
126 if !raw.weight.is_finite() {
127 return Err(format!("LinkOp weight must be finite, got {}", raw.weight));
128 }
129 if !khive_types::validate_edge_weight(raw.weight) {
130 return Err(format!(
131 "LinkOp weight must be in [0.0, 1.0], got {}",
132 raw.weight
133 ));
134 }
135 Ok(LinkOp {
136 id: raw.id,
137 namespace: raw.namespace,
138 source: raw.source,
139 target: raw.target,
140 relation: raw.relation,
141 weight: raw.weight,
142 properties: raw.properties,
143 })
144 }
145}
146
147impl<'de> Deserialize<'de> for LinkOp {
148 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149 where
150 D: serde::Deserializer<'de>,
151 {
152 let raw = LinkOpRaw::deserialize(deserializer)?;
153 LinkOp::try_from(raw).map_err(serde::de::Error::custom)
154 }
155}
156
157#[derive(Clone, Debug, PartialEq, Serialize)]
163pub struct UpdateOp {
164 target_id: Id128,
165 patch: UpdatePatch,
166 preimage: UpdatePreimage,
167}
168
169impl UpdateOp {
170 pub fn new(
178 target_id: Id128,
179 patch: UpdatePatch,
180 preimage: UpdatePreimage,
181 ) -> Result<Self, String> {
182 validate_update_congruence(&patch, &preimage)?;
183 Ok(UpdateOp {
184 target_id,
185 patch,
186 preimage,
187 })
188 }
189
190 pub fn target_id(&self) -> Id128 {
192 self.target_id
193 }
194
195 pub fn patch(&self) -> &UpdatePatch {
197 &self.patch
198 }
199
200 pub fn preimage(&self) -> &UpdatePreimage {
202 &self.preimage
203 }
204}
205
206#[derive(Deserialize)]
207#[serde(deny_unknown_fields)]
208struct UpdateOpRaw {
209 target_id: Id128,
210 patch: UpdatePatch,
211 preimage: UpdatePreimage,
212}
213
214impl<'de> Deserialize<'de> for UpdateOp {
215 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216 where
217 D: Deserializer<'de>,
218 {
219 let raw = UpdateOpRaw::deserialize(deserializer)?;
220 UpdateOp::new(raw.target_id, raw.patch, raw.preimage).map_err(serde::de::Error::custom)
221 }
222}
223
224#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228#[serde(tag = "target", rename_all = "snake_case")]
229pub enum UpdatePatch {
230 Entity(EntityPatch),
231 Note(NotePatch),
232 Edge(EdgePatch),
233}
234
235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct EntityPatch {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub name: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
244 pub description: Option<Option<String>>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub properties: Option<BTreeMap<String, PropertyValue>>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub tags: Option<Vec<String>>,
249 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
250 pub entity_type: Option<Option<String>>,
251}
252
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct NotePatch {
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub content: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
260 pub salience: Option<Option<f64>>,
261 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
262 pub decay_factor: Option<Option<f64>>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub properties: Option<BTreeMap<String, PropertyValue>>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub tags: Option<Vec<String>>,
267}
268
269#[derive(Clone, Debug, PartialEq, Serialize)]
271#[serde(into = "EdgePatchRaw")]
272pub struct EdgePatch {
273 pub relation: Option<EdgeRelation>,
274 pub weight: Option<f64>,
275}
276
277#[derive(Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279struct EdgePatchRaw {
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 relation: Option<EdgeRelation>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 weight: Option<f64>,
284}
285
286impl From<EdgePatch> for EdgePatchRaw {
287 fn from(p: EdgePatch) -> Self {
288 Self {
289 relation: p.relation,
290 weight: p.weight,
291 }
292 }
293}
294
295impl TryFrom<EdgePatchRaw> for EdgePatch {
296 type Error = String;
297
298 fn try_from(raw: EdgePatchRaw) -> Result<Self, Self::Error> {
299 let patch = EdgePatch {
300 relation: raw.relation,
301 weight: raw.weight,
302 };
303 patch.validate_weight()?;
304 Ok(patch)
305 }
306}
307
308impl EdgePatch {
309 fn validate_weight(&self) -> Result<(), String> {
310 if let Some(w) = self.weight {
311 if !w.is_finite() {
312 return Err(format!("EdgePatch weight must be finite, got {w}"));
313 }
314 if !khive_types::validate_edge_weight(w) {
315 return Err(format!("EdgePatch weight must be in [0.0, 1.0], got {w}"));
316 }
317 }
318 Ok(())
319 }
320}
321
322impl<'de> Deserialize<'de> for EdgePatch {
323 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
324 where
325 D: Deserializer<'de>,
326 {
327 let raw = EdgePatchRaw::deserialize(deserializer)?;
328 EdgePatch::try_from(raw).map_err(serde::de::Error::custom)
329 }
330}
331
332mod opt_opt {
334 use serde::{Deserialize, Deserializer, Serialize, Serializer};
335
336 pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
337 where
338 T: Serialize,
339 S: Serializer,
340 {
341 match val {
342 None => unreachable!("skip_serializing_if guards the None case"),
343 Some(inner) => inner.serialize(s),
344 }
345 }
346
347 pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
348 where
349 T: Deserialize<'de>,
350 D: Deserializer<'de>,
351 {
352 let opt: Option<T> = Option::deserialize(d)?;
353 Ok(Some(opt))
354 }
355}
356
357#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
359#[serde(deny_unknown_fields)]
360pub struct EntityPreimage {
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub name: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
364 pub description: Option<Option<String>>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub properties: Option<BTreeMap<String, PropertyValue>>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub tags: Option<Vec<String>>,
369 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
370 pub entity_type: Option<Option<String>>,
371}
372
373#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
377#[serde(deny_unknown_fields)]
378pub struct NotePreimage {
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub content: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
382 pub salience: Option<Option<f64>>,
383 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
384 pub decay_factor: Option<Option<f64>>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub properties: Option<BTreeMap<String, PropertyValue>>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub tags: Option<Vec<String>>,
389}
390
391#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
395#[serde(deny_unknown_fields)]
396pub struct EdgePreimage {
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub relation: Option<EdgeRelation>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub weight: Option<f64>,
401}
402
403#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
405#[serde(tag = "target", rename_all = "snake_case")]
406pub enum UpdatePreimage {
407 Entity(EntityPreimage),
408 Note(NotePreimage),
409 Edge(EdgePreimage),
410}
411
412fn validate_update_congruence(
414 patch: &UpdatePatch,
415 preimage: &UpdatePreimage,
416) -> Result<(), String> {
417 match (patch, preimage) {
418 (UpdatePatch::Entity(p), UpdatePreimage::Entity(pre)) => {
419 check_touched("name", p.name.is_some(), pre.name.is_some())?;
420 check_touched(
421 "description",
422 p.description.is_some(),
423 pre.description.is_some(),
424 )?;
425 check_touched(
426 "properties",
427 p.properties.is_some(),
428 pre.properties.is_some(),
429 )?;
430 check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
431 check_touched(
432 "entity_type",
433 p.entity_type.is_some(),
434 pre.entity_type.is_some(),
435 )?;
436 Ok(())
437 }
438 (UpdatePatch::Note(p), UpdatePreimage::Note(pre)) => {
439 check_touched("content", p.content.is_some(), pre.content.is_some())?;
440 check_touched("salience", p.salience.is_some(), pre.salience.is_some())?;
441 check_touched(
442 "decay_factor",
443 p.decay_factor.is_some(),
444 pre.decay_factor.is_some(),
445 )?;
446 check_touched(
447 "properties",
448 p.properties.is_some(),
449 pre.properties.is_some(),
450 )?;
451 check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
452 if let Some(Some(s)) = pre.salience {
453 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
454 return Err(format!(
455 "UpdateOp note preimage salience must be finite and in [0.0, 1.0], got {s}"
456 ));
457 }
458 }
459 if let Some(Some(d)) = pre.decay_factor {
460 if !d.is_finite() || d < 0.0 {
461 return Err(format!(
462 "UpdateOp note preimage decay_factor must be finite and non-negative, got {d}"
463 ));
464 }
465 }
466 Ok(())
467 }
468 (UpdatePatch::Edge(p), UpdatePreimage::Edge(pre)) => {
469 check_touched("relation", p.relation.is_some(), pre.relation.is_some())?;
470 check_touched("weight", p.weight.is_some(), pre.weight.is_some())?;
471 if let Some(w) = pre.weight {
472 if !khive_types::validate_edge_weight(w) {
473 return Err(format!(
474 "UpdateOp edge preimage weight must be finite and in [0.0, 1.0], got {w}"
475 ));
476 }
477 }
478 p.validate_weight()?;
479 Ok(())
480 }
481 _ => Err(format!(
482 "UpdateOp patch target ({}) does not match preimage target ({})",
483 patch_target_name(patch),
484 preimage_target_name(preimage)
485 )),
486 }
487}
488
489fn check_touched(field: &str, patch_touched: bool, preimage_present: bool) -> Result<(), String> {
490 match (patch_touched, preimage_present) {
491 (true, false) => Err(format!(
492 "UpdateOp preimage is missing `{field}`, which the patch sets or clears"
493 )),
494 (false, true) => Err(format!(
495 "UpdateOp preimage carries `{field}`, which the patch leaves unchanged"
496 )),
497 _ => Ok(()),
498 }
499}
500
501fn patch_target_name(patch: &UpdatePatch) -> &'static str {
502 match patch {
503 UpdatePatch::Entity(_) => "entity",
504 UpdatePatch::Note(_) => "note",
505 UpdatePatch::Edge(_) => "edge",
506 }
507}
508
509fn preimage_target_name(preimage: &UpdatePreimage) -> &'static str {
510 match preimage {
511 UpdatePreimage::Entity(_) => "entity",
512 UpdatePreimage::Note(_) => "note",
513 UpdatePreimage::Edge(_) => "edge",
514 }
515}
516
517#[derive(Clone, Debug, Serialize)]
522pub struct DeleteOp {
523 pub target_id: Id128,
524 pub hard: bool,
525 pub preimage: DeletePreimage,
526}
527
528#[derive(Deserialize)]
529#[serde(deny_unknown_fields)]
530struct DeleteOpRaw {
531 target_id: Id128,
532 hard: bool,
533 preimage: DeletePreimage,
534}
535
536impl<'de> Deserialize<'de> for DeleteOp {
537 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
538 where
539 D: Deserializer<'de>,
540 {
541 let raw = DeleteOpRaw::deserialize(deserializer)?;
542 let preimage_id = raw.preimage.record_id();
543 if raw.target_id != preimage_id {
544 return Err(serde::de::Error::custom(format!(
545 "DeleteOp target_id {} does not match preimage record id {preimage_id}",
546 raw.target_id
547 )));
548 }
549 Ok(DeleteOp {
550 target_id: raw.target_id,
551 hard: raw.hard,
552 preimage: raw.preimage,
553 })
554 }
555}
556
557#[derive(Clone, Debug, Serialize)]
562pub struct RestoreOp {
563 target_id: Id128,
564 record: DeletePreimage,
565}
566
567impl RestoreOp {
568 pub fn new(target_id: Id128, record: DeletePreimage) -> Result<Self, String> {
574 if target_id != record.record_id() {
575 return Err(format!(
576 "RestoreOp target_id {target_id} does not match record id {}",
577 record.record_id()
578 ));
579 }
580 if let DeletePreimage::Note(row) = &record {
581 if row.status != NoteStatus::Active {
582 return Err("RestoreOp note record must be active; live restore cannot recover another status".into());
583 }
584 }
585 let (live, valid) = match &record {
586 DeletePreimage::Entity(row) => (row.deleted_at.is_none(), true),
587 DeletePreimage::Note(row) => (row.deleted_at.is_none(), row.is_valid()),
588 DeletePreimage::Edge(row) => (row.deleted_at.is_none(), row.is_valid()),
589 };
590 if !live {
591 return Err("RestoreOp record must have no deleted_at tombstone".into());
592 }
593 if !valid {
594 return Err("RestoreOp record has invalid numeric values".into());
595 }
596 Ok(Self { target_id, record })
597 }
598
599 pub fn target_id(&self) -> Id128 {
601 self.target_id
602 }
603
604 pub fn record(&self) -> &DeletePreimage {
606 &self.record
607 }
608}
609
610#[derive(Deserialize)]
611#[serde(deny_unknown_fields)]
612struct RestoreOpRaw {
613 target_id: Id128,
614 record: DeletePreimage,
615}
616
617impl<'de> Deserialize<'de> for RestoreOp {
618 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
619 where
620 D: Deserializer<'de>,
621 {
622 let raw = RestoreOpRaw::deserialize(deserializer)?;
623 Self::new(raw.target_id, raw.record).map_err(serde::de::Error::custom)
624 }
625}
626
627#[derive(Clone, Debug, Serialize)]
629#[serde(tag = "substrate", rename_all = "snake_case")]
630pub enum DeletePreimage {
631 Entity(Box<Entity>),
632 Note(Box<Note>),
633 Edge(Box<Link>),
634}
635
636impl DeletePreimage {
637 fn record_id(&self) -> Id128 {
639 match self {
640 DeletePreimage::Entity(e) => e.header.id,
641 DeletePreimage::Note(n) => n.header.id,
642 DeletePreimage::Edge(l) => l.id,
643 }
644 }
645}
646
647#[derive(Deserialize)]
649#[serde(tag = "substrate", rename_all = "snake_case")]
650enum DeletePreimageRaw {
651 Entity(StrictEntity),
652 Note(StrictNote),
653 Edge(StrictLink),
654}
655
656impl<'de> Deserialize<'de> for DeletePreimage {
657 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
658 where
659 D: Deserializer<'de>,
660 {
661 let raw = DeletePreimageRaw::deserialize(deserializer)?;
662 Ok(match raw {
663 DeletePreimageRaw::Entity(e) => DeletePreimage::Entity(Box::new(e.into())),
664 DeletePreimageRaw::Note(n) => {
665 DeletePreimage::Note(Box::new(n.try_into().map_err(serde::de::Error::custom)?))
666 }
667 DeletePreimageRaw::Edge(l) => {
668 DeletePreimage::Edge(Box::new(l.try_into().map_err(serde::de::Error::custom)?))
669 }
670 })
671 }
672}
673
674#[derive(Clone, Debug, Serialize)]
680pub struct MergeOp {
681 pub into_id: Id128,
682 pub from_id: Id128,
683 pub preimage: MergePreimage,
684}
685
686#[derive(Deserialize)]
687#[serde(deny_unknown_fields)]
688struct MergeOpRaw {
689 into_id: Id128,
690 from_id: Id128,
691 preimage: MergePreimage,
692}
693
694impl<'de> Deserialize<'de> for MergeOp {
695 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
696 where
697 D: Deserializer<'de>,
698 {
699 let raw = MergeOpRaw::deserialize(deserializer)?;
700 if raw.into_id != raw.preimage.into.header.id {
701 return Err(serde::de::Error::custom(format!(
702 "MergeOp into_id {} does not match preimage.into record id {}",
703 raw.into_id, raw.preimage.into.header.id
704 )));
705 }
706 if raw.from_id != raw.preimage.from.header.id {
707 return Err(serde::de::Error::custom(format!(
708 "MergeOp from_id {} does not match preimage.from record id {}",
709 raw.from_id, raw.preimage.from.header.id
710 )));
711 }
712 for edge in &raw.preimage.incident_edges {
713 let touches_into = edge.source == raw.into_id || edge.target == raw.into_id;
714 let touches_from = edge.source == raw.from_id || edge.target == raw.from_id;
715 if !(touches_into || touches_from) {
716 return Err(serde::de::Error::custom(format!(
717 "MergeOp incident edge {} references neither into_id {} nor from_id {}",
718 edge.id, raw.into_id, raw.from_id
719 )));
720 }
721 }
722 Ok(MergeOp {
723 into_id: raw.into_id,
724 from_id: raw.from_id,
725 preimage: raw.preimage,
726 })
727 }
728}
729
730#[derive(Clone, Debug, Serialize)]
734pub struct MergePreimage {
735 pub into: Box<Entity>,
736 pub from: Box<Entity>,
737 pub incident_edges: Vec<Link>,
738}
739
740#[derive(Deserialize)]
741#[serde(deny_unknown_fields)]
742struct MergePreimageRaw {
743 into: StrictEntity,
744 from: StrictEntity,
745 incident_edges: Vec<StrictLink>,
746}
747
748impl<'de> Deserialize<'de> for MergePreimage {
749 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
750 where
751 D: Deserializer<'de>,
752 {
753 let raw = MergePreimageRaw::deserialize(deserializer)?;
754 let incident_edges = raw
755 .incident_edges
756 .into_iter()
757 .map(|l| l.try_into().map_err(serde::de::Error::custom))
758 .collect::<Result<Vec<Link>, D::Error>>()?;
759 Ok(MergePreimage {
760 into: Box::new(raw.into.into()),
761 from: Box::new(raw.from.into()),
762 incident_edges,
763 })
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn link_op_rejects_out_of_range_weight() {
773 let json = serde_json::json!({
774 "id": "00000000-0000-0000-0000-000000000001",
775 "namespace": "local",
776 "source": "00000000-0000-0000-0000-000000000002",
777 "target": "00000000-0000-0000-0000-000000000003",
778 "relation": "extends",
779 "weight": 1.5,
780 "properties": {}
781 });
782 let result: Result<LinkOp, _> = serde_json::from_value(json);
783 assert!(result.is_err());
784 }
785
786 #[test]
787 fn create_op_rejects_unknown_field() {
788 let json = serde_json::json!({
789 "id": "00000000-0000-0000-0000-000000000001",
790 "namespace": "local",
791 "target": {
792 "kind": "entity",
793 "entity_kind": "concept",
794 "name": "X",
795 "surprise": true
796 }
797 });
798 let result: Result<CreateOp, _> = serde_json::from_value(json);
799 assert!(result.is_err());
800 }
801
802 #[test]
803 fn entity_patch_distinguishes_absent_and_null_description() {
804 let absent: EntityPatch = serde_json::from_value(serde_json::json!({})).unwrap();
805 assert_eq!(absent.description, None);
806
807 let cleared: EntityPatch =
808 serde_json::from_value(serde_json::json!({ "description": null })).unwrap();
809 assert_eq!(cleared.description, Some(None));
810
811 let set: EntityPatch =
812 serde_json::from_value(serde_json::json!({ "description": "new" })).unwrap();
813 assert_eq!(set.description, Some(Some("new".to_string())));
814 }
815
816 #[test]
817 fn delete_op_requires_preimage() {
818 let json = serde_json::json!({
819 "target_id": "00000000-0000-0000-0000-000000000001",
820 "hard": false
821 });
822 let result: Result<DeleteOp, _> = serde_json::from_value(json);
823 assert!(result.is_err());
824 }
825
826 #[test]
827 fn merge_op_requires_preimage() {
828 let json = serde_json::json!({
829 "into_id": "00000000-0000-0000-0000-000000000001",
830 "from_id": "00000000-0000-0000-0000-000000000002"
831 });
832 let result: Result<MergeOp, _> = serde_json::from_value(json);
833 assert!(result.is_err());
834 }
835
836 #[test]
837 fn update_op_rejects_preimage_missing_touched_field() {
838 let json = serde_json::json!({
839 "target_id": "00000000-0000-0000-0000-000000000001",
840 "patch": { "target": "entity", "name": "new-name" },
841 "preimage": { "target": "entity" }
842 });
843 let result: Result<UpdateOp, _> = serde_json::from_value(json);
844 assert!(result.is_err());
845 }
846
847 #[test]
848 fn update_op_rejects_preimage_with_extra_untouched_field() {
849 let json = serde_json::json!({
850 "target_id": "00000000-0000-0000-0000-000000000001",
851 "patch": { "target": "entity", "tags": ["a"] },
852 "preimage": {
853 "target": "entity",
854 "name": "stale-prior-name",
855 "tags": ["prior-a"]
856 }
857 });
858 let result: Result<UpdateOp, _> = serde_json::from_value(json);
859 assert!(result.is_err());
860 }
861
862 #[test]
863 fn update_op_rejects_mismatched_target_variant() {
864 let json = serde_json::json!({
865 "target_id": "00000000-0000-0000-0000-000000000001",
866 "patch": { "target": "entity", "name": "n" },
867 "preimage": { "target": "note", "content": "prior" }
868 });
869 let result: Result<UpdateOp, _> = serde_json::from_value(json);
870 assert!(result.is_err());
871 }
872
873 #[test]
874 fn update_op_explicit_null_clear_captures_prior_value() {
875 let json = serde_json::json!({
876 "target_id": "00000000-0000-0000-0000-000000000001",
877 "patch": { "target": "entity", "description": null },
878 "preimage": { "target": "entity", "description": "was-set" }
879 });
880 let op: UpdateOp = serde_json::from_value(json).unwrap();
881 match op.patch {
882 UpdatePatch::Entity(p) => assert_eq!(p.description, Some(None)),
883 other => panic!("expected entity patch, got {other:?}"),
884 }
885 match op.preimage {
886 UpdatePreimage::Entity(pre) => {
887 assert_eq!(pre.description, Some(Some("was-set".to_string())))
888 }
889 other => panic!("expected entity preimage, got {other:?}"),
890 }
891 }
892
893 #[test]
894 fn update_op_accepts_congruent_preimage() {
895 let json = serde_json::json!({
896 "target_id": "00000000-0000-0000-0000-000000000001",
897 "patch": { "target": "edge", "weight": 0.5 },
898 "preimage": { "target": "edge", "weight": 0.9 }
899 });
900 let result: Result<UpdateOp, _> = serde_json::from_value(json);
901 assert!(result.is_ok());
902 }
903
904 #[test]
905 fn update_op_rejects_out_of_range_preimage_weight() {
906 let json = serde_json::json!({
907 "target_id": "00000000-0000-0000-0000-000000000001",
908 "patch": { "target": "edge", "weight": 0.5 },
909 "preimage": { "target": "edge", "weight": 1.5 }
910 });
911 let result: Result<UpdateOp, _> = serde_json::from_value(json);
912 assert!(result.is_err());
913 }
914
915 #[test]
916 fn update_op_rejects_out_of_range_preimage_salience() {
917 let json = serde_json::json!({
918 "target_id": "00000000-0000-0000-0000-000000000001",
919 "patch": { "target": "note", "salience": 0.2 },
920 "preimage": { "target": "note", "salience": 1.9 }
921 });
922 let result: Result<UpdateOp, _> = serde_json::from_value(json);
923 assert!(result.is_err());
924 }
925
926 #[test]
927 fn update_op_new_rejects_incongruent_construction() {
928 let result = UpdateOp::new(
929 Id128::from_u128(1),
930 UpdatePatch::Entity(EntityPatch {
931 name: Some("new-name".to_string()),
932 description: None,
933 properties: None,
934 tags: None,
935 entity_type: None,
936 }),
937 UpdatePreimage::Entity(EntityPreimage {
938 name: None,
939 description: None,
940 properties: None,
941 tags: None,
942 entity_type: None,
943 }),
944 );
945 assert!(result.is_err());
946 }
947}