Skip to main content

mcpmem_core/
mutation.rs

1//! The single graph write boundary. Snapshots, graph writes and derived
2//! counters share the writer transaction; no change is published before commit.
3use std::collections::{BTreeMap, BTreeSet};
4
5use rusqlite::{Connection, OptionalExtension, params};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::errors::{MCSError, Result};
10use crate::graph::{GraphHandle, TxGuard, name_hash};
11use crate::types::{
12    AttributeDelete, AttributeSet, Entity, EntityInput, Observation, ObservationInput, Relation,
13    RelationInput, RelationObservationUpdate,
14};
15
16pub type MutationError = MCSError;
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct MutationContext {
21    pub actor: String,
22    pub origin: String,
23    pub correlation_id: Uuid,
24    pub causation_id: Option<Uuid>,
25    pub hop_count: u8,
26    pub idempotency_key: Option<String>,
27}
28
29impl MutationContext {
30    /// Trusted in-process legacy ingress. Network callers must supply a
31    /// context derived from their authenticated principal instead.
32    pub fn local() -> Self {
33        Self {
34            actor: "local".into(),
35            origin: "mcp".into(),
36            correlation_id: Uuid::new_v4(),
37            causation_id: None,
38            hop_count: 0,
39            idempotency_key: None,
40        }
41    }
42
43    pub fn validate(self) -> Result<Self> {
44        if self.actor.trim().is_empty()
45            || self.actor.len() > 256
46            || self.origin.trim().is_empty()
47            || self.origin.len() > 256
48            || self.actor.chars().any(char::is_control)
49            || self.origin.chars().any(char::is_control)
50            || self.correlation_id.is_nil()
51            || self.hop_count > 15
52            || self.causation_id.is_some_and(|id| id.is_nil())
53            || (self.hop_count > 0) != self.causation_id.is_some()
54            || self.idempotency_key.as_ref().is_some_and(|key| {
55                key.is_empty() || key.len() > 128 || key.chars().any(char::is_control)
56            })
57        {
58            return Err(MCSError::InvalidParams(
59                "Invalid mutation provenance".into(),
60            ));
61        }
62        Ok(self)
63    }
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ObservationUpdate {
69    pub entity_name: String,
70    pub contents: Vec<ObservationInput>,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
74#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
75pub enum MutationRequest {
76    CreateEntities {
77        entities: Vec<EntityInput>,
78    },
79    UpsertEntities {
80        entities: Vec<EntityInput>,
81    },
82    DeleteEntities {
83        names: Vec<String>,
84    },
85    CreateRelations {
86        relations: Vec<RelationInput>,
87    },
88    DeleteRelations {
89        relations: Vec<Relation>,
90    },
91    AddRelationObservations {
92        relations: Vec<RelationObservationUpdate>,
93    },
94    DeleteRelationObservations {
95        relations: Vec<RelationObservationUpdate>,
96    },
97    SetAttributes {
98        targets: Vec<AttributeSet>,
99    },
100    DeleteAttributes {
101        targets: Vec<AttributeDelete>,
102    },
103    AddObservations {
104        observations: Vec<ObservationUpdate>,
105    },
106    DeleteObservations {
107        observations: Vec<ObservationUpdate>,
108    },
109    MergeEntities {
110        source: String,
111        target: String,
112    },
113    RenameEntity {
114        old_name: String,
115        new_name: String,
116    },
117    PurgeDefinedEntities {
118        name: String,
119    },
120    Compact,
121    Wipe,
122}
123
124#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct EntitySnapshot {
127    pub entity_id: i64,
128    pub name: String,
129    pub entity_type: String,
130    pub observations: Vec<Observation>,
131}
132
133impl EntitySnapshot {
134    pub fn entity(&self) -> Entity {
135        Entity {
136            name: self.name.clone(),
137            entity_type: self.entity_type.clone(),
138            observations: self.observations.clone(),
139            attributes: None,
140        }
141    }
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum ChangeOperation {
147    Create,
148    Update,
149    Delete,
150    Rename,
151}
152
153#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
154pub struct RelationDelta {
155    pub added: Vec<Relation>,
156    pub removed: Vec<Relation>,
157}
158
159#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct EntityChange {
162    pub operation: ChangeOperation,
163    pub before: Option<EntitySnapshot>,
164    pub after: Option<EntitySnapshot>,
165    pub relation_delta: Option<RelationDelta>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub old_name: Option<String>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub new_name: Option<String>,
170}
171
172#[derive(Clone, Debug, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct CommittedChangeSet {
175    pub transaction_id: Uuid,
176    pub changes: Vec<EntityChange>,
177}
178
179#[derive(Debug, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct ObservationResult {
182    pub entity_name: String,
183    pub added_observations: Vec<Observation>,
184}
185
186/// Per-target result of an `AddRelationObservations` write. The triple strings
187/// name the mirrored relation the observations were appended to; the handler
188/// layer serializes this shape directly on the MCP wire.
189#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct RelationObservationResult {
192    pub from: String,
193    pub to: String,
194    pub relation_type: String,
195    pub added_observations: Vec<Observation>,
196}
197
198/// Legacy response data is captured inside the same transaction, preventing
199/// an adapter from returning a concurrent writer's later state.
200#[derive(Debug, Serialize, Deserialize)]
201pub enum MutationResult {
202    Entities(Vec<Entity>),
203    Relations(Vec<Relation>),
204    Observations(Vec<ObservationResult>),
205    RelationObservations(Vec<RelationObservationResult>),
206    Entity(Entity),
207    Count(usize),
208    Unit,
209}
210
211#[derive(Debug, Serialize, Deserialize)]
212pub struct MutationOutcome {
213    pub changes: CommittedChangeSet,
214    pub result: MutationResult,
215    pub replayed: bool,
216}
217
218pub struct MutationService<'a> {
219    graph: &'a GraphHandle,
220}
221
222impl<'a> MutationService<'a> {
223    pub const fn new(graph: &'a GraphHandle) -> Self {
224        Self { graph }
225    }
226
227    pub fn apply(
228        &self,
229        request: MutationRequest,
230        context: MutationContext,
231    ) -> Result<CommittedChangeSet> {
232        self.apply_with_result(request, context)
233            .map(|(changes, _)| changes)
234    }
235
236    pub fn apply_with_result(
237        &self,
238        request: MutationRequest,
239        context: MutationContext,
240    ) -> Result<(CommittedChangeSet, MutationResult)> {
241        if context.idempotency_key.is_some() {
242            return Err(MCSError::InvalidParams(
243                "idempotent ingress requires a raw request fingerprint".into(),
244            ));
245        }
246        self.apply_inner(request, context, None)
247            .map(|outcome| (outcome.changes, outcome.result))
248    }
249
250    pub fn apply_idempotent(
251        &self,
252        request: MutationRequest,
253        context: MutationContext,
254        fingerprint: &str,
255    ) -> Result<MutationOutcome> {
256        if context.idempotency_key.is_none()
257            || fingerprint.len() != 64
258            || !fingerprint
259                .bytes()
260                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
261        {
262            return Err(MCSError::InvalidParams(
263                "idempotent ingress requires a key and SHA-256 request fingerprint".into(),
264            ));
265        }
266        self.apply_inner(request, context, Some(fingerprint))
267    }
268
269    fn apply_inner(
270        &self,
271        request: MutationRequest,
272        context: MutationContext,
273        fingerprint: Option<&str>,
274    ) -> Result<MutationOutcome> {
275        let context = context.validate()?;
276        let conn = self.graph.writer.lock();
277        let tx = TxGuard::begin(&conn)?;
278        if let (Some(key), Some(fingerprint)) = (&context.idempotency_key, fingerprint) {
279            let prior: Option<(String,String)> = conn.query_row("SELECT request_fingerprint,response FROM idempotency_record WHERE principal_id=?1 AND idempotency_key=?2", params![context.actor,key], |r| Ok((r.get(0)?,r.get(1)?))).optional().map_err(sql_error)?;
280            if let Some((saved_fingerprint, response)) = prior {
281                if saved_fingerprint != fingerprint {
282                    return Err(MCSError::InvalidParams("idempotency_conflict".into()));
283                }
284                let mut outcome: MutationOutcome = serde_json::from_str(&response)?;
285                outcome.replayed = true;
286                tx.commit()?;
287                return Ok(outcome);
288            }
289        }
290        if let Some(parent_id) = context.causation_id {
291            let parent = crate::events::EventRepository::new(&conn)
292                .get(parent_id)?
293                .ok_or_else(|| MCSError::InvalidParams("unknown causation event".into()))?;
294            if parent.provenance.correlation_id != context.correlation_id
295                || parent.provenance.hop_count.checked_add(1) != Some(context.hop_count)
296            {
297                return Err(MCSError::InvalidParams("invalid causation chain".into()));
298            }
299        }
300        self.graph.refresh_seqs(&conn)?;
301        let rename = match &request {
302            MutationRequest::RenameEntity { old_name, new_name } => {
303                Some((old_name.clone(), new_name.clone()))
304            }
305            _ => None,
306        };
307        let names = affected_names(&conn, &request)?;
308        let before = capture(&conn, &names)?;
309        let result = execute(self.graph, &conn, request)?;
310        let after = capture(&conn, &names)?;
311        let changes = match rename {
312            Some((old_name, new_name)) if old_name != new_name => {
313                rename_changes(&before, &after, &old_name, &new_name)
314            }
315            _ => effective_changes(&before, &after),
316        };
317        update_counters(&conn, &before, &after, &changes)?;
318        self.graph.sync_seqs(&conn)?;
319        let committed = CommittedChangeSet {
320            transaction_id: Uuid::new_v4(),
321            changes,
322        };
323        crate::events::persist_changes(&conn, &committed, &context)?;
324        let outcome = MutationOutcome {
325            changes: committed,
326            result,
327            replayed: false,
328        };
329        if let (Some(key), Some(fingerprint)) = (&context.idempotency_key, fingerprint) {
330            conn.execute(
331                "INSERT INTO idempotency_record VALUES(?1,?2,?3,?4,?5)",
332                params![
333                    context.actor,
334                    key,
335                    fingerprint,
336                    serde_json::to_string(&outcome)?,
337                    now_us()
338                ],
339            )
340            .map_err(sql_error)?;
341        }
342        tx.commit()?;
343        Ok(outcome)
344    }
345}
346
347fn sql_error(error: rusqlite::Error) -> MCSError {
348    MCSError::IoError(std::io::Error::other(error))
349}
350
351fn now_us() -> i64 {
352    std::time::SystemTime::now()
353        .duration_since(std::time::UNIX_EPOCH)
354        .unwrap_or_default()
355        .as_micros() as i64
356}
357
358pub(crate) fn read_entity(conn: &Connection, name: &str) -> Result<Option<EntitySnapshot>> {
359    let row = conn.query_row(
360        "SELECT e.id, e.name, t.name FROM entity e JOIN type_dict t ON t.id=e.type_id WHERE e.name_hash=?1 AND e.name=?2 AND e.flags=0",
361        params![name_hash(name), name],
362        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)),
363    ).optional().map_err(sql_error)?;
364    row.map(|(entity_id, name, entity_type)| {
365        let mut stmt = conn
366            .prepare_cached("SELECT body,created_us,occurred_us,origin_entity_name FROM observation WHERE entity_id=?1 ORDER BY idx, id")
367            .map_err(sql_error)?;
368        let observations = stmt
369            .query_map([entity_id], |row| Ok(Observation { body: row.get(0)?, created_at_us: Some(row.get(1)?), occurred_at_us: row.get(2)?, origin_entity_name: row.get(3)? }))
370            .map_err(sql_error)?
371            .collect::<rusqlite::Result<Vec<Observation>>>()
372            .map_err(sql_error)?;
373        Ok(EntitySnapshot {
374            entity_id,
375            name,
376            entity_type,
377            observations,
378        })
379    })
380    .transpose()
381}
382
383fn require_entity(conn: &Connection, name: &str) -> Result<EntitySnapshot> {
384    read_entity(conn, name)?
385        .ok_or_else(|| MCSError::InvalidParams(format!("Entity '{name}' not found")))
386}
387
388pub(crate) fn relations_for(conn: &Connection, name: &str) -> Result<Vec<Relation>> {
389    let mut stmt = conn.prepare_cached(
390        "SELECT f.name, t.name, d.name FROM relation r JOIN entity f ON f.id=r.from_id JOIN entity t ON t.id=r.to_id JOIN type_dict d ON d.id=r.type_id WHERE f.flags=0 AND t.flags=0 AND (r.from_id IN (SELECT id FROM entity WHERE name_hash=?1 AND name=?2 AND flags=0) OR r.to_id IN (SELECT id FROM entity WHERE name_hash=?1 AND name=?2 AND flags=0)) ORDER BY f.name, t.name, d.name"
391    ).map_err(sql_error)?;
392    stmt.query_map(params![name_hash(name), name], |row| {
393        Ok(Relation {
394            from: row.get(0)?,
395            to: row.get(1)?,
396            relation_type: row.get(2)?,
397        })
398    })
399    .map_err(sql_error)?
400    .collect::<rusqlite::Result<Vec<_>>>()
401    .map_err(sql_error)
402}
403
404fn defined_names(conn: &Connection, name: &str) -> Result<Vec<String>> {
405    let mut names: Vec<String> = relations_for(conn, name)?
406        .into_iter()
407        .filter(|r| r.from == name && r.relation_type == "defines")
408        .map(|r| r.to)
409        .collect();
410    names.push(name.into());
411    names.sort();
412    names.dedup();
413    Ok(names)
414}
415
416fn affected_names(conn: &Connection, request: &MutationRequest) -> Result<BTreeSet<String>> {
417    let mut names: BTreeSet<String> = match request {
418        MutationRequest::CreateEntities { entities }
419        | MutationRequest::UpsertEntities { entities } => {
420            entities.iter().map(|e| e.name.clone()).collect()
421        }
422        MutationRequest::DeleteEntities { names } => names.iter().cloned().collect(),
423        MutationRequest::CreateRelations { relations } => relations
424            .iter()
425            .flat_map(|r| [r.from.clone(), r.to.clone()])
426            .collect(),
427        MutationRequest::DeleteRelations { relations } => relations
428            .iter()
429            .flat_map(|r| [r.from.clone(), r.to.clone()])
430            .collect(),
431        MutationRequest::AddObservations { observations }
432        | MutationRequest::DeleteObservations { observations } => {
433            observations.iter().map(|o| o.entity_name.clone()).collect()
434        }
435        // REQ-ATTR-OFFLINE: relation observation and attribute writes are
436        // structurally excluded from the entity event stream. An endpoint
437        // entity here would bump entity_revision, emit a change event, and
438        // re-enqueue its index job on every attribute write.
439        MutationRequest::AddRelationObservations { .. }
440        | MutationRequest::DeleteRelationObservations { .. }
441        | MutationRequest::SetAttributes { .. }
442        | MutationRequest::DeleteAttributes { .. } => BTreeSet::new(),
443        MutationRequest::MergeEntities { source, target } => {
444            [source.clone(), target.clone()].into()
445        }
446        MutationRequest::RenameEntity { old_name, new_name } => {
447            [old_name.clone(), new_name.clone()].into()
448        }
449        MutationRequest::PurgeDefinedEntities { name } => {
450            defined_names(conn, name)?.into_iter().collect()
451        }
452        MutationRequest::Compact => BTreeSet::new(),
453        MutationRequest::Wipe => {
454            let mut stmt = conn
455                .prepare("SELECT name FROM entity WHERE flags=0")
456                .map_err(sql_error)?;
457            stmt.query_map([], |row| row.get(0))
458                .map_err(sql_error)?
459                .collect::<rusqlite::Result<_>>()
460                .map_err(sql_error)?
461        }
462    };
463    // Deletes/merges change surviving neighbours too. Resolve these before
464    // deleting any rows so their before snapshots and relation deltas survive.
465    if matches!(
466        request,
467        MutationRequest::DeleteEntities { .. }
468            | MutationRequest::MergeEntities { .. }
469            | MutationRequest::RenameEntity { .. }
470            | MutationRequest::PurgeDefinedEntities { .. }
471    ) {
472        let neighbours = names
473            .iter()
474            .map(|name| relations_for(conn, name))
475            .collect::<Result<Vec<_>>>()?
476            .into_iter()
477            .flatten()
478            .flat_map(|r| [r.from, r.to])
479            .collect::<Vec<_>>();
480        names.extend(neighbours);
481    }
482    Ok(names)
483}
484
485#[derive(Default)]
486struct Snapshot {
487    entities: BTreeMap<String, EntitySnapshot>,
488    relations: BTreeSet<Relation>,
489    relation_rows: BTreeMap<Relation, i64>,
490}
491
492fn capture(conn: &Connection, names: &BTreeSet<String>) -> Result<Snapshot> {
493    let mut snapshot = Snapshot::default();
494    for name in names {
495        if let Some(entity) = read_entity(conn, name)? {
496            snapshot.entities.insert(name.clone(), entity);
497        }
498        let mut relation_rows = BTreeMap::new();
499        for relation in relations_for(conn, name)? {
500            *relation_rows.entry(relation).or_default() += 1;
501        }
502        // Both endpoint queries return every physical row of the same relation.
503        // Replace the count rather than adding it twice; keep set semantics for
504        // committed deltas independently of legacy duplicate storage rows.
505        snapshot.relations.extend(relation_rows.keys().cloned());
506        snapshot.relation_rows.extend(relation_rows);
507    }
508    Ok(snapshot)
509}
510
511fn effective_changes(before: &Snapshot, after: &Snapshot) -> Vec<EntityChange> {
512    let mut deltas: BTreeMap<&str, RelationDelta> = BTreeMap::new();
513    for (added, relations) in [
514        (true, after.relations.difference(&before.relations)),
515        (false, before.relations.difference(&after.relations)),
516    ] {
517        for relation in relations {
518            for name in [&relation.from, &relation.to]
519                .into_iter()
520                .collect::<BTreeSet<_>>()
521            {
522                let delta = deltas.entry(name).or_default();
523                if added {
524                    delta.added.push(relation.clone());
525                } else {
526                    delta.removed.push(relation.clone());
527                }
528            }
529        }
530    }
531    before
532        .entities
533        .keys()
534        .chain(after.entities.keys())
535        .collect::<BTreeSet<_>>()
536        .into_iter()
537        .filter_map(|name| {
538            let old = before.entities.get(name);
539            let new = after.entities.get(name);
540            let delta = deltas.remove(name.as_str()).unwrap_or_default();
541            let has_delta = !delta.added.is_empty() || !delta.removed.is_empty();
542            if old == new && !has_delta {
543                return None;
544            }
545            let operation = match (old, new) {
546                (None, Some(_)) => ChangeOperation::Create,
547                (Some(_), None) => ChangeOperation::Delete,
548                _ => ChangeOperation::Update,
549            };
550            Some(EntityChange {
551                operation,
552                before: old.cloned(),
553                after: new.cloned(),
554                relation_delta: has_delta.then_some(delta),
555                old_name: None,
556                new_name: None,
557            })
558        })
559        .collect()
560}
561
562fn rename_changes(
563    before: &Snapshot,
564    after: &Snapshot,
565    old_name: &str,
566    new_name: &str,
567) -> Vec<EntityChange> {
568    let (Some(before), Some(after)) = (before.entities.get(old_name), after.entities.get(new_name))
569    else {
570        return Vec::new();
571    };
572    vec![EntityChange {
573        operation: ChangeOperation::Rename,
574        before: Some(before.clone()),
575        after: Some(after.clone()),
576        relation_delta: None,
577        old_name: Some(old_name.into()),
578        new_name: Some(new_name.into()),
579    }]
580}
581
582fn type_id(conn: &Connection, name: &str, kind: i64) -> Result<i64> {
583    if let Some(id) = conn
584        .query_row(
585            "SELECT id FROM type_dict WHERE kind=?1 AND name=?2",
586            params![kind, name],
587            |r| r.get(0),
588        )
589        .optional()
590        .map_err(sql_error)?
591    {
592        return Ok(id);
593    }
594    conn.execute(
595        "INSERT INTO type_dict(kind,name,count) VALUES(?1,?2,0)",
596        params![kind, name],
597    )
598    .map_err(sql_error)?;
599    Ok(conn.last_insert_rowid())
600}
601
602/// Queue one taxonomy subject for every serving profile, using the same
603/// lookup as the entity job path. Without a serving profile the job is
604/// explicitly held by the queue function itself.
605fn enqueue_taxonomy_jobs(
606    conn: &Connection,
607    kind: i64,
608    id: i64,
609    revision: i64,
610    operation: crate::jobs::IndexOperation,
611) -> Result<()> {
612    for profile_id in crate::jobs::serving_profile_ids(conn)? {
613        crate::jobs::enqueue_taxonomy(conn, kind, id, revision, operation, profile_id)?;
614    }
615    Ok(())
616}
617
618/// Tombstone the taxonomy mirror of one deleted relation triple and enqueue
619/// the delete. A missing mirror is a legacy row: insert it tombstoned.
620/// REQ-LIFECYCLE: the mirror's observations and attributes die with the
621/// relation in the same transaction, via the same funnel the entity-delete
622/// cascade uses.
623fn tombstone_relation_mirror(
624    conn: &Connection,
625    from_id: i64,
626    to_id: i64,
627    type_id: i64,
628) -> Result<()> {
629    let (id, revision): (i64, i64) = conn
630        .query_row(
631            "INSERT INTO taxonomy_relation(from_id,to_id,type_id,revision,deleted) VALUES(?1,?2,?3,1,1) \
632             ON CONFLICT(from_id,to_id,type_id) DO UPDATE SET revision=revision+1,deleted=1 \
633             RETURNING id,revision",
634            params![from_id, to_id, type_id],
635            |row| Ok((row.get(0)?, row.get(1)?)),
636        )
637        .map_err(sql_error)?;
638    conn.execute(
639        "DELETE FROM relation_observation WHERE relation_id=?1",
640        [id],
641    )
642    .map_err(sql_error)?;
643    conn.execute(
644        "DELETE FROM attribute WHERE owner_kind='relation' AND owner_id=?1",
645        [id],
646    )
647    .map_err(sql_error)?;
648    crate::jobs::enqueue_chunk_change(conn, crate::jobs::OwnerKind::Relation, id, revision, true)
649}
650
651fn insert_observations(
652    graph: &GraphHandle,
653    conn: &Connection,
654    id: i64,
655    contents: &[ObservationInput],
656) -> Result<Vec<Observation>> {
657    let idx: i64 = conn
658        .query_row(
659            "SELECT COALESCE(MAX(idx),-1) FROM observation WHERE entity_id=?1",
660            [id],
661            |r| r.get(0),
662        )
663        .map_err(sql_error)?;
664    let mut stmt = conn
665        .prepare_cached(
666            "INSERT INTO observation(id,entity_id,idx,body,created_us,occurred_us) VALUES(?1,?2,?3,?4,?5,?6)",
667        )
668        .map_err(sql_error)?;
669    let mut inserted = Vec::with_capacity(contents.len());
670    for (offset, observation) in contents.iter().enumerate() {
671        if observation.occurred_at_us.is_some_and(|time| time < 0) {
672            return Err(MCSError::InvalidParams(
673                "occurredAtUs must be non-negative".into(),
674            ));
675        }
676        let created_at_us = now_us();
677        stmt.execute(params![
678            graph.next_obs_id(),
679            id,
680            idx + offset as i64 + 1,
681            observation.body,
682            created_at_us,
683            observation.occurred_at_us
684        ])
685        .map_err(sql_error)?;
686        inserted.push(Observation {
687            body: observation.body.clone(),
688            created_at_us: Some(created_at_us),
689            occurred_at_us: observation.occurred_at_us,
690            origin_entity_name: None,
691        });
692    }
693    Ok(inserted)
694}
695
696/// Resolve the live taxonomy mirror id of one relation triple. A triple that
697/// does not exist as a live mirror is an `InvalidParams` error, mirroring the
698/// entity observation path: there is nothing to append to.
699fn resolve_relation_mirror(conn: &Connection, relation: &Relation) -> Result<i64> {
700    conn.query_row(
701        "SELECT m.id FROM taxonomy_relation m
702         JOIN entity f ON f.id = m.from_id AND f.name = ?1 AND f.flags = 0
703         JOIN entity t ON t.id = m.to_id AND t.name = ?2 AND t.flags = 0
704         JOIN type_dict d ON d.id = m.type_id AND d.kind = 1 AND d.name = ?3
705         WHERE m.deleted = 0",
706        params![relation.from, relation.to, relation.relation_type],
707        |row| row.get(0),
708    )
709    .map_err(|error| match error {
710        rusqlite::Error::QueryReturnedNoRows => MCSError::InvalidParams(format!(
711            "{} -> {} -> {} not found",
712            relation.from, relation.relation_type, relation.to
713        )),
714        _ => sql_error(error),
715    })
716}
717
718/// Insert relation observation rows, keyed on the mirror id, id from the
719/// `rel_obs_seq` cell. The relational counter is maintained here — the change
720/// snapshot carries no relation observations, so `update_counters` cannot
721/// derive the delta. The FTS projection updates through its insert trigger.
722fn insert_relation_observations(
723    graph: &GraphHandle,
724    conn: &Connection,
725    relation_id: i64,
726    contents: &[ObservationInput],
727) -> Result<Vec<Observation>> {
728    let idx: i64 = conn
729        .query_row(
730            "SELECT COALESCE(MAX(idx),-1) FROM relation_observation WHERE relation_id=?1",
731            [relation_id],
732            |r| r.get(0),
733        )
734        .map_err(sql_error)?;
735    let mut stmt = conn
736        .prepare_cached(
737            "INSERT INTO relation_observation(id,relation_id,idx,body,created_us,occurred_us) VALUES(?1,?2,?3,?4,?5,?6)",
738        )
739        .map_err(sql_error)?;
740    let mut inserted = Vec::with_capacity(contents.len());
741    for (offset, observation) in contents.iter().enumerate() {
742        if observation.occurred_at_us.is_some_and(|time| time < 0) {
743            return Err(MCSError::InvalidParams(
744                "occurredAtUs must be non-negative".into(),
745            ));
746        }
747        let created_at_us = now_us();
748        stmt.execute(params![
749            graph.next_rel_obs_id(),
750            relation_id,
751            idx + offset as i64 + 1,
752            observation.body,
753            created_at_us,
754            observation.occurred_at_us
755        ])
756        .map_err(sql_error)?;
757        inserted.push(Observation {
758            body: observation.body.clone(),
759            created_at_us: Some(created_at_us),
760            occurred_at_us: observation.occurred_at_us,
761            origin_entity_name: None,
762        });
763    }
764    if !inserted.is_empty() {
765        conn.execute(
766            "UPDATE graph_stat SET value=value+?1 WHERE key='relation_obs'",
767            [inserted.len() as i64],
768        )
769        .map_err(sql_error)?;
770    }
771    Ok(inserted)
772}
773
774/// Upsert one k:v write set for an owner. `ON CONFLICT ... DO UPDATE` makes
775/// the provided value win over an existing row; keys not in the set stay.
776/// REQ-ATTR-OFFLINE: no revision bump, no queue row, no event — enforced by
777/// the empty `affected_names` arms, asserted in the integration suite.
778fn upsert_attributes(
779    conn: &Connection,
780    owner_kind: &str,
781    owner_id: i64,
782    attributes: &BTreeMap<String, String>,
783) -> Result<()> {
784    let mut stmt = conn
785        .prepare_cached(
786            "INSERT INTO attribute(owner_kind,owner_id,key,value,created_us,updated_us)
787             VALUES(?1,?2,?3,?4,?5,?5)
788             ON CONFLICT(owner_kind,owner_id,key) DO UPDATE
789             SET value=excluded.value, updated_us=excluded.updated_us",
790        )
791        .map_err(sql_error)?;
792    for (key, value) in attributes {
793        stmt.execute(params![owner_kind, owner_id, key, value, now_us()])
794            .map_err(sql_error)?;
795    }
796    Ok(())
797}
798
799fn delete_attribute_keys(
800    conn: &Connection,
801    owner_kind: &str,
802    owner_id: i64,
803    keys: &[String],
804) -> Result<()> {
805    let mut stmt = conn
806        .prepare_cached("DELETE FROM attribute WHERE owner_kind=?1 AND owner_id=?2 AND key=?3")
807        .map_err(sql_error)?;
808    for key in keys {
809        stmt.execute(params![owner_kind, owner_id, key])
810            .map_err(sql_error)?;
811    }
812    Ok(())
813}
814
815/// Bump the mirror revision and enqueue the relation owner for re-embedding.
816/// The worker reads `relation_observation` at claim time, so the single
817/// enqueue covers rows already present in this transaction.
818fn bump_relation_revision_enqueue(conn: &Connection, relation_id: i64) -> Result<()> {
819    let revision: i64 = conn
820        .query_row(
821            "UPDATE taxonomy_relation SET revision=revision+1 WHERE id=?1 RETURNING revision",
822            [relation_id],
823            |row| row.get(0),
824        )
825        .map_err(sql_error)?;
826    crate::jobs::enqueue_chunk_change(
827        conn,
828        crate::jobs::OwnerKind::Relation,
829        relation_id,
830        revision,
831        false,
832    )
833}
834
835/// Validate one attribute target's owner shape and resolve its owner id.
836/// Exactly one owner shape is legal per `owner_kind`; the wire DTO parses
837/// either shape alone (absent fields default to `None`), so a mixed shape is
838/// rejected here, in the service layer.
839fn resolve_attribute_owner(
840    conn: &Connection,
841    owner_kind: &str,
842    entity_name: Option<&str>,
843    from: Option<&str>,
844    to: Option<&str>,
845    relation_type: Option<&str>,
846) -> Result<(String, i64)> {
847    match (owner_kind, entity_name, from, to, relation_type) {
848        ("entity", Some(name), None, None, None) => {
849            Ok(("entity".into(), require_entity(conn, name)?.entity_id))
850        }
851        ("relation", None, Some(from), Some(to), Some(relation_type)) => Ok((
852            "relation".into(),
853            resolve_relation_mirror(
854                conn,
855                &Relation {
856                    from: from.into(),
857                    to: to.into(),
858                    relation_type: relation_type.into(),
859                },
860            )?,
861        )),
862        _ => Err(MCSError::InvalidParams(format!(
863            "Invalid attribute target for owner_kind '{owner_kind}'"
864        ))),
865    }
866}
867
868fn create_entity(graph: &GraphHandle, conn: &Connection, entity: &EntityInput) -> Result<bool> {
869    if entity.name.is_empty() || read_entity(conn, &entity.name)?.is_some() {
870        return Ok(false);
871    }
872    let id = graph.next_entity_id();
873    let kind = type_id(conn, &entity.entity_type, 0)?;
874    conn.execute("INSERT INTO entity(id,name_hash,name,type_id,obs_count,out_deg,in_deg,created_us,updated_us,flags) VALUES(?1,?2,?3,?4,0,0,0,?5,?5,0)", params![id,name_hash(&entity.name),entity.name,kind,now_us()]).map_err(sql_error)?;
875    insert_observations(graph, conn, id, &entity.observations)?;
876    if let Some(attributes) = &entity.attributes {
877        upsert_attributes(conn, "entity", id, attributes)?;
878    }
879    conn.execute(
880        "INSERT INTO name_fts(rowid,name) VALUES(?1,?2)",
881        params![id, entity.name],
882    )
883    .map_err(sql_error)?;
884    Ok(true)
885}
886
887fn create_relation(conn: &Connection, relation: &Relation) -> Result<bool> {
888    let (Some(from), Some(to)) = (
889        read_entity(conn, &relation.from)?,
890        read_entity(conn, &relation.to)?,
891    ) else {
892        return Ok(false);
893    };
894    let kind = type_id(conn, &relation.relation_type, 1)?;
895    let changed = conn.execute("INSERT INTO relation(from_id,to_id,type_id,created_us) SELECT ?1,?2,?3,?4 WHERE NOT EXISTS(SELECT 1 FROM relation WHERE from_id=?1 AND to_id=?2 AND type_id=?3)", params![from.entity_id,to.entity_id,kind,now_us()]).map_err(sql_error)?;
896    if changed > 0 {
897        // Mirror the triple for the chunk worker. The mirror id is its own
898        // autoincrement, never the source rowid: SQLite reuses a freed rowid
899        // for a later row, and an explicit-id insert would collide with the
900        // tombstoned mirror of a different triple. A recreated triple keeps
901        // the id its mirror already owns.
902        let mirror_id: i64 = conn
903            .query_row(
904                "INSERT INTO taxonomy_relation(from_id,to_id,type_id,revision,deleted) VALUES(?1,?2,?3,1,0) \
905                 ON CONFLICT(from_id,to_id,type_id) DO UPDATE SET revision=1,deleted=0 \
906                 RETURNING id",
907                params![from.entity_id, to.entity_id, kind],
908                |row| row.get(0),
909            )
910            .map_err(sql_error)?;
911        crate::jobs::enqueue_chunk_change(
912            conn,
913            crate::jobs::OwnerKind::Relation,
914            mirror_id,
915            1,
916            false,
917        )?;
918    }
919    Ok(changed > 0)
920}
921
922/// The create-with-detail path behind `CreateRelations`. The bare-triple
923/// insert and its single revision-1 enqueue run first; the observation rows
924/// land in the same transaction, so the enqueued worker reads them at claim
925/// time and the existing single enqueue covers them. Attributes are upserted
926/// with no revision bump (REQ-ATTR-OFFLINE).
927fn create_relation_with(
928    graph: &GraphHandle,
929    conn: &Connection,
930    input: &RelationInput,
931) -> Result<bool> {
932    let triple = Relation {
933        from: input.from.clone(),
934        to: input.to.clone(),
935        relation_type: input.relation_type.clone(),
936    };
937    if !create_relation(conn, &triple)? {
938        return Ok(false);
939    }
940    if !input.observations.is_empty() {
941        let mirror_id = resolve_relation_mirror(conn, &triple)?;
942        insert_relation_observations(graph, conn, mirror_id, &input.observations)?;
943    }
944    if let Some(attributes) = &input.attributes {
945        let mirror_id = resolve_relation_mirror(conn, &triple)?;
946        upsert_attributes(conn, "relation", mirror_id, attributes)?;
947    }
948    Ok(true)
949}
950
951fn delete_entities(conn: &Connection, names: &[String]) -> Result<()> {
952    for name in names.iter().collect::<BTreeSet<_>>() {
953        if let Some(entity) = read_entity(conn, name)? {
954            let triples = conn
955                .prepare_cached(
956                    "SELECT from_id, to_id, type_id FROM relation WHERE from_id=?1 OR to_id=?1",
957                )
958                .map_err(sql_error)?
959                .query_map([entity.entity_id], |row| {
960                    Ok((
961                        row.get::<_, i64>(0)?,
962                        row.get::<_, i64>(1)?,
963                        row.get::<_, i64>(2)?,
964                    ))
965                })
966                .map_err(sql_error)?
967                .collect::<rusqlite::Result<Vec<(i64, i64, i64)>>>()
968                .map_err(sql_error)?;
969            conn.execute(
970                "DELETE FROM observation WHERE entity_id=?1",
971                [entity.entity_id],
972            )
973            .map_err(sql_error)?;
974            conn.execute(
975                "DELETE FROM relation WHERE from_id=?1 OR to_id=?1",
976                [entity.entity_id],
977            )
978            .map_err(sql_error)?;
979            conn.execute(
980                "INSERT INTO name_fts(name_fts,rowid,name) VALUES('delete',?1,?2)",
981                params![entity.entity_id, entity.name],
982            )
983            .map_err(sql_error)?;
984            conn.execute(
985                "DELETE FROM attribute WHERE owner_kind='entity' AND owner_id=?1",
986                [entity.entity_id],
987            )
988            .map_err(sql_error)?;
989            conn.execute("DELETE FROM entity WHERE id=?1", [entity.entity_id])
990                .map_err(sql_error)?;
991            for (from_id, to_id, type_id) in triples {
992                tombstone_relation_mirror(conn, from_id, to_id, type_id)?;
993            }
994        }
995    }
996    Ok(())
997}
998
999fn execute(
1000    graph: &GraphHandle,
1001    conn: &Connection,
1002    request: MutationRequest,
1003) -> Result<MutationResult> {
1004    match request {
1005        MutationRequest::CreateEntities { entities } => {
1006            let mut created = Vec::new();
1007            for entity in entities {
1008                if create_entity(graph, conn, &entity)? {
1009                    created.push(require_entity(conn, &entity.name)?.entity());
1010                }
1011            }
1012            Ok(MutationResult::Entities(created))
1013        }
1014        MutationRequest::UpsertEntities { entities } => {
1015            let mut result = Vec::new();
1016            for entity in entities {
1017                if let Some(existing) = read_entity(conn, &entity.name)? {
1018                    if existing.entity_type != entity.entity_type {
1019                        conn.execute(
1020                            "UPDATE entity SET type_id=?1 WHERE id=?2",
1021                            params![type_id(conn, &entity.entity_type, 0)?, existing.entity_id],
1022                        )
1023                        .map_err(sql_error)?;
1024                    }
1025                    let mut seen: BTreeSet<&str> = existing
1026                        .observations
1027                        .iter()
1028                        .map(|o| o.body.as_str())
1029                        .collect();
1030                    let added: Vec<ObservationInput> = entity
1031                        .observations
1032                        .iter()
1033                        .filter(|o| seen.insert(o.body.as_str()))
1034                        .cloned()
1035                        .collect();
1036                    insert_observations(graph, conn, existing.entity_id, &added)?;
1037                    if let Some(attributes) = &entity.attributes {
1038                        upsert_attributes(conn, "entity", existing.entity_id, attributes)?;
1039                    }
1040                    result.push(require_entity(conn, &entity.name)?.entity());
1041                } else if create_entity(graph, conn, &entity)? {
1042                    result.push(require_entity(conn, &entity.name)?.entity());
1043                }
1044            }
1045            Ok(MutationResult::Entities(result))
1046        }
1047        MutationRequest::DeleteEntities { names } => {
1048            delete_entities(conn, &names)?;
1049            Ok(MutationResult::Unit)
1050        }
1051        MutationRequest::CreateRelations { relations } => {
1052            let mut created = Vec::new();
1053            for relation in relations {
1054                if create_relation_with(graph, conn, &relation)? {
1055                    created.push(Relation {
1056                        from: relation.from,
1057                        to: relation.to,
1058                        relation_type: relation.relation_type,
1059                    });
1060                }
1061            }
1062            Ok(MutationResult::Relations(created))
1063        }
1064        MutationRequest::DeleteRelations { relations } => {
1065            for relation in relations {
1066                let triples = conn.prepare_cached(
1067                    "SELECT rowid, from_id, to_id, type_id FROM relation \
1068                     WHERE from_id IN (SELECT id FROM entity WHERE name_hash=?1 AND name=?2 AND flags=0) \
1069                     AND to_id IN (SELECT id FROM entity WHERE name_hash=?3 AND name=?4 AND flags=0) \
1070                     AND type_id IN (SELECT id FROM type_dict WHERE kind=1 AND name=?5)",
1071                )
1072                .map_err(sql_error)?
1073                .query_map(
1074                    params![name_hash(&relation.from), relation.from, name_hash(&relation.to), relation.to, relation.relation_type],
1075                    |row| {
1076                        Ok((
1077                            row.get::<_, i64>(1)?,
1078                            row.get::<_, i64>(2)?,
1079                            row.get::<_, i64>(3)?,
1080                        ))
1081                    },
1082                )
1083                .map_err(sql_error)?
1084                .collect::<rusqlite::Result<Vec<(i64, i64, i64)>>>()
1085                .map_err(sql_error)?;
1086                conn.execute("DELETE FROM relation WHERE from_id IN (SELECT id FROM entity WHERE name_hash=?1 AND name=?2 AND flags=0) AND to_id IN (SELECT id FROM entity WHERE name_hash=?3 AND name=?4 AND flags=0) AND type_id IN (SELECT id FROM type_dict WHERE kind=1 AND name=?5)", params![name_hash(&relation.from),relation.from,name_hash(&relation.to),relation.to,relation.relation_type]).map_err(sql_error)?;
1087                for (from_id, to_id, type_id) in triples {
1088                    tombstone_relation_mirror(conn, from_id, to_id, type_id)?;
1089                }
1090            }
1091            Ok(MutationResult::Unit)
1092        }
1093        MutationRequest::AddRelationObservations { relations } => {
1094            let mut result = Vec::new();
1095            for update in relations {
1096                let mirror_id = resolve_relation_mirror(conn, &update.relation)?;
1097                let inserted =
1098                    insert_relation_observations(graph, conn, mirror_id, &update.contents)?;
1099                if !inserted.is_empty() {
1100                    bump_relation_revision_enqueue(conn, mirror_id)?;
1101                }
1102                result.push(RelationObservationResult {
1103                    from: update.relation.from,
1104                    to: update.relation.to,
1105                    relation_type: update.relation.relation_type,
1106                    added_observations: inserted,
1107                });
1108            }
1109            Ok(MutationResult::RelationObservations(result))
1110        }
1111        MutationRequest::DeleteRelationObservations { relations } => {
1112            for update in relations {
1113                if update.contents.is_empty() {
1114                    continue;
1115                }
1116                let mirror_id = resolve_relation_mirror(conn, &update.relation)?;
1117                let mut deleted: i64 = 0;
1118                for body in &update.contents {
1119                    deleted += conn
1120                        .execute(
1121                            "DELETE FROM relation_observation WHERE relation_id=?1 AND body=?2",
1122                            params![mirror_id, body.body],
1123                        )
1124                        .map_err(sql_error)? as i64;
1125                }
1126                if deleted > 0 {
1127                    conn.execute(
1128                        "UPDATE graph_stat SET value=value-?1 WHERE key='relation_obs'",
1129                        [deleted],
1130                    )
1131                    .map_err(sql_error)?;
1132                    bump_relation_revision_enqueue(conn, mirror_id)?;
1133                }
1134            }
1135            Ok(MutationResult::Unit)
1136        }
1137        MutationRequest::SetAttributes { targets } => {
1138            for target in targets {
1139                let (owner_kind, owner_id) = resolve_attribute_owner(
1140                    conn,
1141                    &target.owner_kind,
1142                    target.entity_name.as_deref(),
1143                    target.from.as_deref(),
1144                    target.to.as_deref(),
1145                    target.relation_type.as_deref(),
1146                )?;
1147                upsert_attributes(conn, &owner_kind, owner_id, &target.attributes)?;
1148            }
1149            Ok(MutationResult::Unit)
1150        }
1151        MutationRequest::DeleteAttributes { targets } => {
1152            for target in targets {
1153                let (owner_kind, owner_id) = resolve_attribute_owner(
1154                    conn,
1155                    &target.owner_kind,
1156                    target.entity_name.as_deref(),
1157                    target.from.as_deref(),
1158                    target.to.as_deref(),
1159                    target.relation_type.as_deref(),
1160                )?;
1161                delete_attribute_keys(conn, &owner_kind, owner_id, &target.keys)?;
1162            }
1163            Ok(MutationResult::Unit)
1164        }
1165        MutationRequest::AddObservations { observations } => {
1166            let mut result = Vec::new();
1167            for update in observations {
1168                let entity = require_entity(conn, &update.entity_name)?;
1169                let inserted =
1170                    insert_observations(graph, conn, entity.entity_id, &update.contents)?;
1171                result.push(ObservationResult {
1172                    entity_name: update.entity_name,
1173                    added_observations: inserted,
1174                });
1175            }
1176            Ok(MutationResult::Observations(result))
1177        }
1178        MutationRequest::DeleteObservations { observations } => {
1179            for update in observations {
1180                if update.contents.is_empty() {
1181                    continue;
1182                }
1183                let entity = require_entity(conn, &update.entity_name)?;
1184                for body in &update.contents {
1185                    conn.execute(
1186                        "DELETE FROM observation WHERE entity_id=?1 AND body=?2",
1187                        params![entity.entity_id, body.body],
1188                    )
1189                    .map_err(sql_error)?;
1190                }
1191            }
1192            Ok(MutationResult::Unit)
1193        }
1194        MutationRequest::MergeEntities { source, target } => {
1195            let old = require_entity(conn, &source)?;
1196            let into = require_entity(conn, &target)?;
1197            if source != target {
1198                // Body remains the observation identity. Equal target bodies keep
1199                // their metadata; newly copied rows retain the original fact/write
1200                // times and record this merge's immediate source as audit origin.
1201                let mut seen: BTreeSet<&str> =
1202                    into.observations.iter().map(|o| o.body.as_str()).collect();
1203                let mut idx: i64 = conn
1204                    .query_row(
1205                        "SELECT COALESCE(MAX(idx),-1) FROM observation WHERE entity_id=?1",
1206                        [into.entity_id],
1207                        |r| r.get(0),
1208                    )
1209                    .map_err(sql_error)?;
1210                for observation in &old.observations {
1211                    if seen.insert(&observation.body) {
1212                        idx += 1;
1213                        conn.execute("INSERT INTO observation(id,entity_id,idx,body,created_us,occurred_us,origin_entity_id,origin_entity_name) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![graph.next_obs_id(),into.entity_id,idx,observation.body,observation.created_at_us,observation.occurred_at_us,old.entity_id,old.name]).map_err(sql_error)?;
1214                    }
1215                }
1216                // Source k:v attributes move to the target; the collision rule
1217                // is source-wins (`ON CONFLICT ... DO UPDATE`). The source's
1218                // own rows are deleted by the delete_entities below.
1219                let source_attributes: BTreeMap<String, String> = conn
1220                    .prepare_cached(
1221                        "SELECT key, value FROM attribute
1222                         WHERE owner_kind='entity' AND owner_id=?1",
1223                    )
1224                    .map_err(sql_error)?
1225                    .query_map([old.entity_id], |row| Ok((row.get(0)?, row.get(1)?)))
1226                    .map_err(sql_error)?
1227                    .collect::<rusqlite::Result<_>>()
1228                    .map_err(sql_error)?;
1229                if !source_attributes.is_empty() {
1230                    upsert_attributes(conn, "entity", into.entity_id, &source_attributes)?;
1231                }
1232                let relations = relations_for(conn, &source)?;
1233                for mut relation in relations {
1234                    if relation.from == source {
1235                        relation.from = target.clone();
1236                    }
1237                    if relation.to == source {
1238                        relation.to = target.clone();
1239                    }
1240                    create_relation(conn, &relation)?;
1241                }
1242                delete_entities(conn, std::slice::from_ref(&source))?;
1243            }
1244            Ok(MutationResult::Entity(
1245                require_entity(conn, &target)?.entity(),
1246            ))
1247        }
1248        MutationRequest::RenameEntity { old_name, new_name } => {
1249            let entity = require_entity(conn, &old_name)?;
1250            if old_name == new_name {
1251                return Ok(MutationResult::Entity(entity.entity()));
1252            }
1253            if read_entity(conn, &new_name)?.is_some() {
1254                return Err(MCSError::InvalidParams(format!(
1255                    "Entity '{new_name}' already exists"
1256                )));
1257            }
1258            conn.execute(
1259                "UPDATE entity SET name_hash=?1,name=?2 WHERE id=?3",
1260                params![name_hash(&new_name), new_name, entity.entity_id],
1261            )
1262            .map_err(sql_error)?;
1263            conn.execute(
1264                "INSERT INTO name_fts(name_fts,rowid,name) VALUES('delete',?1,?2)",
1265                params![entity.entity_id, old_name],
1266            )
1267            .map_err(sql_error)?;
1268            conn.execute(
1269                "INSERT INTO name_fts(rowid,name) VALUES(?1,?2)",
1270                params![entity.entity_id, new_name],
1271            )
1272            .map_err(sql_error)?;
1273            Ok(MutationResult::Entity(
1274                require_entity(conn, &new_name)?.entity(),
1275            ))
1276        }
1277        MutationRequest::PurgeDefinedEntities { name } => {
1278            let names = defined_names(conn, &name)?;
1279            delete_entities(conn, &names)?;
1280            Ok(MutationResult::Count(names.len()))
1281        }
1282        MutationRequest::Compact => {
1283            conn.execute_batch("PRAGMA incremental_vacuum;")
1284                .map_err(sql_error)?;
1285            Ok(MutationResult::Unit)
1286        }
1287        MutationRequest::Wipe => {
1288            let mut stmt = conn
1289                .prepare("SELECT name FROM entity WHERE flags=0")
1290                .map_err(sql_error)?;
1291            let names = stmt
1292                .query_map([], |row| row.get(0))
1293                .map_err(sql_error)?
1294                .collect::<rusqlite::Result<Vec<String>>>()
1295                .map_err(sql_error)?;
1296            delete_entities(conn, &names)?;
1297            // External-content indexes may contain orphan postings left by
1298            // legacy deletions. Reset the indexes inside this transaction too.
1299            conn.execute_batch(
1300                "INSERT INTO name_fts(name_fts) VALUES('delete-all');
1301                 INSERT INTO obs_fts(obs_fts) VALUES('delete-all');
1302                 INSERT INTO rel_obs_fts(rel_obs_fts) VALUES('delete-all');
1303                 UPDATE graph_stat SET value=0 WHERE key='relation_obs';",
1304            )
1305            .map_err(sql_error)?;
1306            Ok(MutationResult::Unit)
1307        }
1308    }
1309}
1310
1311fn update_counters(
1312    conn: &Connection,
1313    before: &Snapshot,
1314    after: &Snapshot,
1315    changes: &[EntityChange],
1316) -> Result<()> {
1317    let mut type_deltas: BTreeMap<(i64, &str), i64> = BTreeMap::new();
1318    for old in before.entities.values() {
1319        *type_deltas.entry((0, &old.entity_type)).or_default() -= 1;
1320    }
1321    for new in after.entities.values() {
1322        *type_deltas.entry((0, &new.entity_type)).or_default() += 1;
1323    }
1324    for (old, count) in &before.relation_rows {
1325        *type_deltas.entry((1, &old.relation_type)).or_default() -= count;
1326    }
1327    for (new, count) in &after.relation_rows {
1328        *type_deltas.entry((1, &new.relation_type)).or_default() += count;
1329    }
1330    // Affected types are the union of the net-delta keys and the types of
1331    // every entity change. Each affected type gets ONE count/revision update
1332    // and ONE enqueue, never one per change.
1333    let mut affected_types: BTreeSet<(i64, &str)> = type_deltas.keys().copied().collect();
1334    for change in changes {
1335        if let Some(before) = &change.before {
1336            affected_types.insert((0, before.entity_type.as_str()));
1337        }
1338        if let Some(after) = &change.after {
1339            affected_types.insert((0, after.entity_type.as_str()));
1340        }
1341        if let Some(delta) = &change.relation_delta {
1342            for relation in delta.added.iter().chain(delta.removed.iter()) {
1343                affected_types.insert((1, relation.relation_type.as_str()));
1344            }
1345        }
1346    }
1347    for (kind, name) in affected_types {
1348        let delta = type_deltas.get(&(kind, name)).copied().unwrap_or(0);
1349        let revision: i64 = conn
1350            .query_row(
1351                "UPDATE type_dict SET count=count+?1, revision=revision+1 WHERE kind=?2 AND name=?3 RETURNING revision",
1352                params![delta, kind, name],
1353                |row| row.get(0),
1354            )
1355            .map_err(sql_error)?;
1356        enqueue_taxonomy_jobs(
1357            conn,
1358            kind,
1359            type_id(conn, name, kind)?,
1360            revision,
1361            crate::jobs::IndexOperation::Upsert,
1362        )?;
1363    }
1364    let observations = |snapshot: &Snapshot| {
1365        snapshot
1366            .entities
1367            .values()
1368            .map(|e| e.observations.len() as i64)
1369            .sum::<i64>()
1370    };
1371    for (key, delta) in [
1372        (
1373            "entities",
1374            after.entities.len() as i64 - before.entities.len() as i64,
1375        ),
1376        (
1377            "relations",
1378            after.relation_rows.values().sum::<i64>() - before.relation_rows.values().sum::<i64>(),
1379        ),
1380        ("observations", observations(after) - observations(before)),
1381    ] {
1382        if delta != 0 {
1383            conn.execute(
1384                "UPDATE graph_stat SET value=value+?1 WHERE key=?2",
1385                params![delta, key],
1386            )
1387            .map_err(sql_error)?;
1388        }
1389    }
1390    let mut degrees: BTreeMap<&str, (i64, i64)> = BTreeMap::new();
1391    for (relation, count) in &after.relation_rows {
1392        degrees.entry(&relation.from).or_default().0 += count;
1393        degrees.entry(&relation.to).or_default().1 += count;
1394    }
1395    for entity in changes
1396        .iter()
1397        .filter(|change| change.operation != ChangeOperation::Rename)
1398        .filter_map(|change| change.after.as_ref())
1399    {
1400        let (outgoing, incoming) = degrees
1401            .get(entity.name.as_str())
1402            .copied()
1403            .unwrap_or_default();
1404        conn.execute(
1405            "UPDATE entity SET obs_count=?1,out_deg=?2,in_deg=?3,updated_us=?4 WHERE id=?5",
1406            params![
1407                entity.observations.len() as i64,
1408                outgoing,
1409                incoming,
1410                now_us(),
1411                entity.entity_id
1412            ],
1413        )
1414        .map_err(sql_error)?;
1415    }
1416    Ok(())
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421    use super::*;
1422    use crate::graph::GraphHandle;
1423    use crate::storage::{Durability, SqliteTuning};
1424    use crate::types::EntityInput as Entity;
1425    use std::num::NonZeroUsize;
1426    use std::ops::Deref;
1427    use std::path::PathBuf;
1428
1429    struct TestKg(GraphHandle, PathBuf);
1430
1431    impl Deref for TestKg {
1432        type Target = GraphHandle;
1433        fn deref(&self) -> &GraphHandle {
1434            &self.0
1435        }
1436    }
1437
1438    impl Drop for TestKg {
1439        fn drop(&mut self) {
1440            let _ = std::fs::remove_file(&self.1);
1441            let _ = std::fs::remove_file(self.1.with_extension("db-wal"));
1442            let _ = std::fs::remove_file(self.1.with_extension("db-shm"));
1443        }
1444    }
1445
1446    fn new_kg() -> TestKg {
1447        use std::sync::atomic::AtomicU64;
1448        use std::sync::atomic::Ordering;
1449        static COUNTER: AtomicU64 = AtomicU64::new(200_000);
1450        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
1451        let path =
1452            std::env::temp_dir().join(format!("kg_mutation_{}_{}.db", std::process::id(), n));
1453        let _ = std::fs::remove_file(&path);
1454        let _ = std::fs::remove_file(path.with_extension("db-wal"));
1455        let _ = std::fs::remove_file(path.with_extension("db-shm"));
1456        let kg = GraphHandle::new(
1457            &path,
1458            Durability::Async,
1459            SqliteTuning::default(),
1460            NonZeroUsize::new(10000).unwrap(),
1461            4,
1462        )
1463        .expect("create test kg");
1464        TestKg(kg, path)
1465    }
1466
1467    /// One managed serving profile, so taxonomy jobs land pending instead of
1468    /// held.
1469    fn serving_profile(kg: &GraphHandle) -> Uuid {
1470        let profile = Uuid::new_v4();
1471        let conn = kg.writer.lock();
1472        conn.execute(
1473            "UPDATE index_profile_registry SET state='Active', serving_profile=?1 WHERE store_key='default'",
1474            [profile.to_string()],
1475        )
1476        .expect("activate serving profile");
1477        profile
1478    }
1479
1480    fn entity(name: &str, entity_type: &str) -> Entity {
1481        Entity {
1482            name: name.into(),
1483            entity_type: entity_type.into(),
1484            observations: vec![],
1485            attributes: None,
1486        }
1487    }
1488
1489    fn relation(from: &str, to: &str, relation_type: &str) -> Relation {
1490        Relation {
1491            from: from.into(),
1492            to: to.into(),
1493            relation_type: relation_type.into(),
1494        }
1495    }
1496
1497    fn relation_input(from: &str, to: &str, relation_type: &str) -> RelationInput {
1498        RelationInput {
1499            from: from.into(),
1500            to: to.into(),
1501            relation_type: relation_type.into(),
1502            observations: vec![],
1503            attributes: None,
1504        }
1505    }
1506
1507    /// (owner_id, owner_revision, operation, state) of the chunk jobs for
1508    /// relation owners. The relation funnels enqueue these instead of the
1509    /// retired taxonomy kind-2 rows.
1510    fn relation_chunk_jobs(kg: &GraphHandle) -> Vec<(i64, i64, String, String)> {
1511        let conn = kg.writer.lock();
1512        let mut stmt = conn
1513            .prepare(
1514                "SELECT owner_id, owner_revision, operation, state FROM chunk_index_job
1515                 WHERE owner_kind='relation' ORDER BY owner_id",
1516            )
1517            .unwrap();
1518        stmt.query_map([], |row| {
1519            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
1520        })
1521        .unwrap()
1522        .collect::<rusqlite::Result<_>>()
1523        .unwrap()
1524    }
1525
1526    /// (subject_kind, subject_id, subject_revision, operation, state)
1527    fn taxonomy_jobs(kg: &GraphHandle) -> Vec<(i64, i64, i64, String, String)> {
1528        let conn = kg.writer.lock();
1529        let mut stmt = conn
1530            .prepare("SELECT subject_kind, subject_id, subject_revision, operation, state FROM taxonomy_job ORDER BY subject_kind, subject_id")
1531            .unwrap();
1532        stmt.query_map([], |row| {
1533            Ok((
1534                row.get(0)?,
1535                row.get(1)?,
1536                row.get(2)?,
1537                row.get(3)?,
1538                row.get(4)?,
1539            ))
1540        })
1541        .unwrap()
1542        .collect::<rusqlite::Result<_>>()
1543        .unwrap()
1544    }
1545
1546    /// (count, revision) of one type_dict row.
1547    fn type_row(kg: &GraphHandle, kind: i64, name: &str) -> (i64, i64) {
1548        let conn = kg.writer.lock();
1549        conn.query_row(
1550            "SELECT count, revision FROM type_dict WHERE kind=?1 AND name=?2",
1551            params![kind, name],
1552            |row| Ok((row.get(0)?, row.get(1)?)),
1553        )
1554        .unwrap()
1555    }
1556
1557    /// (id, revision, deleted) of the mirror row for one relation triple.
1558    fn mirror_row(kg: &GraphHandle, from: &str, to: &str, relation_type: &str) -> (i64, i64, i64) {
1559        let conn = kg.writer.lock();
1560        conn.query_row(
1561            "SELECT m.id, m.revision, m.deleted
1562             FROM taxonomy_relation m
1563             JOIN entity f ON f.id = m.from_id
1564             JOIN entity t ON t.id = m.to_id
1565             JOIN type_dict d ON d.id = m.type_id
1566             WHERE f.name=?1 AND t.name=?2 AND d.name=?3 AND d.kind=1",
1567            params![from, to, relation_type],
1568            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1569        )
1570        .unwrap()
1571    }
1572
1573    #[test]
1574    fn create_entity_enqueues_one_type_job_at_revision_one() {
1575        let kg = new_kg();
1576        serving_profile(&kg);
1577        kg.create_entities(&[entity("ada", "person")]).unwrap();
1578
1579        let jobs = taxonomy_jobs(&kg);
1580        assert_eq!(jobs.len(), 1, "exactly one taxonomy job expected");
1581        assert_eq!(jobs[0].0, 0, "entity type job expected");
1582        assert_eq!(jobs[0].2, 1, "first revision expected");
1583        assert_eq!(jobs[0].3, "upsert");
1584        assert_eq!(jobs[0].4, "pending");
1585        let (count, revision) = type_row(&kg, 0, "person");
1586        assert_eq!((count, revision), (1, 1));
1587    }
1588
1589    #[test]
1590    fn second_entity_of_same_type_upserts_job_to_revision_two() {
1591        let kg = new_kg();
1592        serving_profile(&kg);
1593        kg.create_entities(&[entity("ada", "person")]).unwrap();
1594        kg.create_entities(&[entity("bob", "person")]).unwrap();
1595
1596        let jobs = taxonomy_jobs(&kg);
1597        assert_eq!(jobs.len(), 1, "one job row per affected type expected");
1598        assert_eq!((jobs[0].0, jobs[0].2, jobs[0].3.as_str()), (0, 2, "upsert"));
1599        let (count, revision) = type_row(&kg, 0, "person");
1600        assert_eq!((count, revision), (2, 2));
1601    }
1602
1603    #[test]
1604    fn rename_bumps_type_revision_without_count_change() {
1605        let kg = new_kg();
1606        serving_profile(&kg);
1607        kg.create_entities(&[entity("ada", "person")]).unwrap();
1608        kg.rename_entity("ada", "ada lovelace").unwrap();
1609
1610        let (count, revision) = type_row(&kg, 0, "person");
1611        assert_eq!((count, revision), (1, 2), "count stable, revision bumped");
1612        let jobs = taxonomy_jobs(&kg);
1613        assert_eq!(jobs.len(), 1);
1614        assert_eq!((jobs[0].0, jobs[0].2, jobs[0].3.as_str()), (0, 2, "upsert"));
1615    }
1616
1617    #[test]
1618    fn create_relation_writes_mirror() {
1619        let kg = new_kg();
1620        serving_profile(&kg);
1621        kg.create_entities(&[entity("ada", "person"), entity("bob", "person")])
1622            .unwrap();
1623        kg.create_relations(&[relation_input("ada", "bob", "knows")])
1624            .unwrap();
1625
1626        let (mirror_id, mirror_revision, deleted) = mirror_row(&kg, "ada", "bob", "knows");
1627        assert_eq!((mirror_revision, deleted), (1, 0), "fresh mirror expected");
1628
1629        let jobs = relation_chunk_jobs(&kg);
1630        assert_eq!(jobs.len(), 1, "the mirror enqueues one relation chunk job");
1631        assert_eq!(
1632            (jobs[0].0, jobs[0].1, jobs[0].2.as_str(), jobs[0].3.as_str()),
1633            (mirror_id, 1, "upsert", "pending")
1634        );
1635        let (count, revision) = type_row(&kg, 1, "knows");
1636        assert_eq!((count, revision), (1, 1));
1637    }
1638
1639    #[test]
1640    fn delete_relation_tombstones_mirror_and_enqueues_delete() {
1641        let kg = new_kg();
1642        serving_profile(&kg);
1643        kg.create_entities(&[entity("ada", "person"), entity("bob", "person")])
1644            .unwrap();
1645        kg.create_relations(&[relation_input("ada", "bob", "knows")])
1646            .unwrap();
1647        let (mirror_id, _, _) = mirror_row(&kg, "ada", "bob", "knows");
1648
1649        kg.delete_relations(&[relation("ada", "bob", "knows")])
1650            .unwrap();
1651
1652        let (mirror_id_after, revision, deleted) = mirror_row(&kg, "ada", "bob", "knows");
1653        assert_eq!(
1654            (mirror_id_after, revision, deleted),
1655            (mirror_id, 2, 1),
1656            "tombstone expected"
1657        );
1658        let conn = kg.writer.lock();
1659        let remaining: i64 = conn
1660            .query_row("SELECT COUNT(*) FROM relation", [], |r| r.get(0))
1661            .unwrap();
1662        drop(conn);
1663        assert_eq!(remaining, 0, "physical triple deleted");
1664        let jobs = relation_chunk_jobs(&kg);
1665        assert_eq!(jobs.len(), 1);
1666        assert_eq!(
1667            (jobs[0].0, jobs[0].1, jobs[0].2.as_str()),
1668            (mirror_id, 2, "delete")
1669        );
1670    }
1671
1672    #[test]
1673    fn recreated_relation_reuses_no_freed_mirror_id() {
1674        // The mirror id must not track the physical rowid: SQLite reuses a
1675        // freed rowid for a later row, and an explicit-id mirror insert would
1676        // collide with the tombstoned mirror of a different triple.
1677        let kg = new_kg();
1678        serving_profile(&kg);
1679        kg.create_entities(&[
1680            entity("ada", "person"),
1681            entity("bob", "person"),
1682            entity("carol", "person"),
1683        ])
1684        .unwrap();
1685        kg.create_relations(&[relation_input("ada", "bob", "knows")])
1686            .unwrap();
1687        let (first_id, _, _) = mirror_row(&kg, "ada", "bob", "knows");
1688        kg.delete_relations(&[relation("ada", "bob", "knows")])
1689            .unwrap();
1690        // The new triple reuses the freed relation rowid; its mirror must get
1691        // a fresh id instead of colliding with the tombstoned mirror above.
1692        kg.create_relations(&[relation_input("ada", "carol", "knows")])
1693            .unwrap();
1694        let (second_id, revision, deleted) = mirror_row(&kg, "ada", "carol", "knows");
1695        assert_ne!(first_id, second_id);
1696        assert_eq!((revision, deleted), (1, 0));
1697        let jobs = relation_chunk_jobs(&kg);
1698        assert_eq!(jobs.len(), 2);
1699        assert_eq!(
1700            (jobs[1].0, jobs[1].1, jobs[1].2.as_str()),
1701            (second_id, 1, "upsert")
1702        );
1703    }
1704
1705    #[test]
1706    fn one_mutation_with_many_changes_bumps_each_type_once() {
1707        let kg = new_kg();
1708        serving_profile(&kg);
1709        // Two creations of one new type in a single call. The union ruling
1710        // demands one revision bump and one job row for the affected type.
1711        kg.create_entities(&[entity("ada", "person"), entity("bob", "person")])
1712            .unwrap();
1713
1714        let (count, revision) = type_row(&kg, 0, "person");
1715        assert_eq!((count, revision), (2, 1), "one bump, not one per change");
1716        let jobs = taxonomy_jobs(&kg);
1717        assert_eq!(jobs.len(), 1);
1718        assert_eq!((jobs[0].0, jobs[0].2), (0, 1));
1719    }
1720
1721    #[test]
1722    fn delete_entity_tombstones_its_relation_mirrors() {
1723        let kg = new_kg();
1724        serving_profile(&kg);
1725        kg.create_entities(&[entity("ada", "person"), entity("bob", "person")])
1726            .unwrap();
1727        kg.create_relations(&[relation_input("ada", "bob", "knows")])
1728            .unwrap();
1729        let (mirror_id, _, _) = mirror_row(&kg, "ada", "bob", "knows");
1730
1731        kg.delete_entities(&["ada".into()]).unwrap();
1732
1733        let conn = kg.writer.lock();
1734        let (revision, deleted): (i64, i64) = conn
1735            .query_row(
1736                "SELECT revision, deleted FROM taxonomy_relation WHERE id=?1",
1737                [mirror_id],
1738                |row| Ok((row.get(0)?, row.get(1)?)),
1739            )
1740            .unwrap();
1741        drop(conn);
1742        assert_eq!((revision, deleted), (2, 1), "cascade tombstone expected");
1743        let jobs = relation_chunk_jobs(&kg);
1744        assert_eq!(jobs.len(), 1);
1745        assert_eq!((jobs[0].1, jobs[0].2.as_str()), (2, "delete"));
1746        let (count, revision) = type_row(&kg, 0, "person");
1747        // Bump once at entity creation, once at relation creation (the
1748        // relation delta makes an entity change for each endpoint), once at
1749        // the delete. Count drops to the one survivor.
1750        assert_eq!((count, revision), (1, 3), "survivor count and bumps");
1751    }
1752}