use graph_storage_sdk::models::{ItemError, ItemFamily, TypeKind};
use graph_storage_sdk::plugin_api::GraphStoreError;
use sea_orm::sea_query::Expr;
use sea_orm::{ColumnTrait, Condition, EntityTrait, ExprTrait, QueryFilter};
use std::collections::BTreeMap;
use toolkit_db::secure::{DBRunner, SecureEntityExt, SecureUpdateExt};
use crate::domain::ontology::ChainValidator;
use crate::infra::projections::{NodeIdent, node_ident_columns};
use crate::infra::storage::entity::{edge, node};
use crate::infra::store::map_scope_err;
#[derive(Clone, Copy)]
pub(crate) struct ScanBounds {
pub batch: u64,
pub max_reported: usize,
pub budget: graph_storage_sdk::models::RemainingBudget,
}
fn still_within(bounds: ScanBounds) -> Result<(), GraphStoreError> {
if bounds.budget.is_exhausted() {
return Err(GraphStoreError::Deadline);
}
Ok(())
}
async fn endpoint_keys(
scope: &toolkit_security::AccessScope,
tx: &impl DBRunner,
rows: &[edge::Model],
) -> Result<BTreeMap<i64, String>, GraphStoreError> {
let mut ids: Vec<i64> = rows
.iter()
.flat_map(|row| [row.src_node_id, row.dst_node_id])
.collect();
ids.sort_unstable();
ids.dedup();
Ok(node::Entity::find()
.secure()
.scope_with(scope)
.filter(Condition::all().add(node::Column::Id.is_in(ids)))
.project_all(tx, |query| {
node_ident_columns(query).into_model::<NodeIdent>()
})
.await
.map_err(map_scope_err)?
.into_iter()
.map(|model| (model.id, model.node_key))
.collect())
}
fn edge_instance(
model: &edge::Model,
keys: &BTreeMap<i64, String>,
type_id: &str,
) -> serde_json::Value {
let mut instance = serde_json::json!({
"type": type_id,
"src_node_key": keys.get(&model.src_node_id).cloned().unwrap_or_default(),
"dst_node_key": keys.get(&model.dst_node_id).cloned().unwrap_or_default(),
});
if let Some(discriminator) = &model.discriminator {
instance["discriminator"] = serde_json::Value::String(discriminator.clone());
}
instance["payload"] = model.payload.clone();
instance
}
pub(crate) async fn count_live(
scope: &toolkit_security::AccessScope,
tx: &impl DBRunner,
kind: TypeKind,
interned: i32,
) -> Result<u64, GraphStoreError> {
match kind {
TypeKind::Node => node::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(node::Column::GtsNodeTypeId.eq(interned))
.add(node::Column::DeletedAt.is_null()),
)
.count(tx)
.await
.map_err(map_scope_err),
TypeKind::Edge => edge::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(edge::Column::GtsEdgeTypeId.eq(interned))
.add(edge::Column::DeletedAt.is_null()),
)
.count(tx)
.await
.map_err(map_scope_err),
TypeKind::Attribute => Err(GraphStoreError::Unsupported {
what: "re-validating an attribute type; it has no rows",
}),
}
}
pub(crate) async fn revalidate(
scope: &toolkit_security::AccessScope,
tx: &impl DBRunner,
type_id: &str,
kind: TypeKind,
interned: i32,
validator: &ChainValidator,
bounds: ScanBounds,
) -> Result<Vec<ItemError>, GraphStoreError> {
match kind {
TypeKind::Node => revalidate_nodes(scope, tx, type_id, interned, validator, bounds).await,
TypeKind::Edge => revalidate_edges(scope, tx, type_id, interned, validator, bounds).await,
TypeKind::Attribute => Err(GraphStoreError::Unsupported {
what: "re-validating an attribute type; it has no rows",
}),
}
}
fn node_instance(model: &node::Model) -> serde_json::Value {
let mut instance = serde_json::json!({
"node_key": model.node_key,
"type": serde_json::Value::Null,
});
if !model.name.is_empty() {
instance["name"] = serde_json::Value::String(model.name.clone());
}
instance["payload"] = model.payload.clone();
instance
}
async fn revalidate_nodes(
scope: &toolkit_security::AccessScope,
tx: &impl DBRunner,
type_id: &str,
interned: i32,
validator: &ChainValidator,
bounds: ScanBounds,
) -> Result<Vec<ItemError>, GraphStoreError> {
let mut errors: Vec<ItemError> = Vec::new();
let mut after: i64 = i64::MIN;
let mut index = 0usize;
loop {
still_within(bounds)?;
let rows = node::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(node::Column::GtsNodeTypeId.eq(interned))
.add(node::Column::DeletedAt.is_null())
.add(node::Column::Id.gt(after)),
)
.order_by(node::Column::Id, sea_orm::Order::Asc)
.limit(bounds.batch)
.all(tx)
.await
.map_err(map_scope_err)?;
if rows.is_empty() {
return Ok(errors);
}
for model in &rows {
after = model.id;
let mut instance = node_instance(model);
instance["type"] = serde_json::Value::String(type_id.to_owned());
for (pointer, message) in validator.validate(&instance) {
if errors.len() < bounds.max_reported {
errors.push(ItemError {
index,
family: ItemFamily::Node,
gts_type: Some(type_id.to_owned()),
pointer: Some(pointer),
message: format!("node `{}`: {message}", model.node_key),
});
}
}
index += 1;
}
}
}
async fn revalidate_edges(
scope: &toolkit_security::AccessScope,
tx: &impl DBRunner,
type_id: &str,
interned: i32,
validator: &ChainValidator,
bounds: ScanBounds,
) -> Result<Vec<ItemError>, GraphStoreError> {
let mut errors: Vec<ItemError> = Vec::new();
let mut after: i64 = i64::MIN;
let mut index = 0usize;
loop {
still_within(bounds)?;
let rows = edge::Entity::find()
.secure()
.scope_with(scope)
.filter(
Condition::all()
.add(edge::Column::GtsEdgeTypeId.eq(interned))
.add(edge::Column::DeletedAt.is_null())
.add(edge::Column::Id.gt(after)),
)
.order_by(edge::Column::Id, sea_orm::Order::Asc)
.limit(bounds.batch)
.all(tx)
.await
.map_err(map_scope_err)?;
if rows.is_empty() {
return Ok(errors);
}
let keys = endpoint_keys(scope, tx, &rows).await?;
for model in &rows {
after = model.id;
let instance = edge_instance(model, &keys, type_id);
for (pointer, message) in validator.validate(&instance) {
if errors.len() < bounds.max_reported {
errors.push(ItemError {
index,
family: ItemFamily::Edge,
gts_type: Some(type_id.to_owned()),
pointer: Some(pointer),
message: format!("edge `{}`: {message}", model.edge_key),
});
}
}
index += 1;
}
}
}
pub(crate) struct MigrationOutcome {
pub rows_scanned: u64,
pub rows_rewritten: u64,
pub failures: Vec<ItemError>,
}
pub(crate) async fn migrate(
who: Migrator<'_>,
tx: &impl DBRunner,
what: Migrating<'_>,
bounds: ScanBounds,
) -> Result<MigrationOutcome, GraphStoreError> {
match what.kind {
TypeKind::Node => migrate_nodes(who, tx, what, bounds).await,
TypeKind::Edge => migrate_edges(who, tx, what, bounds).await,
TypeKind::Attribute => Err(GraphStoreError::Unsupported {
what: "migrating an attribute type; it has no rows",
}),
}
}
#[derive(Clone, Copy)]
pub(crate) struct Migrator<'a> {
pub scope: &'a toolkit_security::AccessScope,
pub subject: &'a graph_storage_sdk::models::Subject,
pub dry_run: bool,
}
#[derive(Clone, Copy)]
pub(crate) struct Migrating<'a> {
pub type_id: &'a str,
pub kind: TypeKind,
pub interned: i32,
pub plan: &'a crate::domain::migration::Plan,
pub validator: &'a ChainValidator,
pub full_text_search: &'a [String],
pub vectorized: bool,
}
async fn migrate_nodes(
who: Migrator<'_>,
tx: &impl DBRunner,
what: Migrating<'_>,
bounds: ScanBounds,
) -> Result<MigrationOutcome, GraphStoreError> {
let mut out = MigrationOutcome {
rows_scanned: 0,
rows_rewritten: 0,
failures: Vec::new(),
};
let mut after: i64 = i64::MIN;
loop {
still_within(bounds)?;
let rows = node::Entity::find()
.secure()
.scope_with(who.scope)
.filter(
Condition::all()
.add(node::Column::GtsNodeTypeId.eq(what.interned))
.add(node::Column::DeletedAt.is_null())
.add(node::Column::Id.gt(after)),
)
.order_by(node::Column::Id, sea_orm::Order::Asc)
.limit(bounds.batch)
.all(tx)
.await
.map_err(map_scope_err)?;
if rows.is_empty() {
return Ok(out);
}
for model in &rows {
after = model.id;
out.rows_scanned += 1;
let mut payload = model.payload.clone();
let changed = what.plan.apply(&mut payload);
let mut instance = node_instance(model);
instance["type"] = serde_json::Value::String(what.type_id.to_owned());
instance["payload"] = payload.clone();
let violations = what.validator.validate(&instance);
if !violations.is_empty() {
for (pointer, message) in violations {
if out.failures.len() < bounds.max_reported {
out.failures.push(ItemError {
index: usize::try_from(out.rows_scanned - 1).unwrap_or(usize::MAX),
family: ItemFamily::Node,
gts_type: Some(what.type_id.to_owned()),
pointer: Some(pointer),
message: format!(
"node `{}` does not satisfy the candidate after the migration: \
{message}",
model.node_key
),
});
}
}
continue;
}
if !changed {
continue;
}
out.rows_rewritten += 1;
if who.dry_run {
continue;
}
let search_text = crate::infra::store::ingest::compose_search_text(
Some(model.name.as_str()),
Some(&payload),
what.full_text_search,
);
let mut update = node::Entity::update_many()
.col_expr(node::Column::Payload, Expr::value(payload))
.col_expr(node::Column::SearchText, Expr::value(search_text))
.col_expr(
node::Column::Version,
Expr::col(node::Column::Version).add(1),
)
.col_expr(
node::Column::UpdatedAt,
Expr::value(time::OffsetDateTime::now_utc()),
)
.col_expr(
node::Column::UpdatedBySubjectId,
Expr::value(who.subject.subject_id),
)
.col_expr(
node::Column::UpdatedBySubjectType,
Expr::value(who.subject.subject_type.clone()),
);
if what.vectorized {
update = update.col_expr(
node::Column::EmbeddingEpoch,
Expr::value(Option::<i64>::None),
);
}
let written = update
.filter(
Condition::all()
.add(node::Column::Id.eq(model.id))
.add(node::Column::Version.eq(model.version)),
)
.secure()
.scope_with(who.scope)
.exec(tx)
.await
.map_err(map_scope_err)?;
if written.rows_affected == 0 {
return Err(GraphStoreError::Conflict {
reason: format!(
"node `{}` was written while this migration was reading it; \
re-run the migration",
model.node_key
),
});
}
}
}
}
async fn migrate_edges(
who: Migrator<'_>,
tx: &impl DBRunner,
what: Migrating<'_>,
bounds: ScanBounds,
) -> Result<MigrationOutcome, GraphStoreError> {
let mut out = MigrationOutcome {
rows_scanned: 0,
rows_rewritten: 0,
failures: Vec::new(),
};
let mut after: i64 = i64::MIN;
loop {
still_within(bounds)?;
let rows = edge::Entity::find()
.secure()
.scope_with(who.scope)
.filter(
Condition::all()
.add(edge::Column::GtsEdgeTypeId.eq(what.interned))
.add(edge::Column::DeletedAt.is_null())
.add(edge::Column::Id.gt(after)),
)
.order_by(edge::Column::Id, sea_orm::Order::Asc)
.limit(bounds.batch)
.all(tx)
.await
.map_err(map_scope_err)?;
if rows.is_empty() {
return Ok(out);
}
let keys = endpoint_keys(who.scope, tx, &rows).await?;
for model in &rows {
after = model.id;
out.rows_scanned += 1;
let mut payload = model.payload.clone();
let changed = what.plan.apply(&mut payload);
let mut instance = edge_instance(model, &keys, what.type_id);
instance["payload"] = payload.clone();
let violations = what.validator.validate(&instance);
if !violations.is_empty() {
for (pointer, message) in violations {
if out.failures.len() < bounds.max_reported {
out.failures.push(ItemError {
index: usize::try_from(out.rows_scanned - 1).unwrap_or(usize::MAX),
family: ItemFamily::Edge,
gts_type: Some(what.type_id.to_owned()),
pointer: Some(pointer),
message: format!(
"edge `{}` does not satisfy the candidate after the migration: \
{message}",
model.edge_key
),
});
}
}
continue;
}
if !changed {
continue;
}
out.rows_rewritten += 1;
if who.dry_run {
continue;
}
let written = edge::Entity::update_many()
.col_expr(edge::Column::Payload, Expr::value(payload))
.col_expr(
edge::Column::UpdatedAt,
Expr::value(time::OffsetDateTime::now_utc()),
)
.col_expr(
edge::Column::UpdatedBySubjectId,
Expr::value(who.subject.subject_id),
)
.col_expr(
edge::Column::UpdatedBySubjectType,
Expr::value(who.subject.subject_type.clone()),
)
.filter(
Condition::all()
.add(edge::Column::Id.eq(model.id))
.add(edge::Column::Payload.eq(model.payload.clone())),
)
.secure()
.scope_with(who.scope)
.exec(tx)
.await
.map_err(map_scope_err)?;
if written.rows_affected == 0 {
return Err(GraphStoreError::Conflict {
reason: format!(
"edge `{}` was written while this migration was reading it; \
re-run the migration",
model.edge_key
),
});
}
}
}
}