Skip to main content

khive_changeset/
op.rs

1//! The five typed change-set operations and their stage-time payloads.
2
3use std::collections::BTreeMap;
4
5use khive_types::{EdgeRelation, Entity, EntityKind, Id128, Link, Namespace, Note, PropertyValue};
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::strict::{StrictEntity, StrictLink, StrictNote};
9
10/// One staged mutation. Tagged internally by `"op"` so every NDJSON-delta
11/// line self-describes its kind without a wrapping envelope.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(tag = "op", rename_all = "snake_case")]
14pub enum Op {
15    Create(CreateOp),
16    Link(LinkOp),
17    Update(UpdateOp),
18    Delete(DeleteOp),
19    Merge(MergeOp),
20}
21
22// ---- create -----------------------------------------------------------
23
24/// Create a new entity or note. `id` is minted at stage time and is stable
25/// across however long the change-set sits before it is applied.
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct CreateOp {
29    pub id: Id128,
30    pub namespace: Namespace,
31    pub target: CreateTarget,
32}
33
34/// The substrate a `create` op targets, with its own field surface.
35#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
36#[serde(tag = "kind", rename_all = "snake_case")]
37pub enum CreateTarget {
38    Entity(EntityCreateFields),
39    Note(NoteCreateFields),
40}
41
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct EntityCreateFields {
45    pub entity_kind: EntityKind,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub entity_type: Option<String>,
48    pub name: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub description: Option<String>,
51    #[serde(default)]
52    pub properties: BTreeMap<String, PropertyValue>,
53    #[serde(default)]
54    pub tags: Vec<String>,
55}
56
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct NoteCreateFields {
60    /// Pack-declared note kind string (e.g. `"observation"`); not a closed enum.
61    pub note_kind: String,
62    pub content: String,
63    #[serde(default)]
64    pub properties: BTreeMap<String, PropertyValue>,
65    #[serde(default)]
66    pub tags: Vec<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub salience: Option<f64>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub decay_factor: Option<f64>,
71}
72
73// ---- link ---------------------------------------------------------------
74
75/// Create a new directed, typed edge. `id` is minted at stage time; `source`
76/// and `target` may resolve to another op's stage-time `id` in the same or a
77/// different change-set.
78#[derive(Clone, Debug, PartialEq, Serialize)]
79#[serde(into = "LinkOpRaw")]
80pub struct LinkOp {
81    pub id: Id128,
82    pub namespace: Namespace,
83    pub source: Id128,
84    pub target: Id128,
85    pub relation: EdgeRelation,
86    pub weight: f64,
87    pub properties: BTreeMap<String, PropertyValue>,
88}
89
90#[derive(Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct LinkOpRaw {
93    id: Id128,
94    namespace: Namespace,
95    source: Id128,
96    target: Id128,
97    relation: EdgeRelation,
98    weight: f64,
99    #[serde(default)]
100    properties: BTreeMap<String, PropertyValue>,
101}
102
103impl From<LinkOp> for LinkOpRaw {
104    fn from(l: LinkOp) -> Self {
105        Self {
106            id: l.id,
107            namespace: l.namespace,
108            source: l.source,
109            target: l.target,
110            relation: l.relation,
111            weight: l.weight,
112            properties: l.properties,
113        }
114    }
115}
116
117impl TryFrom<LinkOpRaw> for LinkOp {
118    type Error = String;
119
120    fn try_from(raw: LinkOpRaw) -> Result<Self, Self::Error> {
121        if !raw.weight.is_finite() {
122            return Err(format!("LinkOp weight must be finite, got {}", raw.weight));
123        }
124        if !(0.0..=1.0).contains(&raw.weight) {
125            return Err(format!(
126                "LinkOp weight must be in [0.0, 1.0], got {}",
127                raw.weight
128            ));
129        }
130        Ok(LinkOp {
131            id: raw.id,
132            namespace: raw.namespace,
133            source: raw.source,
134            target: raw.target,
135            relation: raw.relation,
136            weight: raw.weight,
137            properties: raw.properties,
138        })
139    }
140}
141
142impl<'de> Deserialize<'de> for LinkOp {
143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144    where
145        D: serde::Deserializer<'de>,
146    {
147        let raw = LinkOpRaw::deserialize(deserializer)?;
148        LinkOp::try_from(raw).map_err(serde::de::Error::custom)
149    }
150}
151
152// ---- update ---------------------------------------------------------------
153
154/// Patch an existing entity, note, or edge's mutable fields, carrying a
155/// stage-time preimage scoped to exactly the fields the patch touches.
156/// `preimage`'s populated fields must equal `patch`'s touched fields —
157/// every field the patch sets or explicitly clears to null, and no field
158/// the patch leaves unchanged — enforced at deserialize time, the same
159/// discipline `DeleteOp` and `MergeOp` apply to their own preimages below.
160/// An `UpdateOp` whose preimage and patch disagree on which fields changed
161/// is unrepresentable — both the checked constructor and `Deserialize`
162/// enforce the same congruence validation, so there is no construction path
163/// that bypasses it.
164#[derive(Clone, Debug, PartialEq, Serialize)]
165pub struct UpdateOp {
166    target_id: Id128,
167    patch: UpdatePatch,
168    preimage: UpdatePreimage,
169}
170
171impl UpdateOp {
172    /// Construct an `UpdateOp`, enforcing the congruence invariant:
173    /// `preimage` must target the same substrate as `patch` and populate
174    /// exactly the fields `patch` touches. This is the same invariant the
175    /// `Deserialize` impl enforces on the wire, so an incongruent update
176    /// cannot be built through either entry point.
177    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    /// The identifier of the record this update targets.
191    pub fn target_id(&self) -> Id128 {
192        self.target_id
193    }
194
195    /// The staged patch.
196    pub fn patch(&self) -> &UpdatePatch {
197        &self.patch
198    }
199
200    /// The field-scoped prior value the patch touches.
201    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)]
225#[serde(tag = "target", rename_all = "snake_case")]
226pub enum UpdatePatch {
227    Entity(EntityPatch),
228    Note(NotePatch),
229    Edge(EdgePatch),
230}
231
232/// Absent field = unchanged. `description` distinguishes explicit-null
233/// (clear) from absent (unchanged) via `Option<Option<String>>`.
234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct EntityPatch {
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub name: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
240    pub description: Option<Option<String>>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub properties: Option<BTreeMap<String, PropertyValue>>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub tags: Option<Vec<String>>,
245}
246
247#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct NotePatch {
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub content: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
253    pub salience: Option<Option<f64>>,
254    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
255    pub decay_factor: Option<Option<f64>>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub properties: Option<BTreeMap<String, PropertyValue>>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub tags: Option<Vec<String>>,
260}
261
262/// `weight`, when present, must be finite and in `[0.0, 1.0]` — the same
263/// invariant `LinkOp`/`khive_types::Link` enforce, so a staged edge update
264/// can never target a weight the live graph would itself reject.
265#[derive(Clone, Debug, PartialEq, Serialize)]
266#[serde(into = "EdgePatchRaw")]
267pub struct EdgePatch {
268    pub relation: Option<EdgeRelation>,
269    pub weight: Option<f64>,
270}
271
272#[derive(Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274struct EdgePatchRaw {
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    relation: Option<EdgeRelation>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    weight: Option<f64>,
279}
280
281impl From<EdgePatch> for EdgePatchRaw {
282    fn from(p: EdgePatch) -> Self {
283        Self {
284            relation: p.relation,
285            weight: p.weight,
286        }
287    }
288}
289
290impl TryFrom<EdgePatchRaw> for EdgePatch {
291    type Error = String;
292
293    fn try_from(raw: EdgePatchRaw) -> Result<Self, Self::Error> {
294        if let Some(w) = raw.weight {
295            if !w.is_finite() {
296                return Err(format!("EdgePatch weight must be finite, got {w}"));
297            }
298            if !(0.0..=1.0).contains(&w) {
299                return Err(format!("EdgePatch weight must be in [0.0, 1.0], got {w}"));
300            }
301        }
302        Ok(EdgePatch {
303            relation: raw.relation,
304            weight: raw.weight,
305        })
306    }
307}
308
309impl<'de> Deserialize<'de> for EdgePatch {
310    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
311    where
312        D: Deserializer<'de>,
313    {
314        let raw = EdgePatchRaw::deserialize(deserializer)?;
315        EdgePatch::try_from(raw).map_err(serde::de::Error::custom)
316    }
317}
318
319/// Serde helper for `Option<Option<T>>`: distinguishes absent (unchanged)
320/// from explicit `null` (clear) on partial-update patch fields.
321mod opt_opt {
322    use serde::{Deserialize, Deserializer, Serialize, Serializer};
323
324    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
325    where
326        T: Serialize,
327        S: Serializer,
328    {
329        match val {
330            None => unreachable!("skip_serializing_if guards the None case"),
331            Some(inner) => inner.serialize(s),
332        }
333    }
334
335    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
336    where
337        T: Deserialize<'de>,
338        D: Deserializer<'de>,
339    {
340        let opt: Option<T> = Option::deserialize(d)?;
341        Ok(Some(opt))
342    }
343}
344
345// ---- update preimage -------------------------------------------------------
346
347/// Prior value of exactly the fields an [`UpdateOp`]'s patch touches,
348/// captured at stage time. Mirrors [`EntityPatch`]'s field shape one-to-one:
349/// a populated preimage field means "the patch touches this field, and this
350/// was its value before the patch"; an absent preimage field means "the
351/// patch leaves this field unchanged." [`UpdateOp`]'s custom `Deserialize`
352/// enforces that this field set exactly equals `EntityPatch`'s touched-field
353/// set.
354#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct EntityPreimage {
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub name: Option<String>,
359    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
360    pub description: Option<Option<String>>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub properties: Option<BTreeMap<String, PropertyValue>>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub tags: Option<Vec<String>>,
365}
366
367/// Prior value of exactly the fields an [`UpdateOp`]'s patch touches;
368/// mirrors [`NotePatch`] the same way [`EntityPreimage`] mirrors
369/// [`EntityPatch`]. A captured `salience`/`decay_factor` value, when
370/// present, must satisfy the same finite/range invariant
371/// `khive_types::Note::is_valid` enforces on a live note — a prior value
372/// outside that range could never have been real.
373#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
374#[serde(deny_unknown_fields)]
375pub struct NotePreimage {
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub content: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
379    pub salience: Option<Option<f64>>,
380    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
381    pub decay_factor: Option<Option<f64>>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub properties: Option<BTreeMap<String, PropertyValue>>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub tags: Option<Vec<String>>,
386}
387
388/// Prior value of exactly the fields an [`UpdateOp`]'s patch touches;
389/// mirrors [`EdgePatch`] the same way [`EntityPreimage`] mirrors
390/// [`EntityPatch`]. A captured `weight` value, when present, must satisfy
391/// the same finite/`[0.0, 1.0]` invariant `EdgePatch`/`LinkOp`/
392/// `khive_types::Link` enforce on a live edge weight.
393#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
394#[serde(deny_unknown_fields)]
395pub struct EdgePreimage {
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub relation: Option<EdgeRelation>,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub weight: Option<f64>,
400}
401
402/// The field-scoped preimage for one [`UpdateOp`], tagged by the same
403/// `target` discriminant [`UpdatePatch`] uses so a preimage's substrate is
404/// self-describing on the wire.
405#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
406#[serde(tag = "target", rename_all = "snake_case")]
407pub enum UpdatePreimage {
408    Entity(EntityPreimage),
409    Note(NotePreimage),
410    Edge(EdgePreimage),
411}
412
413/// Enforce [`UpdateOp`]'s hard requirement: `preimage` targets the same
414/// substrate as `patch`, and the set of fields `preimage` populates equals
415/// the set of fields `patch` touches (sets or explicitly clears) exactly —
416/// no field the patch leaves unchanged, no field the patch touches left
417/// uncaptured. Also re-validates the finite/range invariants a captured
418/// prior `salience`, `decay_factor`, or edge `weight` must satisfy, since an
419/// out-of-range prior value could never have been a real previously-live
420/// state.
421fn validate_update_congruence(
422    patch: &UpdatePatch,
423    preimage: &UpdatePreimage,
424) -> Result<(), String> {
425    match (patch, preimage) {
426        (UpdatePatch::Entity(p), UpdatePreimage::Entity(pre)) => {
427            check_touched("name", p.name.is_some(), pre.name.is_some())?;
428            check_touched(
429                "description",
430                p.description.is_some(),
431                pre.description.is_some(),
432            )?;
433            check_touched(
434                "properties",
435                p.properties.is_some(),
436                pre.properties.is_some(),
437            )?;
438            check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
439            Ok(())
440        }
441        (UpdatePatch::Note(p), UpdatePreimage::Note(pre)) => {
442            check_touched("content", p.content.is_some(), pre.content.is_some())?;
443            check_touched("salience", p.salience.is_some(), pre.salience.is_some())?;
444            check_touched(
445                "decay_factor",
446                p.decay_factor.is_some(),
447                pre.decay_factor.is_some(),
448            )?;
449            check_touched(
450                "properties",
451                p.properties.is_some(),
452                pre.properties.is_some(),
453            )?;
454            check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
455            if let Some(Some(s)) = pre.salience {
456                if !s.is_finite() || !(0.0..=1.0).contains(&s) {
457                    return Err(format!(
458                        "UpdateOp note preimage salience must be finite and in [0.0, 1.0], got {s}"
459                    ));
460                }
461            }
462            if let Some(Some(d)) = pre.decay_factor {
463                if !d.is_finite() || d < 0.0 {
464                    return Err(format!(
465                        "UpdateOp note preimage decay_factor must be finite and non-negative, got {d}"
466                    ));
467                }
468            }
469            Ok(())
470        }
471        (UpdatePatch::Edge(p), UpdatePreimage::Edge(pre)) => {
472            check_touched("relation", p.relation.is_some(), pre.relation.is_some())?;
473            check_touched("weight", p.weight.is_some(), pre.weight.is_some())?;
474            if let Some(w) = pre.weight {
475                if !w.is_finite() || !(0.0..=1.0).contains(&w) {
476                    return Err(format!(
477                        "UpdateOp edge preimage weight must be finite and in [0.0, 1.0], got {w}"
478                    ));
479                }
480            }
481            Ok(())
482        }
483        _ => Err(format!(
484            "UpdateOp patch target ({}) does not match preimage target ({})",
485            patch_target_name(patch),
486            preimage_target_name(preimage)
487        )),
488    }
489}
490
491fn check_touched(field: &str, patch_touched: bool, preimage_present: bool) -> Result<(), String> {
492    match (patch_touched, preimage_present) {
493        (true, false) => Err(format!(
494            "UpdateOp preimage is missing `{field}`, which the patch sets or clears"
495        )),
496        (false, true) => Err(format!(
497            "UpdateOp preimage carries `{field}`, which the patch leaves unchanged"
498        )),
499        _ => Ok(()),
500    }
501}
502
503fn patch_target_name(patch: &UpdatePatch) -> &'static str {
504    match patch {
505        UpdatePatch::Entity(_) => "entity",
506        UpdatePatch::Note(_) => "note",
507        UpdatePatch::Edge(_) => "edge",
508    }
509}
510
511fn preimage_target_name(preimage: &UpdatePreimage) -> &'static str {
512    match preimage {
513        UpdatePreimage::Entity(_) => "entity",
514        UpdatePreimage::Note(_) => "note",
515        UpdatePreimage::Edge(_) => "edge",
516    }
517}
518
519// ---- delete ---------------------------------------------------------------
520
521/// Remove an existing entity, note, or edge. `preimage` captures the full
522/// prior record state at stage time — required, not optional, so a `delete`
523/// op without a captured preimage is unrepresentable. `target_id` is
524/// validated against the embedded preimage's own record id at deserialize
525/// time; a mismatched pair is a parse error, not a silently-accepted op.
526#[derive(Clone, Debug, Serialize)]
527pub struct DeleteOp {
528    pub target_id: Id128,
529    pub hard: bool,
530    pub preimage: DeletePreimage,
531}
532
533#[derive(Deserialize)]
534#[serde(deny_unknown_fields)]
535struct DeleteOpRaw {
536    target_id: Id128,
537    hard: bool,
538    preimage: DeletePreimage,
539}
540
541impl<'de> Deserialize<'de> for DeleteOp {
542    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
543    where
544        D: Deserializer<'de>,
545    {
546        let raw = DeleteOpRaw::deserialize(deserializer)?;
547        let preimage_id = raw.preimage.record_id();
548        if raw.target_id != preimage_id {
549            return Err(serde::de::Error::custom(format!(
550                "DeleteOp target_id {} does not match preimage record id {preimage_id}",
551                raw.target_id
552            )));
553        }
554        Ok(DeleteOp {
555            target_id: raw.target_id,
556            hard: raw.hard,
557            preimage: raw.preimage,
558        })
559    }
560}
561
562// `Entity` and `Note` each already serialize their own `kind` field
563// (entity kind / note kind), so the discriminant tag here is `substrate`
564// (matching `khive_types::SubstrateKind`'s vocabulary) rather than `kind`,
565// which would collide and produce a "duplicate field `kind`" parse error.
566#[derive(Clone, Debug, Serialize)]
567#[serde(tag = "substrate", rename_all = "snake_case")]
568pub enum DeletePreimage {
569    Entity(Box<Entity>),
570    Note(Box<Note>),
571    Edge(Box<Link>),
572}
573
574impl DeletePreimage {
575    /// The identifier of the record this preimage was captured from.
576    fn record_id(&self) -> Id128 {
577        match self {
578            DeletePreimage::Entity(e) => e.header.id,
579            DeletePreimage::Note(n) => n.header.id,
580            DeletePreimage::Edge(l) => l.id,
581        }
582    }
583}
584
585/// Deserialize-only mirror of [`DeletePreimage`], substituting each
586/// substrate's strict (`deny_unknown_fields`) wire-shape mirror for the
587/// looser `khive_types` deserializer.
588#[derive(Deserialize)]
589#[serde(tag = "substrate", rename_all = "snake_case")]
590enum DeletePreimageRaw {
591    Entity(StrictEntity),
592    Note(StrictNote),
593    Edge(StrictLink),
594}
595
596impl<'de> Deserialize<'de> for DeletePreimage {
597    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
598    where
599        D: Deserializer<'de>,
600    {
601        let raw = DeletePreimageRaw::deserialize(deserializer)?;
602        Ok(match raw {
603            DeletePreimageRaw::Entity(e) => DeletePreimage::Entity(Box::new(e.into())),
604            DeletePreimageRaw::Note(n) => {
605                DeletePreimage::Note(Box::new(n.try_into().map_err(serde::de::Error::custom)?))
606            }
607            DeletePreimageRaw::Edge(l) => {
608                DeletePreimage::Edge(Box::new(l.try_into().map_err(serde::de::Error::custom)?))
609            }
610        })
611    }
612}
613
614// ---- merge ------------------------------------------------------------
615
616/// Merge two entities. `preimage` captures both prior entities and the
617/// incident edges the merge will rewire — required, not optional, so a
618/// `merge` op without a captured preimage is unrepresentable. `into_id` /
619/// `from_id` are validated against `preimage.into` / `preimage.from`'s own
620/// record ids, and every incident edge is validated to actually touch one
621/// of the two merge participants, at deserialize time.
622#[derive(Clone, Debug, Serialize)]
623pub struct MergeOp {
624    pub into_id: Id128,
625    pub from_id: Id128,
626    pub preimage: MergePreimage,
627}
628
629#[derive(Deserialize)]
630#[serde(deny_unknown_fields)]
631struct MergeOpRaw {
632    into_id: Id128,
633    from_id: Id128,
634    preimage: MergePreimage,
635}
636
637impl<'de> Deserialize<'de> for MergeOp {
638    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
639    where
640        D: Deserializer<'de>,
641    {
642        let raw = MergeOpRaw::deserialize(deserializer)?;
643        if raw.into_id != raw.preimage.into.header.id {
644            return Err(serde::de::Error::custom(format!(
645                "MergeOp into_id {} does not match preimage.into record id {}",
646                raw.into_id, raw.preimage.into.header.id
647            )));
648        }
649        if raw.from_id != raw.preimage.from.header.id {
650            return Err(serde::de::Error::custom(format!(
651                "MergeOp from_id {} does not match preimage.from record id {}",
652                raw.from_id, raw.preimage.from.header.id
653            )));
654        }
655        for edge in &raw.preimage.incident_edges {
656            let touches_into = edge.source == raw.into_id || edge.target == raw.into_id;
657            let touches_from = edge.source == raw.from_id || edge.target == raw.from_id;
658            if !(touches_into || touches_from) {
659                return Err(serde::de::Error::custom(format!(
660                    "MergeOp incident edge {} references neither into_id {} nor from_id {}",
661                    edge.id, raw.into_id, raw.from_id
662                )));
663            }
664        }
665        Ok(MergeOp {
666            into_id: raw.into_id,
667            from_id: raw.from_id,
668            preimage: raw.preimage,
669        })
670    }
671}
672
673#[derive(Clone, Debug, Serialize)]
674pub struct MergePreimage {
675    pub into: Box<Entity>,
676    pub from: Box<Entity>,
677    pub incident_edges: Vec<Link>,
678}
679
680#[derive(Deserialize)]
681#[serde(deny_unknown_fields)]
682struct MergePreimageRaw {
683    into: StrictEntity,
684    from: StrictEntity,
685    incident_edges: Vec<StrictLink>,
686}
687
688impl<'de> Deserialize<'de> for MergePreimage {
689    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
690    where
691        D: Deserializer<'de>,
692    {
693        let raw = MergePreimageRaw::deserialize(deserializer)?;
694        let incident_edges = raw
695            .incident_edges
696            .into_iter()
697            .map(|l| l.try_into().map_err(serde::de::Error::custom))
698            .collect::<Result<Vec<Link>, D::Error>>()?;
699        Ok(MergePreimage {
700            into: Box::new(raw.into.into()),
701            from: Box::new(raw.from.into()),
702            incident_edges,
703        })
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn link_op_rejects_out_of_range_weight() {
713        let json = serde_json::json!({
714            "id": "00000000-0000-0000-0000-000000000001",
715            "namespace": "local",
716            "source": "00000000-0000-0000-0000-000000000002",
717            "target": "00000000-0000-0000-0000-000000000003",
718            "relation": "extends",
719            "weight": 1.5,
720            "properties": {}
721        });
722        let result: Result<LinkOp, _> = serde_json::from_value(json);
723        assert!(result.is_err());
724    }
725
726    #[test]
727    fn create_op_rejects_unknown_field() {
728        let json = serde_json::json!({
729            "id": "00000000-0000-0000-0000-000000000001",
730            "namespace": "local",
731            "target": {
732                "kind": "entity",
733                "entity_kind": "concept",
734                "name": "X",
735                "surprise": true
736            }
737        });
738        let result: Result<CreateOp, _> = serde_json::from_value(json);
739        assert!(result.is_err());
740    }
741
742    #[test]
743    fn entity_patch_distinguishes_absent_and_null_description() {
744        let absent: EntityPatch = serde_json::from_value(serde_json::json!({})).unwrap();
745        assert_eq!(absent.description, None);
746
747        let cleared: EntityPatch =
748            serde_json::from_value(serde_json::json!({ "description": null })).unwrap();
749        assert_eq!(cleared.description, Some(None));
750
751        let set: EntityPatch =
752            serde_json::from_value(serde_json::json!({ "description": "new" })).unwrap();
753        assert_eq!(set.description, Some(Some("new".to_string())));
754    }
755
756    #[test]
757    fn delete_op_requires_preimage() {
758        let json = serde_json::json!({
759            "target_id": "00000000-0000-0000-0000-000000000001",
760            "hard": false
761        });
762        let result: Result<DeleteOp, _> = serde_json::from_value(json);
763        assert!(result.is_err());
764    }
765
766    #[test]
767    fn merge_op_requires_preimage() {
768        let json = serde_json::json!({
769            "into_id": "00000000-0000-0000-0000-000000000001",
770            "from_id": "00000000-0000-0000-0000-000000000002"
771        });
772        let result: Result<MergeOp, _> = serde_json::from_value(json);
773        assert!(result.is_err());
774    }
775
776    #[test]
777    fn update_op_rejects_preimage_missing_touched_field() {
778        // patch sets `name`; preimage omits it.
779        let json = serde_json::json!({
780            "target_id": "00000000-0000-0000-0000-000000000001",
781            "patch": { "target": "entity", "name": "new-name" },
782            "preimage": { "target": "entity" }
783        });
784        let result: Result<UpdateOp, _> = serde_json::from_value(json);
785        assert!(result.is_err());
786    }
787
788    #[test]
789    fn update_op_rejects_preimage_with_extra_untouched_field() {
790        // patch leaves `name` unchanged; preimage carries it anyway.
791        let json = serde_json::json!({
792            "target_id": "00000000-0000-0000-0000-000000000001",
793            "patch": { "target": "entity", "tags": ["a"] },
794            "preimage": {
795                "target": "entity",
796                "name": "stale-prior-name",
797                "tags": ["prior-a"]
798            }
799        });
800        let result: Result<UpdateOp, _> = serde_json::from_value(json);
801        assert!(result.is_err());
802    }
803
804    #[test]
805    fn update_op_rejects_mismatched_target_variant() {
806        let json = serde_json::json!({
807            "target_id": "00000000-0000-0000-0000-000000000001",
808            "patch": { "target": "entity", "name": "n" },
809            "preimage": { "target": "note", "content": "prior" }
810        });
811        let result: Result<UpdateOp, _> = serde_json::from_value(json);
812        assert!(result.is_err());
813    }
814
815    #[test]
816    fn update_op_explicit_null_clear_captures_prior_value() {
817        let json = serde_json::json!({
818            "target_id": "00000000-0000-0000-0000-000000000001",
819            "patch": { "target": "entity", "description": null },
820            "preimage": { "target": "entity", "description": "was-set" }
821        });
822        let op: UpdateOp = serde_json::from_value(json).unwrap();
823        match op.patch {
824            UpdatePatch::Entity(p) => assert_eq!(p.description, Some(None)),
825            other => panic!("expected entity patch, got {other:?}"),
826        }
827        match op.preimage {
828            UpdatePreimage::Entity(pre) => {
829                assert_eq!(pre.description, Some(Some("was-set".to_string())))
830            }
831            other => panic!("expected entity preimage, got {other:?}"),
832        }
833    }
834
835    #[test]
836    fn update_op_accepts_congruent_preimage() {
837        let json = serde_json::json!({
838            "target_id": "00000000-0000-0000-0000-000000000001",
839            "patch": { "target": "edge", "weight": 0.5 },
840            "preimage": { "target": "edge", "weight": 0.9 }
841        });
842        let result: Result<UpdateOp, _> = serde_json::from_value(json);
843        assert!(result.is_ok());
844    }
845
846    #[test]
847    fn update_op_rejects_out_of_range_preimage_weight() {
848        let json = serde_json::json!({
849            "target_id": "00000000-0000-0000-0000-000000000001",
850            "patch": { "target": "edge", "weight": 0.5 },
851            "preimage": { "target": "edge", "weight": 1.5 }
852        });
853        let result: Result<UpdateOp, _> = serde_json::from_value(json);
854        assert!(result.is_err());
855    }
856
857    #[test]
858    fn update_op_rejects_out_of_range_preimage_salience() {
859        let json = serde_json::json!({
860            "target_id": "00000000-0000-0000-0000-000000000001",
861            "patch": { "target": "note", "salience": 0.2 },
862            "preimage": { "target": "note", "salience": 1.9 }
863        });
864        let result: Result<UpdateOp, _> = serde_json::from_value(json);
865        assert!(result.is_err());
866    }
867
868    #[test]
869    fn update_op_new_rejects_incongruent_construction() {
870        // The public `UpdateOp::new` constructor is the only in-memory way to
871        // build an `UpdateOp` — this proves it enforces the same congruence
872        // invariant `Deserialize` does, so no incongruent op (patch sets
873        // `name`, preimage doesn't capture it) can ever reach `to_ndjson`.
874        let result = UpdateOp::new(
875            Id128::from_u128(1),
876            UpdatePatch::Entity(EntityPatch {
877                name: Some("new-name".to_string()),
878                description: None,
879                properties: None,
880                tags: None,
881            }),
882            UpdatePreimage::Entity(EntityPreimage {
883                name: None,
884                description: None,
885                properties: None,
886                tags: None,
887            }),
888        );
889        assert!(result.is_err());
890    }
891}