1use 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 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#[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#[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 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 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 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
602fn 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
618fn 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
696fn 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
718fn 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
774fn 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
815fn 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
835fn 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 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
922fn 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!((count, revision), (1, 3), "survivor count and bumps");
1751 }
1752}