use std::collections::BTreeMap;
use graph_storage_sdk::models::{
DeleteOutcome, DeleteRequest, EdgeSpec, EffectiveTraits, GraphRevision, IngestCounts,
IngestOutcome, IngestRequest, ItemError, ItemFamily, ItemOutcome, NodeSpec, RemainingBudget,
ReplaceScope, Subject,
};
use graph_storage_sdk::plugin_api::{EmbeddingPlan, GraphStoreError, StoreCtx};
use sea_orm::sea_query::{Expr, OnConflict};
use sea_orm::{ActiveValue, ColumnTrait, Condition, EntityTrait, ExprTrait, QueryFilter};
use time::OffsetDateTime;
use toolkit_db::secure::{DBRunner, SecureEntityExt, SecureInsertExt, SecureUpdateExt};
use toolkit_security::AccessScope;
use uuid::Uuid;
use crate::domain::embedding::{PlannedVector, StoredVector, VectorOutcome, decide_vector};
use crate::domain::identity;
use crate::domain::ownership;
use crate::domain::tally::IngestTally;
use crate::infra::projections::{
EdgeEnds, EdgeHop, EdgeState, NodeIdent, NodeState, NodeTyped, TypeMeta, edge_ends_columns,
edge_hop_columns, edge_state_columns, node_ident_columns, node_state_columns,
node_typed_columns, type_meta_columns,
};
use crate::infra::storage::entity::{edge, graph_meta, ingest_idempotency, node, scope_registry};
use crate::infra::store::types::interned_ids;
use crate::infra::store::{PgGraphStore, TxStoreError, map_db_error, map_scope_err};
struct TypeInfo {
id: i32,
uuid: Uuid,
family: Option<String>,
full_text_search: Vec<String>,
src_types: Vec<String>,
dst_types: Vec<String>,
}
#[derive(Clone, Copy)]
struct Endpoint {
id: i64,
type_id: i32,
}
fn item_error(index: usize, family: ItemFamily, type_id: &str, message: String) -> GraphStoreError {
GraphStoreError::Validation {
items: vec![ItemError {
index,
family,
gts_type: Some(type_id.to_owned()),
pointer: None,
message,
}],
}
}
pub(crate) fn compose_search_text(
name: Option<&str>,
payload: Option<&serde_json::Value>,
paths: &[String],
) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(name) = name {
parts.push(name.to_owned());
}
if let Some(payload) = payload {
for path in paths {
if path == "/name" {
continue;
}
if let Some(value) = payload.pointer(path.strip_prefix("/payload").unwrap_or(path)) {
match value {
serde_json::Value::String(text) => parts.push(text.clone()),
other => parts.push(other.to_string()),
}
}
}
}
parts.join(" ")
}
async fn resolve_types(
scope: &AccessScope,
runner: &impl DBRunner,
request: &IngestRequest,
) -> Result<BTreeMap<String, TypeInfo>, GraphStoreError> {
let mut wanted: Vec<String> = request
.nodes
.iter()
.map(|n| n.type_id.clone())
.chain(request.edges.iter().map(|e| e.type_id.clone()))
.collect();
if !request.edges.is_empty() {
wanted.push(graph_storage_sdk::gts::PHANTOM_NODE_TYPE.to_owned());
}
wanted.sort();
wanted.dedup();
let raw = interned_ids(scope, runner, &wanted).await?;
Ok(raw
.into_iter()
.map(|(type_id, (id, uuid, traits))| {
let family = traits
.get("family")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let resolved = crate::infra::store::types::traits_from_json(&traits);
(
type_id,
TypeInfo {
id,
uuid,
family,
full_text_search: resolved.full_text_search,
src_types: resolved.src_types,
dst_types: resolved.dst_types,
},
)
})
.collect())
}
fn outcome_to_json(outcome: &IngestOutcome) -> serde_json::Value {
let c = &outcome.counts;
serde_json::json!({
"revision": {
"source_epoch": outcome.revision.source_epoch,
"revision": outcome.revision.revision,
},
"counts": {
"nodes_inserted": c.nodes_inserted,
"nodes_updated": c.nodes_updated,
"nodes_unchanged": c.nodes_unchanged,
"edges_inserted": c.edges_inserted,
"edges_updated": c.edges_updated,
"edges_unchanged": c.edges_unchanged,
"phantoms_created": c.phantoms_created,
"phantoms_materialized": c.phantoms_materialized,
"scope_removed_nodes": c.scope_removed_nodes,
"scope_removed_edges": c.scope_removed_edges,
},
})
}
fn outcome_from_json(value: &serde_json::Value) -> Result<IngestOutcome, GraphStoreError> {
let corrupt = |what: &str| GraphStoreError::Corrupt {
reason: format!("idempotency receipt is missing `{what}`"),
};
let revision = value.get("revision").ok_or_else(|| corrupt("revision"))?;
let counts = value.get("counts").ok_or_else(|| corrupt("counts"))?;
let number = |parent: &serde_json::Value, key: &str| -> u64 {
parent
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
};
Ok(IngestOutcome {
revision: GraphRevision {
source_epoch: revision
.get("source_epoch")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| corrupt("revision.source_epoch"))?,
revision: revision
.get("revision")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| corrupt("revision.revision"))?,
},
replayed: false,
counts: IngestCounts {
nodes_inserted: number(counts, "nodes_inserted"),
nodes_updated: number(counts, "nodes_updated"),
nodes_unchanged: number(counts, "nodes_unchanged"),
edges_inserted: number(counts, "edges_inserted"),
edges_updated: number(counts, "edges_updated"),
edges_unchanged: number(counts, "edges_unchanged"),
phantoms_created: number(counts, "phantoms_created"),
phantoms_materialized: number(counts, "phantoms_materialized"),
scope_removed_nodes: number(counts, "scope_removed_nodes"),
scope_removed_edges: number(counts, "scope_removed_edges"),
},
per_item_nodes: None,
per_item_edges: None,
})
}
async fn current_revision(
scope: &AccessScope,
runner: &impl DBRunner,
) -> Result<i64, GraphStoreError> {
let row = graph_meta::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(graph_meta::Column::Key.eq(graph_meta::KEY_GRAPH_REVISION)))
.one(runner)
.await
.map_err(map_scope_err)?;
Ok(row.and_then(|r| r.value.as_i64()).unwrap_or(0))
}
async fn source_epoch(scope: &AccessScope, runner: &impl DBRunner) -> Result<i64, GraphStoreError> {
let row = graph_meta::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(graph_meta::Column::Key.eq(graph_meta::KEY_SOURCE_EPOCH)))
.one(runner)
.await
.map_err(map_scope_err)?;
Ok(row.and_then(|r| r.value.as_i64()).unwrap_or(1))
}
pub(crate) async fn bump_revision(
tenant: Uuid,
scope: &AccessScope,
runner: &impl DBRunner,
) -> Result<i64, GraphStoreError> {
let incremented = Expr::cust("to_jsonb(((graph_meta.value #>> '{}')::bigint) + 1)");
let active = graph_meta::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
key: ActiveValue::Set(graph_meta::KEY_GRAPH_REVISION.to_owned()),
value: ActiveValue::Set(serde_json::json!(1)),
};
let on_conflict = toolkit_db::secure::SecureOnConflict::<graph_meta::Entity>::columns([
graph_meta::Column::TenantId,
graph_meta::Column::Key,
])
.value(graph_meta::Column::Value, incremented)
.map_err(map_scope_err)?;
graph_meta::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.on_conflict(on_conflict)
.exec(runner)
.await
.map_err(map_scope_err)?;
current_revision(scope, runner).await
}
pub async fn ingest(
store: &PgGraphStore,
ctx: &StoreCtx<'_>,
request: IngestRequest,
embedding: EmbeddingPlan,
) -> Result<IngestOutcome, GraphStoreError> {
let tenant = ctx.tenant;
let scope = ctx.scope.clone();
let subject = ctx.subject.clone();
let budget = ctx.budget;
let producer = subject.principal();
store
.db()
.transaction_ref_mapped::<_, IngestOutcome, TxStoreError>(move |tx| {
let request = request.clone();
let embedding = embedding.clone();
let scope = scope.clone();
let subject = subject.clone();
let producer = producer.clone();
Box::pin(async move {
ingest_in_tx(
Writer {
tenant,
scope: &scope,
subject: &subject,
budget,
},
&producer,
tx,
request,
&embedding,
)
.await
.map_err(TxStoreError::from)
})
})
.await
.map_err(|error| error.0)
}
#[derive(Clone, Copy)]
struct Writer<'a> {
tenant: Uuid,
scope: &'a AccessScope,
subject: &'a Subject,
budget: RemainingBudget,
}
async fn ingest_in_tx(
w: Writer<'_>,
producer: &str,
tx: &impl DBRunner,
request: IngestRequest,
embedding: &EmbeddingPlan,
) -> Result<IngestOutcome, GraphStoreError> {
let (tenant, scope) = (w.tenant, w.scope);
let epoch = source_epoch(scope, tx).await?;
let request_hash = identity::ingest_request_hash(&request);
if let Some(key) = &request.idempotency_key
&& let Some(replayed) =
replay_receipt(scope, producer, tx, key, &request_hash, epoch).await?
{
return Ok(replayed);
}
let mut tally = IngestTally::new(request.options.report_per_item);
if let Some(replace) = &request.replace_scope {
fence_scope(tenant, scope, producer, tx, replace, &request_hash).await?;
}
let types = resolve_types(scope, tx, &request).await?;
let mut changed = false;
let mut node_ids: BTreeMap<String, Endpoint> = BTreeMap::new();
changed |= write_nodes(
w,
tx,
&request,
&types,
&mut node_ids,
&mut tally,
embedding,
)
.await?;
changed |= write_edges(w, tx, &request, &types, &mut node_ids, &mut tally).await?;
if let Some(replace) = &request.replace_scope {
let written: std::collections::BTreeSet<String> = request
.nodes
.iter()
.map(|spec| spec.node_key.clone())
.collect();
let declared_edges: std::collections::BTreeSet<String> = request
.edges
.iter()
.filter_map(|spec| {
let info = types.get(&spec.type_id)?;
Some(identity::derive_edge_key(info.uuid, spec))
})
.collect();
let (removed_nodes, removed_edges) = super::scope::remove_stale(
scope,
tx,
&replace.attribute,
&replace.value,
&written,
&declared_edges,
)
.await?;
tally.counts.scope_removed_nodes = removed_nodes;
tally.counts.scope_removed_edges = removed_edges;
changed |= removed_nodes > 0 || removed_edges > 0;
}
let revision_value = if changed {
bump_revision(tenant, scope, tx).await?
} else {
current_revision(scope, tx).await?
};
let (counts, per_item_nodes, per_item_edges) = tally.into_parts();
let outcome = IngestOutcome {
revision: GraphRevision {
source_epoch: epoch,
revision: revision_value,
},
replayed: false,
counts,
per_item_nodes,
per_item_edges,
};
if let Some(key) = &request.idempotency_key {
let response = outcome_to_json(&outcome);
let active = ingest_idempotency::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
producer: ActiveValue::Set(producer.to_owned()),
idempotency_key: ActiveValue::Set(key.clone()),
request_hash: ActiveValue::Set(request_hash),
source_epoch: ActiveValue::Set(epoch),
graph_revision: ActiveValue::Set(revision_value),
response: ActiveValue::Set(response),
created_at: ActiveValue::Set(OffsetDateTime::now_utc()),
};
ingest_idempotency::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.exec(tx)
.await
.map_err(map_scope_err)?;
}
Ok(outcome)
}
async fn fence_scope(
tenant: Uuid,
scope: &AccessScope,
producer: &str,
tx: &impl DBRunner,
replace: &ReplaceScope,
request_hash: &str,
) -> Result<(), GraphStoreError> {
let active = scope_registry::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
scope_attribute: ActiveValue::Set(replace.attribute.clone()),
scope_value: ActiveValue::Set(replace.value.clone()),
owner_producer: ActiveValue::Set(producer.to_owned()),
generation: ActiveValue::Set(replace.generation),
request_hash: ActiveValue::Set(request_hash.to_owned()),
updated_at: ActiveValue::Set(OffsetDateTime::now_utc()),
};
let keep_higher = Expr::cust("GREATEST(scope_registry.generation, excluded.generation)");
let claim_if_unowned = Expr::cust(
"CASE WHEN scope_registry.owner_producer = '' \
THEN excluded.owner_producer ELSE scope_registry.owner_producer END",
);
let hash_of_winner = Expr::cust(
"CASE WHEN excluded.generation > scope_registry.generation \
THEN excluded.request_hash ELSE scope_registry.request_hash END",
);
let on_conflict = toolkit_db::secure::SecureOnConflict::<scope_registry::Entity>::columns([
scope_registry::Column::TenantId,
scope_registry::Column::ScopeAttribute,
scope_registry::Column::ScopeValue,
])
.value(scope_registry::Column::Generation, keep_higher)
.map_err(map_scope_err)?
.value(scope_registry::Column::RequestHash, hash_of_winner)
.map_err(map_scope_err)?
.value(scope_registry::Column::OwnerProducer, claim_if_unowned)
.map_err(map_scope_err)?
.update_columns([scope_registry::Column::UpdatedAt])
.map_err(map_scope_err)?;
scope_registry::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.on_conflict(on_conflict)
.exec(tx)
.await
.map_err(map_scope_err)?;
let row = scope_registry::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(scope_registry::Column::ScopeAttribute.eq(replace.attribute.clone()))
.add(scope_registry::Column::ScopeValue.eq(replace.value.clone())),
)
.one(tx)
.await
.map_err(map_scope_err)?
.ok_or_else(|| {
GraphStoreError::Internal("the scope fence vanished after being written".to_owned())
})?;
if row.owner_producer != producer {
return Err(GraphStoreError::Conflict {
reason: format!(
"scope `{}={}` is owned by another producer; a replacement may only be \
submitted by its owner",
replace.attribute, replace.value
),
});
}
if row.generation > replace.generation {
return Err(GraphStoreError::StaleGeneration {
recorded: row.generation,
offered: replace.generation,
});
}
if row.request_hash != request_hash {
return Err(GraphStoreError::Conflict {
reason: "same source generation with different content".into(),
});
}
Ok(())
}
async fn lookup_endpoint(
scope: &AccessScope,
tx: &impl DBRunner,
key: &str,
) -> Result<Option<Endpoint>, GraphStoreError> {
Ok(node::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(node::Column::NodeKey.eq(key.to_owned())))
.filter(Condition::all().add(node::Column::DeletedAt.is_null()))
.limit(1)
.project_all(tx, |query| {
node_typed_columns(query).into_model::<NodeTyped>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next()
.map(|m| Endpoint {
id: m.id,
type_id: m.gts_node_type_id,
}))
}
async fn endpoint_is_tombstoned(
scope: &AccessScope,
tx: &impl DBRunner,
key: &str,
) -> Result<bool, GraphStoreError> {
Ok(node::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(node::Column::NodeKey.eq(key.to_owned())))
.filter(Condition::all().add(node::Column::DeletedAt.is_not_null()))
.limit(1)
.project_all(tx, |query| {
node_state_columns(query).into_model::<NodeState>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next()
.is_some())
}
async fn endpoint_types(
scope: &AccessScope,
tx: &impl DBRunner,
ids: &[i32],
) -> Result<BTreeMap<i32, (String, Option<String>)>, GraphStoreError> {
if ids.is_empty() {
return Ok(BTreeMap::new());
}
let rows = crate::infra::storage::entity::gts_type::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(crate::infra::storage::entity::gts_type::Column::Id.is_in(ids.to_vec())),
)
.project_all(tx, |query| {
type_meta_columns(query).into_model::<TypeMeta>()
})
.await
.map_err(map_scope_err)?;
Ok(rows
.into_iter()
.map(|row| {
let family = row
.effective_traits
.get("family")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
(row.id, (row.gts_type_id, family))
})
.collect())
}
async fn revalidate_incident_edges(
scope: &AccessScope,
tx: &impl DBRunner,
node_id: i64,
concrete_type: &str,
index: usize,
) -> Result<(), GraphStoreError> {
let incident = edge::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::any()
.add(edge::Column::SrcNodeId.eq(node_id))
.add(edge::Column::DstNodeId.eq(node_id)),
)
.filter(Condition::all().add(edge::Column::DeletedAt.is_null()))
.project_all(tx, |query| edge_hop_columns(query).into_model::<EdgeHop>())
.await
.map_err(map_scope_err)?;
if incident.is_empty() {
return Ok(());
}
let mut edge_type_ids: Vec<i32> = incident.iter().map(|e| e.gts_edge_type_id).collect();
edge_type_ids.sort_unstable();
edge_type_ids.dedup();
let edge_types = crate::infra::storage::entity::gts_type::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(crate::infra::storage::entity::gts_type::Column::Id.is_in(edge_type_ids)),
)
.project_all(tx, |query| {
type_meta_columns(query).into_model::<TypeMeta>()
})
.await
.map_err(map_scope_err)?;
let by_id: BTreeMap<i32, (String, EffectiveTraits)> = edge_types
.into_iter()
.map(|row| {
(
row.id,
(
row.gts_type_id,
crate::infra::store::types::traits_from_json(&row.effective_traits),
),
)
})
.collect();
for e in incident {
let Some((edge_type, traits)) = by_id.get(&e.gts_edge_type_id) else {
continue;
};
for (is_end, patterns, which) in [
(e.src_node_id == node_id, &traits.src_types, "source"),
(e.dst_node_id == node_id, &traits.dst_types, "destination"),
] {
if !is_end {
continue;
}
if !endpoint_admitted(concrete_type, None, patterns)? {
return Err(GraphStoreError::Validation {
items: vec![ItemError {
index,
family: ItemFamily::Node,
gts_type: Some(concrete_type.to_owned()),
pointer: Some("/type".to_owned()),
message: format!(
"materializing this node as `{concrete_type}` would leave edge \
`{}` invalid: `{edge_type}` does not admit it as a {which} \
(accepts {})",
e.edge_key,
patterns.join(", ")
),
}],
});
}
}
}
Ok(())
}
fn endpoint_admitted(
endpoint_type: &str,
family: Option<&str>,
patterns: &[String],
) -> Result<bool, GraphStoreError> {
if family == Some("phantom") || patterns.is_empty() {
return Ok(true);
}
crate::domain::ontology::matches_any_pattern(endpoint_type, patterns).map_err(|error| {
GraphStoreError::Internal(format!(
"endpoint constraint is not a valid pattern: {error}"
))
})
}
struct VectorWrite {
embedding: Option<sea_orm::entity::prelude::PgVector>,
epoch: Option<i64>,
input_hash: Option<String>,
}
fn plan_vector(current: Option<&node::Model>, planned: PlannedVector<'_>) -> VectorWrite {
let stored = current.map(|row| StoredVector {
has_vector: row.embedding.is_some(),
input_hash: row.embedding_input_hash.as_deref(),
});
match decide_vector(stored, planned) {
VectorOutcome::Store {
vector,
epoch,
input_hash,
} => VectorWrite {
embedding: Some(sea_orm::entity::prelude::PgVector::from(vector)),
epoch,
input_hash: Some(input_hash),
},
VectorOutcome::Absent { input_hash } => VectorWrite {
embedding: None,
epoch: None,
input_hash: Some(input_hash),
},
VectorOutcome::Preserve => VectorWrite {
embedding: current.and_then(|row| row.embedding.clone()),
epoch: current.and_then(|row| row.embedding_epoch),
input_hash: current.and_then(|row| row.embedding_input_hash.clone()),
},
VectorOutcome::Stale => VectorWrite {
embedding: current.and_then(|row| row.embedding.clone()),
epoch: None,
input_hash: current.and_then(|row| row.embedding_input_hash.clone()),
},
}
}
async fn upsert_node(
w: Writer<'_>,
tx: &impl DBRunner,
spec: &NodeSpec,
info: &TypeInfo,
index: usize,
planned: PlannedVector<'_>,
) -> Result<(i64, ItemOutcome), GraphStoreError> {
let (tenant, scope, subject) = (w.tenant, w.scope, w.subject);
let existing = node::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(node::Column::NodeKey.eq(spec.node_key.clone())))
.one(tx)
.await
.map_err(map_scope_err)?;
let payload = spec
.payload
.clone()
.unwrap_or_else(|| serde_json::json!({}));
let name = spec.name.clone().unwrap_or_default();
let search_text = compose_search_text(
spec.name.as_deref(),
spec.payload.as_ref(),
&info.full_text_search,
);
let vector = plan_vector(existing.as_ref(), planned);
let now = OffsetDateTime::now_utc();
let namespace = match ownership::namespace_of(info.family.as_deref(), spec.payload.as_ref())
.map_err(|error| item_error(index, ItemFamily::Node, &spec.type_id, error.to_string()))?
{
ownership::Namespaced::None => None,
ownership::Namespaced::Under(namespace) => {
let writer = subject.principal();
super::namespaces::authorize_write(tenant, scope, tx, namespace, &writer).await?;
Some(namespace.to_owned())
}
};
let Some(current) = existing else {
if let Some(expected) = spec.expected_version
&& expected != 0
{
return Err(GraphStoreError::Conflict {
reason: format!(
"expected version {expected}, but no node is stored under key `{}`",
spec.node_key
),
});
}
let active = node::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
id: ActiveValue::NotSet,
node_key: ActiveValue::Set(spec.node_key.clone()),
gts_node_type_id: ActiveValue::Set(info.id),
name: ActiveValue::Set(name),
payload: ActiveValue::Set(payload),
search_text: ActiveValue::Set(search_text),
embedding: ActiveValue::Set(vector.embedding),
embedding_epoch: ActiveValue::Set(vector.epoch),
embedding_input_hash: ActiveValue::Set(vector.input_hash),
source_namespace: ActiveValue::Set(namespace),
owner_principal: ActiveValue::Set(subject.principal()),
version: ActiveValue::Set(1),
created_at: ActiveValue::Set(now),
updated_at: ActiveValue::Set(now),
deleted_at: ActiveValue::Set(None),
created_by_subject_id: ActiveValue::Set(subject.subject_id),
created_by_subject_type: ActiveValue::Set(subject.subject_type.clone()),
updated_by_subject_id: ActiveValue::Set(subject.subject_id),
updated_by_subject_type: ActiveValue::Set(subject.subject_type.clone()),
deleted_by_subject_id: ActiveValue::Set(None),
deleted_by_subject_type: ActiveValue::Set(None),
};
let model = node::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.exec_with_returning(tx)
.await
.map_err(map_scope_err)?;
return Ok((model.id, ItemOutcome::Inserted));
};
if current.deleted_at.is_some() {
return Err(GraphStoreError::Conflict {
reason: format!(
"node key `{}` is tombstoned and cannot be re-ingested before purge",
spec.node_key
),
});
}
let materializing = current.gts_node_type_id != info.id;
if materializing {
let previous_is_phantom = is_phantom_type(scope, tx, current.gts_node_type_id).await?;
if !previous_is_phantom {
return Err(GraphStoreError::Conflict {
reason: format!(
"node `{}` is already registered under a different type; a same-key \
ingest may not change it",
spec.node_key
),
});
}
}
if let Some(expected) = spec.expected_version
&& expected != current.version
{
return Err(GraphStoreError::Conflict {
reason: format!(
"expected version {expected}, stored version is {}",
current.version
),
});
}
let unchanged = current.name == name
&& current.payload == payload
&& current.search_text == search_text
&& current.embedding == vector.embedding
&& current.embedding_epoch == vector.epoch
&& current.embedding_input_hash == vector.input_hash
&& !materializing;
if unchanged {
return Ok((current.id, ItemOutcome::Unchanged));
}
if materializing {
revalidate_incident_edges(scope, tx, current.id, &spec.type_id, index).await?;
}
let id = current.id;
let mut update = node::Entity::update_many().col_expr(
node::Column::Version,
Expr::col(node::Column::Version).add(1),
);
if let Some(expected) = spec.expected_version {
update = update.filter(Condition::all().add(node::Column::Version.eq(expected)));
}
update = update.filter(Condition::all().add(node::Column::DeletedAt.is_null()));
let written = update
.col_expr(node::Column::GtsNodeTypeId, Expr::value(info.id))
.col_expr(node::Column::Name, Expr::value(name))
.col_expr(node::Column::Payload, Expr::value(payload))
.col_expr(node::Column::SearchText, Expr::value(search_text))
.col_expr(node::Column::Embedding, Expr::value(vector.embedding))
.col_expr(node::Column::EmbeddingEpoch, Expr::value(vector.epoch))
.col_expr(
node::Column::EmbeddingInputHash,
Expr::value(vector.input_hash),
)
.col_expr(node::Column::UpdatedAt, Expr::value(now))
.col_expr(
node::Column::UpdatedBySubjectId,
Expr::value(subject.subject_id),
)
.col_expr(
node::Column::UpdatedBySubjectType,
Expr::value(subject.subject_type.clone()),
)
.filter(Condition::all().add(node::Column::Id.eq(id)))
.secure()
.scope_with(scope)
.exec(tx)
.await
.map_err(map_scope_err)?;
if written.rows_affected == 0 {
let settled = node::Entity::find()
.filter(Condition::all().add(node::Column::Id.eq(id)))
.secure()
.scope_with(scope)
.limit(1)
.project_all(tx, |query| {
node_state_columns(query).into_model::<NodeState>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next();
return Err(GraphStoreError::Conflict {
reason: match settled {
Some(row) if row.deleted_at.is_some() => format!(
"node key `{}` was tombstoned while this write was being prepared, and a \
tombstoned key cannot be re-ingested before purge",
spec.node_key
),
Some(_) => format!(
"node `{}` changed between the check and the write; re-read it and retry \
with the version it has now",
spec.node_key
),
None => format!(
"node `{}` was removed while this write was being prepared; the key is \
free again, so re-ingest it",
spec.node_key
),
},
});
}
Ok((
id,
if materializing {
ItemOutcome::Materialized
} else {
ItemOutcome::Updated
},
))
}
async fn is_phantom_type(
scope: &AccessScope,
tx: &impl DBRunner,
type_id: i32,
) -> Result<bool, GraphStoreError> {
let model = crate::infra::storage::entity::gts_type::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all().add(crate::infra::storage::entity::gts_type::Column::Id.eq(type_id)),
)
.limit(1)
.project_all(tx, |query| {
type_meta_columns(query).into_model::<TypeMeta>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next();
Ok(model
.and_then(|m| {
m.effective_traits
.get("family")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
.as_deref()
== Some("phantom"))
}
async fn insert_phantom(
w: Writer<'_>,
tx: &impl DBRunner,
key: &str,
info: &TypeInfo,
) -> Result<i64, GraphStoreError> {
let (tenant, scope, subject) = (w.tenant, w.scope, w.subject);
let now = OffsetDateTime::now_utc();
let active = node::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
id: ActiveValue::NotSet,
node_key: ActiveValue::Set(key.to_owned()),
gts_node_type_id: ActiveValue::Set(info.id),
name: ActiveValue::Set(String::new()),
payload: ActiveValue::Set(serde_json::json!({})),
search_text: ActiveValue::Set(String::new()),
embedding: ActiveValue::Set(None),
embedding_epoch: ActiveValue::Set(None),
embedding_input_hash: ActiveValue::Set(None),
source_namespace: ActiveValue::Set(None),
owner_principal: ActiveValue::Set(String::new()),
version: ActiveValue::Set(1),
created_at: ActiveValue::Set(now),
updated_at: ActiveValue::Set(now),
deleted_at: ActiveValue::Set(None),
created_by_subject_id: ActiveValue::Set(subject.subject_id),
created_by_subject_type: ActiveValue::Set(subject.subject_type.clone()),
updated_by_subject_id: ActiveValue::Set(subject.subject_id),
updated_by_subject_type: ActiveValue::Set(subject.subject_type.clone()),
deleted_by_subject_id: ActiveValue::Set(None),
deleted_by_subject_type: ActiveValue::Set(None),
};
let inserted = node::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.on_conflict_raw(
OnConflict::columns([node::Column::TenantId, node::Column::NodeKey])
.do_nothing()
.to_owned(),
)
.exec_with_returning(tx)
.await;
match inserted {
Ok(model) => Ok(model.id),
Err(toolkit_db::secure::ScopeError::Db(sea_orm::DbErr::RecordNotFound(_))) => {
let settled = node::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(node::Column::NodeKey.eq(key.to_owned())))
.filter(Condition::all().add(node::Column::DeletedAt.is_null()))
.limit(1)
.project_all(tx, |query| {
node_ident_columns(query).into_model::<NodeIdent>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next();
settled
.map(|row| row.id)
.ok_or_else(|| GraphStoreError::Conflict {
reason: format!(
"node key `{key}` was created and tombstoned while this batch was \
materializing it as an edge endpoint; it cannot be re-ingested \
before purge"
),
})
}
Err(error) => Err(map_scope_err(error)),
}
}
async fn upsert_edge(
w: Writer<'_>,
tx: &impl DBRunner,
spec: &EdgeSpec,
info: &TypeInfo,
src: i64,
dst: i64,
declaring: Option<(&str, &str)>,
) -> Result<ItemOutcome, GraphStoreError> {
let (tenant, scope, subject) = (w.tenant, w.scope, w.subject);
let edge_key = identity::derive_edge_key(info.uuid, spec);
let now = OffsetDateTime::now_utc();
let payload = spec
.payload
.clone()
.unwrap_or_else(|| serde_json::json!({}));
let existing = edge::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(edge::Column::EdgeKey.eq(edge_key.clone())))
.one(tx)
.await
.map_err(map_scope_err)?;
let Some(current) = existing else {
let active = edge::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
id: ActiveValue::NotSet,
edge_key: ActiveValue::Set(edge_key),
gts_edge_type_id: ActiveValue::Set(info.id),
src_node_id: ActiveValue::Set(src),
dst_node_id: ActiveValue::Set(dst),
discriminator: ActiveValue::Set(spec.discriminator.clone()),
payload: ActiveValue::Set(payload),
created_at: ActiveValue::Set(now),
updated_at: ActiveValue::Set(now),
deleted_at: ActiveValue::Set(None),
scope_attribute: ActiveValue::Set(declaring.map(|(attribute, _)| attribute.to_owned())),
scope_value: ActiveValue::Set(declaring.map(|(_, value)| value.to_owned())),
created_by_subject_id: ActiveValue::Set(subject.subject_id),
created_by_subject_type: ActiveValue::Set(subject.subject_type.clone()),
updated_by_subject_id: ActiveValue::Set(subject.subject_id),
updated_by_subject_type: ActiveValue::Set(subject.subject_type.clone()),
deleted_by_subject_id: ActiveValue::Set(None),
deleted_by_subject_type: ActiveValue::Set(None),
};
edge::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.exec(tx)
.await
.map_err(map_scope_err)?;
return Ok(ItemOutcome::Inserted);
};
let owned_by = current
.scope_attribute
.as_deref()
.zip(current.scope_value.as_deref());
if let (Some((attribute, value)), Some(owner)) = (declaring, owned_by)
&& owner != (attribute, value)
{
return Err(GraphStoreError::Conflict {
reason: format!(
"edge `{edge_key}` was declared by scope `{}={}` and may not be \
re-declared under `{attribute}={value}`; a move between scopes \
is a deletion and a re-declaration, not a write",
owner.0, owner.1
),
});
}
let claim = declaring.filter(|(attribute, value)| {
current.scope_attribute.as_deref() != Some(*attribute)
|| current.scope_value.as_deref() != Some(*value)
});
let ownership_as_read = ownership_as_read(¤t);
if current.payload == payload && current.deleted_at.is_none() {
if let Some((attribute, value)) = claim {
let claimed = edge::Entity::update_many()
.col_expr(
edge::Column::ScopeAttribute,
Expr::value(Some(attribute.to_owned())),
)
.col_expr(
edge::Column::ScopeValue,
Expr::value(Some(value.to_owned())),
)
.filter(Condition::all().add(edge::Column::Id.eq(current.id)))
.filter(ownership_as_read)
.secure()
.scope_with(scope)
.exec(tx)
.await
.map_err(map_scope_err)?;
if claimed.rows_affected == 0 {
return Err(lost_write(scope, tx, current.id, &edge_key, declaring).await);
}
}
return Ok(ItemOutcome::Unchanged);
}
let id = current.id;
let mut update = edge::Entity::update_many();
if let Some((attribute, value)) = claim {
update = update
.col_expr(
edge::Column::ScopeAttribute,
Expr::value(Some(attribute.to_owned())),
)
.col_expr(
edge::Column::ScopeValue,
Expr::value(Some(value.to_owned())),
);
}
if declaring.is_some() {
update = update.filter(ownership_as_read);
}
let written = update
.col_expr(edge::Column::Payload, Expr::value(payload))
.col_expr(edge::Column::UpdatedAt, Expr::value(now))
.col_expr(
edge::Column::UpdatedBySubjectId,
Expr::value(subject.subject_id),
)
.col_expr(
edge::Column::UpdatedBySubjectType,
Expr::value(subject.subject_type.clone()),
)
.col_expr(
edge::Column::DeletedAt,
Expr::value(Option::<OffsetDateTime>::None),
)
.col_expr(
edge::Column::DeletedBySubjectId,
Expr::value(Option::<Uuid>::None),
)
.col_expr(
edge::Column::DeletedBySubjectType,
Expr::value(Option::<String>::None),
)
.filter(Condition::all().add(edge::Column::Id.eq(id)))
.secure()
.scope_with(scope)
.exec(tx)
.await
.map_err(map_scope_err)?;
if written.rows_affected == 0 {
return Err(lost_write(scope, tx, id, &edge_key, declaring).await);
}
Ok(ItemOutcome::Updated)
}
fn ownership_as_read(current: &edge::Model) -> Condition {
Condition::all()
.add(match ¤t.scope_attribute {
Some(attribute) => edge::Column::ScopeAttribute.eq(attribute.clone()),
None => edge::Column::ScopeAttribute.is_null(),
})
.add(match ¤t.scope_value {
Some(value) => edge::Column::ScopeValue.eq(value.clone()),
None => edge::Column::ScopeValue.is_null(),
})
}
async fn lost_write(
scope: &AccessScope,
tx: &impl DBRunner,
id: i64,
edge_key: &str,
declaring: Option<(&str, &str)>,
) -> GraphStoreError {
let row = match edge::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(edge::Column::Id.eq(id)))
.one(tx)
.await
{
Ok(row) => row,
Err(error) => return map_scope_err(error),
};
let owner = row.and_then(|row| row.scope_attribute.zip(row.scope_value));
lost_write_verdict(
edge_key,
owner
.as_ref()
.map(|(attribute, value)| (attribute.as_str(), value.as_str())),
declaring,
)
}
fn lost_write_verdict(
edge_key: &str,
owner: Option<(&str, &str)>,
declaring: Option<(&str, &str)>,
) -> GraphStoreError {
match (owner, declaring) {
(Some((attribute, value)), Some((declaring_attribute, declaring_value)))
if (attribute, value) != (declaring_attribute, declaring_value) =>
{
GraphStoreError::Conflict {
reason: format!(
"edge `{edge_key}` was claimed by scope `{attribute}={value}` while this \
write under `{declaring_attribute}={declaring_value}` was being \
prepared; a move between scopes is a deletion and a re-declaration, \
not a write"
),
}
}
_ => GraphStoreError::Conflict {
reason: format!(
"edge `{edge_key}` was removed while this write was being prepared; \
re-ingest it"
),
},
}
}
pub async fn soft_delete(
store: &PgGraphStore,
ctx: &StoreCtx<'_>,
request: DeleteRequest,
) -> Result<DeleteOutcome, GraphStoreError> {
let tenant = ctx.tenant;
let scope = ctx.scope.clone();
let subject = ctx.subject.clone();
store
.db()
.transaction_ref_mapped::<_, DeleteOutcome, TxStoreError>(move |tx| {
let request = request.clone();
let scope = scope.clone();
let subject = subject.clone();
Box::pin(async move {
let epoch = source_epoch(&scope, tx).await?;
let now = OffsetDateTime::now_utc();
let (nodes, edges) = match request {
DeleteRequest::Node(key) => {
let live = node::Entity::find()
.secure()
.scope_with(&scope)
.filter(Condition::all().add(node::Column::NodeKey.eq(key.clone())))
.filter(Condition::all().add(node::Column::DeletedAt.is_null()))
.limit(1)
.project_all(tx, |query| {
node_ident_columns(query).into_model::<NodeIdent>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next();
let Some(model) = live else {
return already_tombstoned_node(&scope, tx, &key, epoch).await;
};
let removed = node::Entity::update_many()
.col_expr(node::Column::DeletedAt, Expr::value(Some(now)))
.col_expr(
node::Column::DeletedBySubjectId,
Expr::value(Some(subject.subject_id)),
)
.col_expr(
node::Column::DeletedBySubjectType,
Expr::value(subject.subject_type.clone()),
)
.filter(
Condition::all()
.add(node::Column::Id.eq(model.id))
.add(node::Column::DeletedAt.is_null()),
)
.secure()
.scope_with(&scope)
.exec(tx)
.await
.map_err(map_scope_err)?
.rows_affected;
if removed == 0 {
return already_tombstoned_node(&scope, tx, &key, epoch).await;
}
let incident = edge::Entity::find()
.secure()
.scope_with(&scope)
.filter(
sea_orm::Condition::any()
.add(edge::Column::SrcNodeId.eq(model.id))
.add(edge::Column::DstNodeId.eq(model.id)),
)
.filter(Condition::all().add(edge::Column::DeletedAt.is_null()))
.project_all(tx, |query| {
edge_ends_columns(query).into_model::<EdgeEnds>()
})
.await
.map_err(map_scope_err)?;
let mut edges = 0u64;
for e in incident {
edges += edge::Entity::update_many()
.col_expr(edge::Column::DeletedAt, Expr::value(Some(now)))
.col_expr(
edge::Column::DeletedBySubjectId,
Expr::value(Some(subject.subject_id)),
)
.col_expr(
edge::Column::DeletedBySubjectType,
Expr::value(subject.subject_type.clone()),
)
.filter(
Condition::all()
.add(edge::Column::Id.eq(e.id))
.add(edge::Column::DeletedAt.is_null()),
)
.secure()
.scope_with(&scope)
.exec(tx)
.await
.map_err(map_scope_err)?
.rows_affected;
}
(removed, edges)
}
DeleteRequest::Edge(key) => {
let live = edge::Entity::find()
.secure()
.scope_with(&scope)
.filter(Condition::all().add(edge::Column::EdgeKey.eq(key.clone())))
.filter(Condition::all().add(edge::Column::DeletedAt.is_null()))
.limit(1)
.project_all(tx, |query| {
edge_ends_columns(query).into_model::<EdgeEnds>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next();
let Some(model) = live else {
return already_tombstoned_edge(&scope, tx, &key, epoch).await;
};
let mut endpoints = vec![model.src_node_id, model.dst_node_id];
endpoints.dedup();
let visible = node::Entity::find()
.secure()
.scope_with(&scope)
.filter(Condition::all().add(node::Column::Id.is_in(endpoints.clone())))
.filter(Condition::all().add(node::Column::DeletedAt.is_null()))
.project_all(tx, |query| {
node_ident_columns(query).into_model::<NodeIdent>()
})
.await
.map_err(map_scope_err)?;
if visible.len() != endpoints.len() {
return Err(GraphStoreError::NotFound.into());
}
let removed = edge::Entity::update_many()
.col_expr(edge::Column::DeletedAt, Expr::value(Some(now)))
.col_expr(
edge::Column::DeletedBySubjectId,
Expr::value(Some(subject.subject_id)),
)
.col_expr(
edge::Column::DeletedBySubjectType,
Expr::value(subject.subject_type.clone()),
)
.filter(
Condition::all()
.add(edge::Column::Id.eq(model.id))
.add(edge::Column::DeletedAt.is_null()),
)
.secure()
.scope_with(&scope)
.exec(tx)
.await
.map_err(map_scope_err)?
.rows_affected;
if removed == 0 {
return already_tombstoned_edge(&scope, tx, &key, epoch).await;
}
(0u64, removed)
}
};
let revision = bump_revision(tenant, &scope, tx).await?;
Ok(DeleteOutcome {
revision: GraphRevision {
source_epoch: epoch,
revision,
},
tombstoned_nodes: nodes,
tombstoned_edges: edges,
})
})
})
.await
.map_err(|error| error.0)
}
async fn already_tombstoned_node(
scope: &AccessScope,
tx: &impl DBRunner,
key: &str,
epoch: i64,
) -> Result<DeleteOutcome, TxStoreError> {
let found = node::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(node::Column::NodeKey.eq(key.to_owned())))
.limit(1)
.project_all(tx, |query| {
node_state_columns(query).into_model::<NodeState>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next()
.map(|row| row.deleted_at.is_some());
settle_no_op(scope, tx, found, "node", key, epoch).await
}
async fn already_tombstoned_edge(
scope: &AccessScope,
tx: &impl DBRunner,
key: &str,
epoch: i64,
) -> Result<DeleteOutcome, TxStoreError> {
let found = edge::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(edge::Column::EdgeKey.eq(key.to_owned())))
.limit(1)
.project_all(tx, |query| {
edge_state_columns(query).into_model::<EdgeState>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.next()
.map(|row| row.deleted_at.is_some());
settle_no_op(scope, tx, found, "edge", key, epoch).await
}
async fn settle_no_op(
scope: &AccessScope,
tx: &impl DBRunner,
tombstoned: Option<bool>,
what: &str,
key: &str,
epoch: i64,
) -> Result<DeleteOutcome, TxStoreError> {
no_op_verdict(tombstoned, what, key)?;
let revision = current_revision(scope, tx).await?;
Ok(DeleteOutcome {
revision: GraphRevision {
source_epoch: epoch,
revision,
},
tombstoned_nodes: 0,
tombstoned_edges: 0,
})
}
fn no_op_verdict(tombstoned: Option<bool>, what: &str, key: &str) -> Result<(), GraphStoreError> {
match tombstoned {
None => Err(GraphStoreError::NotFound),
Some(false) => Err(GraphStoreError::Conflict {
reason: format!(
"the {what} `{key}` this delete read was removed and the key is now held by \
another, live {what}; retry the delete to remove that one"
),
}),
Some(true) => Ok(()),
}
}
pub async fn ensure_meta(
store: &PgGraphStore,
tenant: Uuid,
scope: &AccessScope,
) -> Result<(), GraphStoreError> {
let conn = store.db().conn().map_err(|error| map_db_error(&error))?;
for (key, value) in [
(graph_meta::KEY_GRAPH_REVISION, serde_json::json!(0)),
(graph_meta::KEY_SOURCE_EPOCH, serde_json::json!(1)),
] {
let active = graph_meta::ActiveModel {
tenant_id: ActiveValue::Set(tenant),
key: ActiveValue::Set(key.to_owned()),
value: ActiveValue::Set(value),
};
let on_conflict = toolkit_db::secure::SecureOnConflict::<graph_meta::Entity>::columns([
graph_meta::Column::TenantId,
graph_meta::Column::Key,
])
.build();
let mut on_conflict = on_conflict;
on_conflict.do_nothing();
graph_meta::Entity::insert(active)
.secure()
.scope_unchecked(scope)
.map_err(map_scope_err)?
.on_conflict_raw(on_conflict)
.exec(&conn)
.await
.map_err(map_scope_err)?;
}
Ok(())
}
#[must_use]
pub const fn migrated_embedding_dimension() -> u32 {
crate::infra::storage::migrations::m0001_initial_schema::EMBEDDING_DIMENSION
}
async fn write_nodes(
w: Writer<'_>,
tx: &impl DBRunner,
request: &IngestRequest,
types: &BTreeMap<String, TypeInfo>,
node_ids: &mut BTreeMap<String, Endpoint>,
tally: &mut IngestTally,
embedding: &EmbeddingPlan,
) -> Result<bool, GraphStoreError> {
let mut changed = false;
for (index, spec) in request.nodes.iter().enumerate() {
if w.budget.is_exhausted() {
return Err(GraphStoreError::Deadline);
}
let info = types.get(&spec.type_id).ok_or_else(|| {
item_error(
index,
ItemFamily::Node,
&spec.type_id,
"type is not registered".into(),
)
})?;
let decided = embedding.nodes.get(index).ok_or_else(|| {
GraphStoreError::Internal(format!(
"embedding plan covers {} nodes; the batch has {}",
embedding.nodes.len(),
request.nodes.len()
))
})?;
let (id, write) = upsert_node(
w,
tx,
spec,
info,
index,
PlannedVector {
decided,
active_epoch: embedding.epoch,
},
)
.await?;
node_ids.insert(
spec.node_key.clone(),
Endpoint {
id,
type_id: info.id,
},
);
changed |= tally.node(&write);
}
Ok(changed)
}
#[expect(
clippy::too_many_arguments,
reason = "one resolution step over the transaction's whole working state; bundling it would name a struct for a single call site"
)]
async fn resolve_endpoint(
w: Writer<'_>,
tx: &impl DBRunner,
key: &str,
index: usize,
type_id: &str,
types: &BTreeMap<String, TypeInfo>,
node_ids: &mut BTreeMap<String, Endpoint>,
create_phantoms: bool,
tally: &mut IngestTally,
) -> Result<bool, GraphStoreError> {
if node_ids.contains_key(key) {
return Ok(false);
}
if let Some(endpoint) = lookup_endpoint(w.scope, tx, key).await? {
node_ids.insert(key.to_owned(), endpoint);
return Ok(false);
}
if endpoint_is_tombstoned(w.scope, tx, key).await? {
return Err(GraphStoreError::Conflict {
reason: format!(
"edge[{index}] names endpoint `{key}`, which is tombstoned; the key cannot be \
linked to or re-ingested before purge"
),
});
}
if !create_phantoms {
return Err(item_error(
index,
ItemFamily::Edge,
type_id,
format!("endpoint `{key}` does not exist and phantom creation is disabled"),
));
}
let phantom_type = types
.values()
.find(|t| t.family.as_deref() == Some("phantom"))
.ok_or_else(|| {
item_error(
index,
ItemFamily::Edge,
type_id,
format!("endpoint `{key}` does not exist and no phantom node type is registered"),
)
})?;
let id = insert_phantom(w, tx, key, phantom_type).await?;
node_ids.insert(
key.to_owned(),
Endpoint {
id,
type_id: phantom_type.id,
},
);
tally.phantom_created();
Ok(true)
}
async fn write_edges(
w: Writer<'_>,
tx: &impl DBRunner,
request: &IngestRequest,
types: &BTreeMap<String, TypeInfo>,
node_ids: &mut BTreeMap<String, Endpoint>,
tally: &mut IngestTally,
) -> Result<bool, GraphStoreError> {
let create_phantoms = request.options.create_phantoms.unwrap_or(true);
let mut changed = false;
for (index, spec) in request.edges.iter().enumerate() {
if w.budget.is_exhausted() {
return Err(GraphStoreError::Deadline);
}
let info = types.get(&spec.type_id).ok_or_else(|| {
item_error(
index,
ItemFamily::Edge,
&spec.type_id,
"type is not registered".into(),
)
})?;
for key in [&spec.src_node_key, &spec.dst_node_key] {
changed |= resolve_endpoint(
w,
tx,
key,
index,
&spec.type_id,
types,
node_ids,
create_phantoms,
tally,
)
.await?;
}
let src = node_ids[&spec.src_node_key];
let dst = node_ids[&spec.dst_node_key];
let resolved = endpoint_types(w.scope, tx, &[src.type_id, dst.type_id]).await?;
for (end, endpoint, patterns, pointer) in [
(&spec.src_node_key, src, &info.src_types, "/src_node_key"),
(&spec.dst_node_key, dst, &info.dst_types, "/dst_node_key"),
] {
let Some((endpoint_type, family)) = resolved.get(&endpoint.type_id) else {
continue;
};
if !endpoint_admitted(endpoint_type, family.as_deref(), patterns)? {
return Err(GraphStoreError::Validation {
items: vec![ItemError {
index,
family: ItemFamily::Edge,
gts_type: Some(spec.type_id.clone()),
pointer: Some(pointer.to_owned()),
message: format!(
"endpoint `{end}` is a `{endpoint_type}`, which `{}` does not admit; \
this edge type accepts {}",
spec.type_id,
patterns.join(", ")
),
}],
});
}
}
changed |= tally.edge(
&upsert_edge(
w,
tx,
spec,
info,
src.id,
dst.id,
request
.replace_scope
.as_ref()
.map(|replace| (replace.attribute.as_str(), replace.value.as_str())),
)
.await?,
);
}
Ok(changed)
}
async fn replay_receipt(
scope: &AccessScope,
producer: &str,
tx: &impl DBRunner,
key: &str,
request_hash: &str,
epoch: i64,
) -> Result<Option<IngestOutcome>, GraphStoreError> {
let existing = ingest_idempotency::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(ingest_idempotency::Column::Producer.eq(producer.to_owned())))
.filter(Condition::all().add(ingest_idempotency::Column::IdempotencyKey.eq(key.to_owned())))
.one(tx)
.await
.map_err(map_scope_err)?;
let Some(receipt) = existing else {
return Ok(None);
};
if receipt.request_hash != request_hash {
return Err(GraphStoreError::IdempotencyMismatch);
}
if receipt.source_epoch != epoch {
return Err(GraphStoreError::IdempotencyExpired);
}
let mut outcome = outcome_from_json(&receipt.response)?;
outcome.replayed = true;
Ok(Some(outcome))
}
#[cfg(test)]
mod lost_write_verdict_tests {
use super::{GraphStoreError, lost_write_verdict};
fn reason(error: GraphStoreError) -> String {
match error {
GraphStoreError::Conflict { reason } => reason,
other => panic!("a lost write is a conflict, got {other:?}"),
}
}
#[test]
fn a_row_now_owned_by_another_scope_is_a_lost_claim() {
let text = reason(lost_write_verdict(
"e",
Some(("repository", "acme/infra")),
Some(("component", "auth")),
));
assert!(
text.contains("claimed by scope `repository=acme/infra`"),
"{text}"
);
assert!(text.contains("under `component=auth`"), "{text}");
}
#[test]
fn a_missing_row_is_a_removed_row() {
for declaring in [None, Some(("repository", "acme/infra"))] {
let text = reason(lost_write_verdict("e", None, declaring));
assert!(text.contains("was removed"), "{text}");
assert!(text.contains("re-ingest it"), "{text}");
}
}
#[test]
fn a_row_owned_by_the_declaring_scope_is_not_a_lost_claim() {
let text = reason(lost_write_verdict(
"e",
Some(("repository", "acme/infra")),
Some(("repository", "acme/infra")),
));
assert!(text.contains("was removed"), "{text}");
}
}
#[cfg(test)]
mod no_op_verdict_tests {
use graph_storage_sdk::plugin_api::GraphStoreError;
use super::no_op_verdict;
#[test]
fn a_tombstoned_row_settles_as_a_no_op() {
assert!(no_op_verdict(Some(true), "node", "k").is_ok());
}
#[test]
fn no_row_is_not_found() {
assert!(matches!(
no_op_verdict(None, "node", "k"),
Err(GraphStoreError::NotFound)
));
}
#[test]
fn a_live_row_under_the_key_is_a_conflict_not_a_settled_delete() {
for what in ["node", "edge"] {
let verdict = no_op_verdict(Some(false), what, "k");
assert!(
matches!(&verdict, Err(GraphStoreError::Conflict { reason }) if reason.contains(what)),
"a live {what} under the key must be a conflict, got {verdict:?}"
);
}
}
}