Files
Graphite/document/graph-storage/src/from_runtime.rs
Timon dc813e35fc More consistent document crate names (#4323)
* More consistent document crate names

* Fix fmt

* Fix ASCII art diagrams

* rename document-graph to document-graph-storage
2026-07-15 12:25:12 +00:00

574 lines
24 KiB
Rust

use std::collections::HashMap;
use core_types::Context;
use core_types::context::ContextDependencies;
use core_types::uuid::NodeId as RuntimeNodeId;
use graph_craft::concrete;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeInput as GraphCraftNodeInput, NodeNetwork};
use serde::Serialize;
use crate::attr::*;
use crate::metadata_source::{NoMetadata, NodeMetadataSource};
use crate::{AttributesWrite, ExportSlot, Implementation, InputSlot, Network, NetworkId, Node, NodeId, NodeInput, PeerId, ProtoNode, ROOT_NETWORK, Registry, ResourceHash, ResourceId, TimeStamp};
fn map_serialization_error(key: &str) -> impl FnOnce(serde_json::Error) -> ConversionError + '_ {
move |e| ConversionError::SerializationError(format!("{key}: {e:?}"))
}
/// Path to a node, used to mint stable global IDs by hashing.
///
/// Hashing uses blake3 truncated to 64 bits with the document's `PeerId` mixed in, so two peers
/// converting runtime states that happen to share local IDs (e.g. both editors seeded the same
/// UUID RNG) still produce distinct global IDs. Determinism: same `(peer, path, local_id)` always
/// yields the same global ID, so a peer re-converting its own runtime state preserves IDs.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct NodePath {
path: Vec<(RuntimeNodeId, NetworkId)>,
local_id: RuntimeNodeId,
}
impl NodePath {
fn root(node_id: RuntimeNodeId) -> Self {
Self { path: vec![], local_id: node_id }
}
fn nested(parent_path: &NodePath, parent_node_id: RuntimeNodeId, network_id: NetworkId, local_id: RuntimeNodeId) -> Self {
let mut path = parent_path.path.clone();
path.push((parent_node_id, network_id));
Self { path, local_id }
}
fn to_global_id(&self, peer: PeerId) -> NodeId {
let bytes = rmp_serde::to_vec(&(peer, self)).expect("NodePath must serialize");
let digest = blake3::hash(&bytes);
let mut truncated = [0u8; 8];
truncated.copy_from_slice(&digest.as_bytes()[..8]);
NodeId(u64::from_le_bytes(truncated))
}
/// Stable id of the network owned by the node at this path, derived purely from the (structural)
/// path and peer so it reproduces across `to_runtime` -> `from_runtime` round trips rather than
/// depending on traversal order. A domain tag keeps it from colliding with this node's own
/// `to_global_id`. The root network is `ROOT_NETWORK` and never goes through here.
fn owned_network_id(&self, peer: PeerId) -> NetworkId {
let bytes = rmp_serde::to_vec(&("network", peer, self)).expect("NodePath must serialize");
let digest = blake3::hash(&bytes);
let mut truncated = [0u8; 8];
truncated.copy_from_slice(&digest.as_bytes()[..8]);
NetworkId(u64::from_le_bytes(truncated))
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConversionError {
#[error("Failed to serialize value: {0}")]
SerializationError(String),
#[error("Unsupported node implementation type")]
UnsupportedImplementation,
#[error("Invalid network structure: {0}")]
InvalidNetwork(String),
#[error("Index {0} exceeds the storage format's u32 range")]
IndexOverflow(usize),
}
/// Graph-only conversion (no editor metadata). Use [`Registry::from_runtime_with_metadata`] for
/// editor round-trips.
impl TryFrom<&NodeNetwork> for Registry {
type Error = ConversionError;
/// Test/utility entry point: scopes IDs under `PeerId(0)`. Real editor conversions go through
/// `from_runtime_with_metadata` and pass the document's actual peer.
fn try_from(node_network: &NodeNetwork) -> Result<Self, Self::Error> {
Registry::from_runtime_with_metadata(node_network, &NoMetadata, &graphene_resource::ResourceRegistry::new(), PeerId(0))
}
}
/// Proto-node declaration bytes extracted during conversion, keyed by content hash, for the caller
/// to persist into its byte store.
pub type DeclarationBytes = HashMap<ResourceHash, Vec<u8>>;
/// A `from_runtime` conversion result: the reference-only [`Registry`] plus the proto-node
/// declaration *bytes* it extracted, keyed by content hash. `document-graph-storage` doesn't own a byte
/// store, so the caller (the `Gdd`) persists these into its content store; the registry only holds
/// the `ResourceId`/`ResourceHash` references.
pub struct RuntimeConversion {
pub registry: Registry,
pub declaration_bytes: DeclarationBytes,
/// Each network's runtime `metadata_path` mapped to its stable storage `NetworkId`, for associating
/// per-network, per-peer view state (`session.json`) without re-deriving ids.
pub network_ids: HashMap<Vec<RuntimeNodeId>, NetworkId>,
}
impl RuntimeConversion {
/// Rebuild the [`Declarations`](crate::Declarations) map (`ResourceId` → [`ProtoNode`]) from the
/// extracted bytes, for callers that keep the bytes in hand instead of routing them through a
/// byte store (tests, the round-trip CLI). Editor/`Gdd` paths persist the bytes and resolve via
/// their byte store instead.
pub fn declarations(&self) -> Result<crate::Declarations, ConversionError> {
self.declaration_bytes
.iter()
.map(|(hash, bytes)| {
let proto = decode_declaration(bytes).map_err(|error| ConversionError::SerializationError(format!("declaration {hash}: {error}")))?;
Ok((ResourceId::from_hash(hash), proto))
})
.collect()
}
}
/// Encode a [`ProtoNode`] declaration to its content-addressed bytes: through a self-describing
/// `serde_json::Value` (so serde aliases keep working and the on-disk shape stays migratable), then
/// rmp-serialized (which encodes the intermediate `Value` compactly). Paired with [`decode_declaration`].
pub fn encode_declaration(proto: &ProtoNode) -> Result<Vec<u8>, String> {
let value = serde_json::to_value(proto).map_err(|error| error.to_string())?;
rmp_serde::to_vec(&value).map_err(|error| error.to_string())
}
/// Decode a [`ProtoNode`] declaration from the bytes [`encode_declaration`] produced.
pub fn decode_declaration(bytes: &[u8]) -> Result<ProtoNode, String> {
let value: serde_json::Value = rmp_serde::from_slice(bytes).map_err(|error| error.to_string())?;
serde_json::from_value(value).map_err(|error| error.to_string())
}
impl Registry {
/// Convenience wrapper returning only the registry (declaration bytes discarded). For callers
/// that don't persist a byte store — e.g. the graph-only `TryFrom` and value-comparison tests.
pub fn from_runtime_with_metadata<M: NodeMetadataSource>(node_network: &NodeNetwork, metadata: &M, resources: &graphene_resource::ResourceRegistry, peer: PeerId) -> Result<Self, ConversionError> {
Ok(Self::convert_from_runtime(node_network, metadata, resources, peer)?.registry)
}
/// Full conversion: returns the registry and the extracted declaration bytes for the caller to
/// persist. See [`RuntimeConversion`].
pub fn convert_from_runtime<M: NodeMetadataSource>(
node_network: &NodeNetwork,
metadata: &M,
resources: &graphene_resource::ResourceRegistry,
peer: PeerId,
) -> Result<RuntimeConversion, ConversionError> {
let mut registry = Registry::default();
let mut ctx = ConversionContext {
declaration_ids: HashMap::new(),
declaration_bytes: HashMap::new(),
network_ids: HashMap::new(),
metadata,
peer,
};
convert_network(node_network, ROOT_NETWORK, None, &[], &mut registry, &mut ctx)?;
// Only snapshot resources the network actually references. The runtime resource cache also keeps
// resources alive across undo (so legacy redo can restore them), so it can contain orphans whose
// node was removed by an undo. Snapshotting those would re-introduce an `AddResource` on the next
// diff and let an undone resource resurface as a phantom edit. Declaration resources are added
// separately by `convert_network` and are always referenced, so they're unaffected by this filter.
let referenced = collect_referenced_resources(node_network);
convert_resources(resources, &referenced, peer, &mut registry)?;
Ok(RuntimeConversion {
registry,
declaration_bytes: ctx.declaration_bytes,
network_ids: ctx.network_ids,
})
}
}
/// Snapshot the runtime [`ResourceRegistry`](graphene_resource::ResourceRegistry) into the storage
/// [`ResourceStore`](crate::ResourceStore). Each source's chain position becomes a fractional
/// [`Priority`](crate::Priority) (index-as-priority preserves order); the `DataSource` body is
/// stored type-erased as `serde_json::Value` so its on-disk shape can migrate freely. All
/// timestamps are `ORIGIN`, since this is a bootstrap snapshot, not an edit.
fn convert_resources(resources: &graphene_resource::ResourceRegistry, referenced: &std::collections::HashSet<ResourceId>, peer: PeerId, registry: &mut Registry) -> Result<(), ConversionError> {
for id in resources.ids() {
if !referenced.contains(&id) {
continue;
}
let Some(info) = resources.info(&id) else { continue };
let mut entry = crate::ResourceEntry {
hash: info.hash.copied(),
hash_timestamp: TimeStamp::ORIGIN,
..Default::default()
};
for (position, source) in info.sources.iter().enumerate() {
let key = crate::SourceKey {
priority: crate::Priority::new(position as f64).expect("enumerate index is finite"),
peer,
};
let body = serde_json::to_value(source).map_err(|error| ConversionError::SerializationError(error.to_string()))?;
entry.set_source(
key,
crate::SourceValue {
source: body,
timestamp: TimeStamp::ORIGIN,
},
);
}
registry.resources.insert(id, entry);
}
Ok(())
}
/// Collect the `ResourceId`s referenced by `TaggedValue::Resource` inputs anywhere in the network
/// (recursively through nested networks). These are the resources the document actually uses; the
/// runtime cache may hold more (history-retained orphans) that shouldn't be snapshotted into storage.
fn collect_referenced_resources(network: &NodeNetwork) -> std::collections::HashSet<ResourceId> {
let mut referenced = std::collections::HashSet::new();
collect_referenced_resources_inner(network, &mut referenced);
referenced
}
fn collect_referenced_resources_inner(network: &NodeNetwork, referenced: &mut std::collections::HashSet<ResourceId>) {
for export in &network.exports {
collect_input_resource(export, referenced);
}
for node in network.nodes.values() {
for input in &node.inputs {
collect_input_resource(input, referenced);
}
if let DocumentNodeImplementation::Network(nested) = &node.implementation {
collect_referenced_resources_inner(nested, referenced);
}
}
}
fn collect_input_resource(input: &GraphCraftNodeInput, referenced: &mut std::collections::HashSet<ResourceId>) {
if let GraphCraftNodeInput::Value { tagged_value, .. } = input
&& let TaggedValue::Resource(id) = &**tagged_value
{
referenced.insert(*id);
}
}
/// Register a proto-node declaration as a content-addressed resource: a single `DataSource::Embedded`
/// source resolved to `hash`. The bytes themselves are persisted by the caller's byte store.
fn register_declaration_resource(registry: &mut Registry, id: ResourceId, hash: ResourceHash, peer: PeerId) {
registry.resources.insert(id, crate::ResourceEntry::embedded(hash, peer, TimeStamp::ORIGIN));
}
struct ConversionContext<'m, M: NodeMetadataSource + ?Sized> {
/// Cache from proto-node identifier to its derived `ResourceId`, so repeated proto-nodes reuse
/// one id without re-serializing. (Identical content hashes to the same id anyway; this just
/// skips the work.)
declaration_ids: HashMap<String, ResourceId>,
/// Extracted declaration content keyed by hash, handed back for the caller's byte store.
declaration_bytes: DeclarationBytes,
/// Maps each network's runtime `metadata_path` to its stable storage `NetworkId`, so the caller can
/// associate per-network, per-peer view state (in `session.json`) with networks without re-deriving ids.
network_ids: HashMap<Vec<RuntimeNodeId>, NetworkId>,
metadata: &'m M,
peer: PeerId,
}
fn convert_network<M: NodeMetadataSource + ?Sized>(
node_network: &NodeNetwork,
network_id: NetworkId,
parent_path: Option<&NodePath>,
metadata_path: &[RuntimeNodeId],
registry: &mut Registry,
ctx: &mut ConversionContext<'_, M>,
) -> Result<(), ConversionError> {
for (runtime_node_id, doc_node) in &node_network.nodes {
let node_path = child_path(parent_path, network_id, *runtime_node_id);
let global_id = node_path.to_global_id(ctx.peer);
let location = NodeLocation {
network_id,
parent_path,
metadata_path,
runtime_node_id: *runtime_node_id,
};
let mut node = convert_node(doc_node, location, registry, ctx)?;
node.attributes.set(node::ORIGINAL_NODE_ID, serde_json::json!(runtime_node_id.0), TimeStamp::ORIGIN);
registry.node_instances.insert(global_id, node);
}
let exports = node_network
.exports
.iter()
.map(|export| {
Ok(ExportSlot {
target: Some(convert_input(export, parent_path, network_id, ctx.peer)?),
timestamp: TimeStamp::ORIGIN,
})
})
.collect::<Result<Vec<_>, ConversionError>>()?;
let mut attributes = crate::Attributes::new();
write_ui_network_attributes(&mut attributes, ctx.metadata, metadata_path, TimeStamp::ORIGIN)?;
write_scope_injections(&mut attributes, node_network, parent_path, network_id, ctx.peer, TimeStamp::ORIGIN)?;
registry.networks.insert(network_id, Network { exports, attributes });
ctx.network_ids.insert(metadata_path.to_vec(), network_id);
Ok(())
}
/// Serialize a network's `scope_injections` onto its attributes as one whole-map LWW blob, remapping
/// each runtime-local node reference to its stable storage global ID so the reference survives a
/// round trip even if runtime IDs are later reshuffled.
fn write_scope_injections(
attributes: &mut crate::Attributes,
node_network: &NodeNetwork,
parent_path: Option<&NodePath>,
network_id: NetworkId,
peer: PeerId,
timestamp: TimeStamp,
) -> Result<(), ConversionError> {
if node_network.scope_injections.is_empty() {
return Ok(());
}
let stored: HashMap<String, (NodeId, core_types::Type)> = node_network
.scope_injections
.iter()
.map(|(key, (runtime_id, ty))| {
let storage_id = child_path(parent_path, network_id, *runtime_id).to_global_id(peer);
(key.clone(), (storage_id, ty.clone()))
})
.collect();
attributes
.set_serialized(network::SCOPE_INJECTIONS, &stored, timestamp)
.map_err(map_serialization_error(network::SCOPE_INJECTIONS))
}
fn child_path(parent_path: Option<&NodePath>, network_id: NetworkId, local_id: RuntimeNodeId) -> NodePath {
match parent_path {
None => NodePath::root(local_id),
Some(parent) => NodePath::nested(parent, parent.local_id, network_id, local_id),
}
}
/// Where a node sits in both the storage tree (`network_id`, `parent_path`) and the runtime tree
/// (`metadata_path`, `runtime_node_id`). `metadata_path` is the chain of runtime IDs from the root
/// down to (but not including) this node.
struct NodeLocation<'a> {
network_id: NetworkId,
parent_path: Option<&'a NodePath>,
metadata_path: &'a [RuntimeNodeId],
runtime_node_id: RuntimeNodeId,
}
fn convert_node<M: NodeMetadataSource + ?Sized>(doc_node: &DocumentNode, location: NodeLocation<'_>, registry: &mut Registry, ctx: &mut ConversionContext<'_, M>) -> Result<Node, ConversionError> {
let NodeLocation {
network_id,
parent_path,
metadata_path,
runtime_node_id,
} = location;
let node_path = child_path(parent_path, network_id, runtime_node_id);
let timestamp = TimeStamp::ORIGIN;
let mut inputs = Vec::with_capacity(doc_node.inputs.len());
for (input_index, input) in doc_node.inputs.iter().enumerate() {
let mut input_attrs = convert_input_attributes(input)?;
write_ui_input_attributes(&mut input_attrs, ctx.metadata, metadata_path, runtime_node_id, input_index, timestamp)?;
inputs.push(InputSlot {
input: convert_input(input, parent_path, network_id, ctx.peer)?,
timestamp,
attributes: input_attrs,
});
}
// For nested networks, append this node onto the metadata path.
let mut extended_path = Vec::new();
let child_metadata_path = if matches!(doc_node.implementation, DocumentNodeImplementation::Network(_)) {
extended_path.extend_from_slice(metadata_path);
extended_path.push(runtime_node_id);
extended_path.as_slice()
} else {
metadata_path
};
let implementation = convert_implementation(&doc_node.implementation, &node_path, child_metadata_path, registry, ctx)?;
// Defaults match `DocumentNode::default()`; `to_runtime` rehydrates absent keys from the same defaults.
let mut attributes = crate::Attributes::new();
attributes
.set_if_not_default(node::CALL_ARGUMENT, &doc_node.call_argument, &concrete!(Context), timestamp)
.map_err(map_serialization_error(node::CALL_ARGUMENT))?;
attributes
.set_if_not_default(node::CONTEXT_FEATURES, &doc_node.context_features, &ContextDependencies::default(), timestamp)
.map_err(map_serialization_error(node::CONTEXT_FEATURES))?;
attributes
.set_if_not_default(node::VISIBLE, &doc_node.visible, &true, timestamp)
.map_err(map_serialization_error(node::VISIBLE))?;
attributes
.set_if_not_default(node::SKIP_DEDUPLICATION, &doc_node.skip_deduplication, &false, timestamp)
.map_err(map_serialization_error(node::SKIP_DEDUPLICATION))?;
write_ui_attributes(&mut attributes, ctx.metadata, metadata_path, runtime_node_id, timestamp)?;
Ok(Node {
implementation,
inputs,
attributes,
network: network_id,
})
}
fn write_ui_attributes<M: NodeMetadataSource + ?Sized>(
attributes: &mut crate::Attributes,
metadata: &M,
metadata_path: &[RuntimeNodeId],
runtime_node_id: RuntimeNodeId,
timestamp: TimeStamp,
) -> Result<(), ConversionError> {
if let Some(position) = metadata.position(metadata_path, runtime_node_id) {
attributes
.set_serialized(node::ui::POSITION, &position, timestamp)
.map_err(map_serialization_error(node::ui::POSITION))?;
}
// Bool flags are only emitted when true; absence reads as false.
for (key, value) in [
(node::ui::IS_LAYER, metadata.is_layer(metadata_path, runtime_node_id)),
(node::ui::LOCKED, metadata.locked(metadata_path, runtime_node_id)),
(node::ui::PINNED, metadata.pinned(metadata_path, runtime_node_id)),
] {
if value {
attributes.set(key, serde_json::Value::Bool(true), timestamp);
}
}
if let Some(name) = metadata.display_name(metadata_path, runtime_node_id)
&& !name.is_empty()
{
attributes.set(node::ui::DISPLAY_NAME, serde_json::Value::String(name.to_string()), timestamp);
}
// One whole-vec attribute; per-slot LWW would be overkill for rename-on-output.
let output_names = metadata.output_names(metadata_path, runtime_node_id);
if !output_names.is_empty() {
attributes
.set_serialized(node::ui::OUTPUT_NAMES, &output_names, timestamp)
.map_err(map_serialization_error(node::ui::OUTPUT_NAMES))?;
}
Ok(())
}
fn write_ui_network_attributes<M: NodeMetadataSource + ?Sized>(attributes: &mut crate::Attributes, metadata: &M, network_path: &[RuntimeNodeId], timestamp: TimeStamp) -> Result<(), ConversionError> {
if let Some(reference) = metadata.reference(network_path) {
attributes.set(node::ui::REFERENCE, serde_json::Value::String(reference.to_string()), timestamp);
}
Ok(())
}
/// Empty strings (the runtime's "unset" sentinel) and absent values are both skipped.
/// `input_data` entries each get their own `ui::input_data::<sub_key>` attribute for per-key LWW.
fn write_ui_input_attributes<M: NodeMetadataSource + ?Sized>(
attributes: &mut crate::Attributes,
metadata: &M,
metadata_path: &[RuntimeNodeId],
runtime_node_id: RuntimeNodeId,
input_index: usize,
timestamp: TimeStamp,
) -> Result<(), ConversionError> {
let non_empty_string = |key: &'static str, value: Option<&str>, attributes: &mut crate::Attributes| {
if let Some(value) = value.filter(|s| !s.is_empty()) {
attributes.set(key, serde_json::Value::String(value.to_string()), timestamp);
}
};
non_empty_string(node::input::ui::NAME, metadata.input_name(metadata_path, runtime_node_id, input_index), attributes);
non_empty_string(node::input::ui::DESCRIPTION, metadata.input_description(metadata_path, runtime_node_id, input_index), attributes);
non_empty_string(node::input::ui::WIDGET_OVERRIDE, metadata.widget_override(metadata_path, runtime_node_id, input_index), attributes);
for (sub_key, value) in metadata.input_data(metadata_path, runtime_node_id, input_index) {
attributes.set(&format!("{prefix}{sub_key}", prefix = node::input::ui::DATA_PREFIX), value, timestamp);
}
Ok(())
}
fn convert_input(input: &GraphCraftNodeInput, parent_path: Option<&NodePath>, network_id: NetworkId, peer: PeerId) -> Result<NodeInput, ConversionError> {
Ok(match input {
GraphCraftNodeInput::Node { node_id, output_index } => NodeInput::Node {
id: child_path(parent_path, network_id, *node_id).to_global_id(peer),
index: (*output_index).try_into().map_err(|_| ConversionError::IndexOverflow(*output_index))?,
},
GraphCraftNodeInput::Value { tagged_value, exposed } => {
let value = serde_json::to_value(&**tagged_value).map_err(|e| ConversionError::SerializationError(format!("{e:?}")))?;
NodeInput::Value { value, exposed: *exposed }
}
GraphCraftNodeInput::Scope(s) => NodeInput::Scope(s.clone()),
GraphCraftNodeInput::Import { import_index, .. } => NodeInput::Import {
index: (*import_index).try_into().map_err(|_| ConversionError::IndexOverflow(*import_index))?,
},
GraphCraftNodeInput::Reflection(_) => NodeInput::Reflection,
// GPU-specific; not modeled in the Registry format.
GraphCraftNodeInput::Inline(_) => return Err(ConversionError::UnsupportedImplementation),
})
}
fn convert_input_attributes(input: &GraphCraftNodeInput) -> Result<crate::Attributes, ConversionError> {
let mut attributes = crate::Attributes::new();
let timestamp = TimeStamp::ORIGIN;
match input {
GraphCraftNodeInput::Import { import_type, .. } => {
attributes
.set_serialized(node::input::IMPORT_TYPE, import_type, timestamp)
.map_err(map_serialization_error(node::input::IMPORT_TYPE))?;
}
GraphCraftNodeInput::Reflection(metadata) => {
attributes
.set_serialized(node::REFLECTION_METADATA, metadata, timestamp)
.map_err(map_serialization_error(node::REFLECTION_METADATA))?;
}
_ => {}
}
Ok(attributes)
}
fn convert_implementation<M: NodeMetadataSource + ?Sized>(
implementation: &DocumentNodeImplementation,
current_node_path: &NodePath,
child_metadata_path: &[RuntimeNodeId],
registry: &mut Registry,
ctx: &mut ConversionContext<'_, M>,
) -> Result<Implementation, ConversionError> {
Ok(match implementation {
DocumentNodeImplementation::ProtoNode(identifier) => {
let identifier_str = identifier.as_str().to_string();
// Reuse a previously-converted proto-node's id; identical content hashes to the same id
// anyway, so this only skips re-serializing.
if let Some(id) = ctx.declaration_ids.get(&identifier_str) {
return Ok(Implementation::ProtoNode(*id));
}
let proto = ProtoNode {
identifier: identifier_str.clone(),
attributes: Default::default(),
};
// Content-address the declaration: serialize, hash, derive a deterministic id.
let bytes = encode_declaration(&proto).map_err(|error| ConversionError::SerializationError(format!("proto-node {identifier_str}: {error}")))?;
let hash = ResourceHash::from(bytes.as_slice());
let id = ResourceId::from_hash(&hash);
register_declaration_resource(registry, id, hash, ctx.peer);
ctx.declaration_bytes.insert(hash, bytes);
ctx.declaration_ids.insert(identifier_str, id);
Implementation::ProtoNode(id)
}
DocumentNodeImplementation::Network(nested_network) => {
// Stable, traversal-order-independent id derived from the owning node's path, so a
// `to_runtime` -> `from_runtime` round trip reproduces the same `NetworkId` (and thus the
// same node-path hashes underneath it).
let nested_network_id = current_node_path.owned_network_id(ctx.peer);
convert_network(nested_network, nested_network_id, Some(current_node_path), child_metadata_path, registry, ctx)?;
Implementation::Network(nested_network_id)
}
// TODO: Support Extract in the Registry format.
DocumentNodeImplementation::Extract => return Err(ConversionError::UnsupportedImplementation),
})
}