Skip to main content

kcode_kennedy_kweb_loader/
lib.rs

1//! Atomic durable Kweb loading for Kennedy context projections.
2
3#![forbid(unsafe_code)]
4
5use anyhow::Context as _;
6use kcode_kweb_context::{Connection, Context, LoadReport, Node};
7use kcode_kweb_db::{Node as KwebNode, NodeId, Owner};
8use kcode_kweb_manager::KwebManager;
9use serde_json::{Value, json};
10
11/// Reads and projects every requested durable node, committing the context
12/// update only after the complete batch succeeds.
13pub fn load_durable_batch(
14    manager: &KwebManager,
15    context: &mut Context,
16    durable_ids: &[String],
17) -> anyhow::Result<Value> {
18    load_batch_with(context, durable_ids, |preview, durable_id| {
19        load_one(manager, preview, durable_id)
20    })
21}
22
23/// Decodes the legacy JSON representation of a projected Kweb context node.
24pub fn node_from_value(value: &Value) -> anyhow::Result<Node> {
25    Node::from_kweb_value(value).map_err(anyhow::Error::new)
26}
27
28fn load_batch_with(
29    context: &mut Context,
30    durable_ids: &[String],
31    mut load: impl FnMut(&mut Context, &str) -> anyhow::Result<LoadReport>,
32) -> anyhow::Result<Value> {
33    let mut preview = context.clone();
34    let mut reports = Vec::with_capacity(durable_ids.len());
35    for durable_id in durable_ids {
36        reports.push(load(&mut preview, durable_id)?);
37    }
38    *context = preview;
39    Ok(json!({"loads": reports}))
40}
41
42fn load_one(
43    manager: &KwebManager,
44    context: &mut Context,
45    durable_id: &str,
46) -> anyhow::Result<LoadReport> {
47    let requested = read_node(manager, durable_id)?;
48    anyhow::ensure!(
49        requested.id.to_string() == durable_id,
50        "Kweb returned node {} when {durable_id} was requested",
51        requested.id
52    );
53    let fixed_ids = requested.data.fixed_connections.clone();
54    let requested = node_from_kweb(manager, requested)?;
55    let fixed = fixed_ids
56        .into_iter()
57        .filter(|id| id.to_string() != durable_id)
58        .map(|id| read_node(manager, &id.to_string()))
59        .map(|node| node.and_then(|node| node_from_kweb(manager, node)))
60        .collect::<anyhow::Result<Vec<_>>>()?;
61    context
62        .apply_load(requested, fixed)
63        .map_err(anyhow::Error::new)
64}
65
66fn read_node(manager: &KwebManager, durable_id: &str) -> anyhow::Result<KwebNode> {
67    let node_id = durable_id
68        .parse::<NodeId>()
69        .with_context(|| format!("{durable_id:?} is not a canonical node ID"))?;
70    manager.get_node(node_id).map_err(anyhow::Error::new)
71}
72
73fn node_from_kweb(manager: &KwebManager, node: KwebNode) -> anyhow::Result<Node> {
74    let fixed_connections = connections(manager, &node.data.fixed_connections)?;
75    let recent_connections = connections(manager, &node.data.recent_connections)?;
76    let owner = owner_text(node.data.owner, &node.id);
77    Ok(Node {
78        id: node.id.to_string(),
79        short_name: node.data.short_name,
80        short_description: node.data.short_description,
81        long_description: node.data.long_description,
82        owner,
83        fixed_connections,
84        recent_connections,
85        objects: node.data.objects.iter().map(ToString::to_string).collect(),
86        last_modified_by: node.last_author,
87        last_modified_at: Some(node.committed_at.to_rfc3339()),
88    })
89}
90
91fn owner_text(owner: Owner, node_id: &NodeId) -> String {
92    match owner {
93        Owner::Unowned => "unowned".into(),
94        Owner::SelfNode => node_id.to_string(),
95        Owner::Node(id) => id.to_string(),
96    }
97}
98
99fn connections(manager: &KwebManager, ids: &[NodeId]) -> anyhow::Result<Vec<Connection>> {
100    ids.iter()
101        .map(|id| read_node(manager, &id.to_string()))
102        .map(|node| {
103            node.map(|node| {
104                connection_from_parts(node.id, node.data.short_name, node.data.short_description)
105            })
106        })
107        .collect()
108}
109
110fn connection_from_parts(id: NodeId, short_name: String, short_description: String) -> Connection {
111    Connection {
112        id: id.to_string(),
113        short_name,
114        short_description,
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn context_node(id: &str) -> Node {
123        Node {
124            id: id.into(),
125            short_name: "Node".into(),
126            short_description: "Description".into(),
127            long_description: "Long description".into(),
128            owner: "unowned".into(),
129            fixed_connections: Vec::new(),
130            recent_connections: Vec::new(),
131            objects: Vec::new(),
132            last_modified_by: "test".into(),
133            last_modified_at: None,
134        }
135    }
136
137    #[test]
138    fn converts_owner_and_connection_projection() {
139        let node_id = "AAAAAAAE".parse::<NodeId>().unwrap();
140        let owner_id = "AAAAAAAI".parse::<NodeId>().unwrap();
141
142        assert_eq!(owner_text(Owner::Unowned, &node_id), "unowned");
143        assert_eq!(owner_text(Owner::SelfNode, &node_id), "AAAAAAAE");
144        assert_eq!(owner_text(Owner::Node(owner_id), &node_id), "AAAAAAAI");
145
146        let connection = connection_from_parts(node_id, "Short".into(), "Description".into());
147        assert_eq!(connection.id, "AAAAAAAE");
148        assert_eq!(connection.short_name, "Short");
149        assert_eq!(connection.short_description, "Description");
150    }
151
152    #[test]
153    fn failed_batch_does_not_partially_update_context() {
154        let first = "AAAAAAAE".to_owned();
155        let second = "AAAAAAAI".to_owned();
156        let mut context = Context::new(vec![first.clone()]).unwrap();
157
158        let result = load_batch_with(
159            &mut context,
160            &[first.clone(), second.clone()],
161            |preview, durable_id| {
162                if durable_id == second {
163                    anyhow::bail!("simulated second read failure");
164                }
165                preview
166                    .apply_load(context_node(durable_id), Vec::new())
167                    .map_err(anyhow::Error::new)
168            },
169        );
170
171        assert!(result.is_err());
172        assert!(!context.contains_full_node(&first));
173    }
174}