kcode-kennedy-kweb-loader 0.1.0

Atomic Kweb context loading and legacy node decoding for Kennedy
Documentation
//! Atomic durable Kweb loading for Kennedy context projections.

#![forbid(unsafe_code)]

use anyhow::Context as _;
use kcode_kweb_context::{Connection, Context, LoadReport, Node};
use kcode_kweb_db::{Node as KwebNode, NodeId, Owner};
use kcode_kweb_manager::KwebManager;
use serde_json::{Value, json};

/// Reads and projects every requested durable node, committing the context
/// update only after the complete batch succeeds.
pub fn load_durable_batch(
    manager: &KwebManager,
    context: &mut Context,
    durable_ids: &[String],
) -> anyhow::Result<Value> {
    load_batch_with(context, durable_ids, |preview, durable_id| {
        load_one(manager, preview, durable_id)
    })
}

/// Decodes the legacy JSON representation of a projected Kweb context node.
pub fn node_from_value(value: &Value) -> anyhow::Result<Node> {
    Node::from_kweb_value(value).map_err(anyhow::Error::new)
}

fn load_batch_with(
    context: &mut Context,
    durable_ids: &[String],
    mut load: impl FnMut(&mut Context, &str) -> anyhow::Result<LoadReport>,
) -> anyhow::Result<Value> {
    let mut preview = context.clone();
    let mut reports = Vec::with_capacity(durable_ids.len());
    for durable_id in durable_ids {
        reports.push(load(&mut preview, durable_id)?);
    }
    *context = preview;
    Ok(json!({"loads": reports}))
}

fn load_one(
    manager: &KwebManager,
    context: &mut Context,
    durable_id: &str,
) -> anyhow::Result<LoadReport> {
    let requested = read_node(manager, durable_id)?;
    anyhow::ensure!(
        requested.id.to_string() == durable_id,
        "Kweb returned node {} when {durable_id} was requested",
        requested.id
    );
    let fixed_ids = requested.data.fixed_connections.clone();
    let requested = node_from_kweb(manager, requested)?;
    let fixed = fixed_ids
        .into_iter()
        .filter(|id| id.to_string() != durable_id)
        .map(|id| read_node(manager, &id.to_string()))
        .map(|node| node.and_then(|node| node_from_kweb(manager, node)))
        .collect::<anyhow::Result<Vec<_>>>()?;
    context
        .apply_load(requested, fixed)
        .map_err(anyhow::Error::new)
}

fn read_node(manager: &KwebManager, durable_id: &str) -> anyhow::Result<KwebNode> {
    let node_id = durable_id
        .parse::<NodeId>()
        .with_context(|| format!("{durable_id:?} is not a canonical node ID"))?;
    manager.get_node(node_id).map_err(anyhow::Error::new)
}

fn node_from_kweb(manager: &KwebManager, node: KwebNode) -> anyhow::Result<Node> {
    let fixed_connections = connections(manager, &node.data.fixed_connections)?;
    let recent_connections = connections(manager, &node.data.recent_connections)?;
    let owner = owner_text(node.data.owner, &node.id);
    Ok(Node {
        id: node.id.to_string(),
        short_name: node.data.short_name,
        short_description: node.data.short_description,
        long_description: node.data.long_description,
        owner,
        fixed_connections,
        recent_connections,
        objects: node.data.objects.iter().map(ToString::to_string).collect(),
        last_modified_by: node.last_author,
        last_modified_at: Some(node.committed_at.to_rfc3339()),
    })
}

fn owner_text(owner: Owner, node_id: &NodeId) -> String {
    match owner {
        Owner::Unowned => "unowned".into(),
        Owner::SelfNode => node_id.to_string(),
        Owner::Node(id) => id.to_string(),
    }
}

fn connections(manager: &KwebManager, ids: &[NodeId]) -> anyhow::Result<Vec<Connection>> {
    ids.iter()
        .map(|id| read_node(manager, &id.to_string()))
        .map(|node| {
            node.map(|node| {
                connection_from_parts(node.id, node.data.short_name, node.data.short_description)
            })
        })
        .collect()
}

fn connection_from_parts(id: NodeId, short_name: String, short_description: String) -> Connection {
    Connection {
        id: id.to_string(),
        short_name,
        short_description,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn context_node(id: &str) -> Node {
        Node {
            id: id.into(),
            short_name: "Node".into(),
            short_description: "Description".into(),
            long_description: "Long description".into(),
            owner: "unowned".into(),
            fixed_connections: Vec::new(),
            recent_connections: Vec::new(),
            objects: Vec::new(),
            last_modified_by: "test".into(),
            last_modified_at: None,
        }
    }

    #[test]
    fn converts_owner_and_connection_projection() {
        let node_id = "AAAAAAAE".parse::<NodeId>().unwrap();
        let owner_id = "AAAAAAAI".parse::<NodeId>().unwrap();

        assert_eq!(owner_text(Owner::Unowned, &node_id), "unowned");
        assert_eq!(owner_text(Owner::SelfNode, &node_id), "AAAAAAAE");
        assert_eq!(owner_text(Owner::Node(owner_id), &node_id), "AAAAAAAI");

        let connection = connection_from_parts(node_id, "Short".into(), "Description".into());
        assert_eq!(connection.id, "AAAAAAAE");
        assert_eq!(connection.short_name, "Short");
        assert_eq!(connection.short_description, "Description");
    }

    #[test]
    fn failed_batch_does_not_partially_update_context() {
        let first = "AAAAAAAE".to_owned();
        let second = "AAAAAAAI".to_owned();
        let mut context = Context::new(vec![first.clone()]).unwrap();

        let result = load_batch_with(
            &mut context,
            &[first.clone(), second.clone()],
            |preview, durable_id| {
                if durable_id == second {
                    anyhow::bail!("simulated second read failure");
                }
                preview
                    .apply_load(context_node(durable_id), Vec::new())
                    .map_err(anyhow::Error::new)
            },
        );

        assert!(result.is_err());
        assert!(!context.contains_full_node(&first));
    }
}