///|
/// Private mutable graph state. Database and transaction APIs will own this
/// implementation; callers receive detached model values from read operations.
priv struct GraphState {
  nodes : Map[NodeId, Node]
  edges : Map[EdgeId, Edge]
  node_ids_by_label : Map[String, Array[NodeId]]
  node_property_indexes : Map[
    NodePropertyIndex,
    Map[PropertyValue, Array[NodeId]],
  ]
  edge_ids_by_type : Map[String, Array[EdgeId]]
  edge_property_indexes : Map[
    EdgePropertyIndex,
    Map[PropertyValue, Array[EdgeId]],
  ]
  outgoing_edge_ids : Map[NodeId, Array[EdgeId]]
  incoming_edge_ids : Map[NodeId, Array[EdgeId]]
  mut next_node_id : UInt64?
  mut next_edge_id : UInt64?
}

///|
priv struct NodePropertyIndex {
  label : String
  property : String
} derive(Eq, Hash)

///|
priv struct EdgePropertyIndex {
  edge_type : String
  property : String
} derive(Eq, Hash)

///|
/// Create an empty graph state with the first database-assigned ID reserved.
#warnings("-unused_value")
fn GraphState::new() -> GraphState {
  {
    nodes: Map([]),
    edges: Map([]),
    node_ids_by_label: Map([]),
    node_property_indexes: Map([]),
    edge_ids_by_type: Map([]),
    edge_property_indexes: Map([]),
    outgoing_edge_ids: Map([]),
    incoming_edge_ids: Map([]),
    next_node_id: Some(1UL),
    next_edge_id: Some(1UL),
  }
}

///|
#warnings("-unused_value")
fn GraphState::copy_detached(self : GraphState) -> GraphState {
  let nodes : Map[NodeId, Node] = Map([])
  for id, node in self.nodes {
    nodes.set(
      id,
      Node::Node(
        id=node.id,
        labels=node.labels.copy(),
        properties=node.properties.copy(),
      ),
    )
  }

  let edges : Map[EdgeId, Edge] = Map([])
  for id, edge in self.edges {
    edges.set(
      id,
      Edge::Edge(
        id=edge.id,
        from=edge.from,
        to=edge.to,
        edge_type=edge.edge_type,
        properties=edge.properties.copy(),
      ),
    )
  }

  let node_ids_by_label : Map[String, Array[NodeId]] = Map([])
  for label, ids in self.node_ids_by_label {
    node_ids_by_label.set(label, ids.copy())
  }

  let node_property_indexes : Map[
    NodePropertyIndex,
    Map[PropertyValue, Array[NodeId]],
  ] = Map([])
  for definition, values in self.node_property_indexes {
    let copied_values : Map[PropertyValue, Array[NodeId]] = Map([])
    for value, ids in values {
      copied_values.set(value, ids.copy())
    }
    node_property_indexes.set(definition, copied_values)
  }

  let edge_ids_by_type : Map[String, Array[EdgeId]] = Map([])
  for edge_type, ids in self.edge_ids_by_type {
    edge_ids_by_type.set(edge_type, ids.copy())
  }

  let edge_property_indexes : Map[
    EdgePropertyIndex,
    Map[PropertyValue, Array[EdgeId]],
  ] = Map([])
  for definition, values in self.edge_property_indexes {
    let copied_values : Map[PropertyValue, Array[EdgeId]] = Map([])
    for value, ids in values {
      copied_values.set(value, ids.copy())
    }
    edge_property_indexes.set(definition, copied_values)
  }

  let outgoing_edge_ids : Map[NodeId, Array[EdgeId]] = Map([])
  for node_id, edge_ids in self.outgoing_edge_ids {
    outgoing_edge_ids.set(node_id, edge_ids.copy())
  }

  let incoming_edge_ids : Map[NodeId, Array[EdgeId]] = Map([])
  for node_id, edge_ids in self.incoming_edge_ids {
    incoming_edge_ids.set(node_id, edge_ids.copy())
  }

  {
    nodes,
    edges,
    node_ids_by_label,
    node_property_indexes,
    edge_ids_by_type,
    edge_property_indexes,
    outgoing_edge_ids,
    incoming_edge_ids,
    next_node_id: self.next_node_id,
    next_edge_id: self.next_edge_id,
  }
}

///|
fn NodePropertyIndex::new(
  label : String,
  property : String,
) -> NodePropertyIndex {
  { label, property, }
}

///|
fn EdgePropertyIndex::new(
  edge_type : String,
  property : String,
) -> EdgePropertyIndex {
  { edge_type, property, }
}

///|
fn node_property_index_name(label : String, property : String) -> String {
  String::add(String::add("node:", label), String::add(".", property))
}

///|
fn add_node_property_index_value(
  values : Map[PropertyValue, Array[NodeId]],
  value : PropertyValue,
  node_id : NodeId,
) -> Unit {
  let ids = match values.get(value) {
    Some(ids) => ids.copy()
    None => []
  }
  ids.push(node_id)
  ids.sort_by((left, right) => compare_node_ids(left, right))
  ids.dedup()
  values.set(value, ids)
}

///|
fn remove_node_property_index_value(
  values : Map[PropertyValue, Array[NodeId]],
  value : PropertyValue,
  node_id : NodeId,
) -> Unit {
  match values.get(value) {
    Some(ids) => {
      let remaining : Array[NodeId] = []
      for existing_id in ids.iter() {
        if existing_id != node_id {
          remaining.push(existing_id)
        }
      }
      if remaining.length() == 0 {
        values.remove(value)
      } else {
        values.set(value, remaining)
      }
    }
    None => ()
  }
}

///|
fn GraphState::add_node_property_posting(
  self : GraphState,
  label : String,
  property : String,
  value : PropertyValue,
  node_id : NodeId,
) -> Unit {
  let definition = NodePropertyIndex::new(label, property)
  match self.node_property_indexes.get(definition) {
    Some(current) => {
      let values = current.copy()
      add_node_property_index_value(values, value, node_id)
      self.node_property_indexes.set(definition, values)
    }
    None => ()
  }
}

///|
fn GraphState::remove_node_property_posting(
  self : GraphState,
  label : String,
  property : String,
  value : PropertyValue,
  node_id : NodeId,
) -> Unit {
  let definition = NodePropertyIndex::new(label, property)
  match self.node_property_indexes.get(definition) {
    Some(current) => {
      let values = current.copy()
      remove_node_property_index_value(values, value, node_id)
      self.node_property_indexes.set(definition, values)
    }
    None => ()
  }
}

///|
/// Reserved for the later production graph/database index-definition path.
#warnings("-unused_value")
fn GraphState::create_node_property_index(
  self : GraphState,
  label : String,
  property : String,
) -> Unit raise DatabaseError {
  let definition = NodePropertyIndex::new(label, property)
  match self.node_property_indexes.get(definition) {
    Some(_) =>
      raise DatabaseError::IndexAlreadyExists(
        name=node_property_index_name(label, property),
      )
    None => ()
  }
  let values : Map[PropertyValue, Array[NodeId]] = Map([])
  for node_id, node in self.nodes {
    let mut has_label = false
    for node_label in node.labels.iter() {
      if node_label == label {
        has_label = true
      }
    }
    if has_label {
      match node.properties.get(property) {
        Some(value) => add_node_property_index_value(values, value, node_id)
        None => ()
      }
    }
  }
  self.node_property_indexes.set(definition, values)
}

///|
/// Reserved for the later production graph/database property-index query path.
#warnings("-unused_value")
fn GraphState::node_ids_by_property_index(
  self : GraphState,
  label : String,
  property : String,
  value : PropertyValue,
) -> Array[NodeId]? raise DatabaseError {
  let canonical = normalize_property_value(property, value)
  let definition = NodePropertyIndex::new(label, property)
  match self.node_property_indexes.get(definition) {
    Some(values) =>
      Some(
        match values.get(canonical) {
          Some(ids) => ids.copy()
          None => []
        },
      )
    None => None
  }
}

///|
fn edge_property_index_name(edge_type : String, property : String) -> String {
  String::add(String::add("edge:", edge_type), String::add(".", property))
}

///|
fn add_edge_property_index_value(
  values : Map[PropertyValue, Array[EdgeId]],
  value : PropertyValue,
  edge_id : EdgeId,
) -> Unit {
  let ids = match values.get(value) {
    Some(ids) => ids.copy()
    None => []
  }
  ids.push(edge_id)
  ids.sort_by((left, right) => compare_edge_ids(left, right))
  ids.dedup()
  values.set(value, ids)
}

///|
fn remove_edge_property_index_value(
  values : Map[PropertyValue, Array[EdgeId]],
  value : PropertyValue,
  edge_id : EdgeId,
) -> Unit {
  match values.get(value) {
    Some(ids) => {
      let remaining : Array[EdgeId] = []
      for existing_id in ids.iter() {
        if existing_id != edge_id {
          remaining.push(existing_id)
        }
      }
      if remaining.length() == 0 {
        values.remove(value)
      } else {
        values.set(value, remaining)
      }
    }
    None => ()
  }
}

///|
fn GraphState::add_edge_property_posting(
  self : GraphState,
  edge_type : String,
  property : String,
  value : PropertyValue,
  edge_id : EdgeId,
) -> Unit {
  let definition = EdgePropertyIndex::new(edge_type, property)
  match self.edge_property_indexes.get(definition) {
    Some(current) => {
      let values = current.copy()
      add_edge_property_index_value(values, value, edge_id)
      self.edge_property_indexes.set(definition, values)
    }
    None => ()
  }
}

///|
fn GraphState::remove_edge_property_posting(
  self : GraphState,
  edge_type : String,
  property : String,
  value : PropertyValue,
  edge_id : EdgeId,
) -> Unit {
  let definition = EdgePropertyIndex::new(edge_type, property)
  match self.edge_property_indexes.get(definition) {
    Some(current) => {
      let values = current.copy()
      remove_edge_property_index_value(values, value, edge_id)
      self.edge_property_indexes.set(definition, values)
    }
    None => ()
  }
}

///|
/// Build an edge-property equality index over current graph contents.
#warnings("-unused_value")
fn GraphState::create_edge_property_index(
  self : GraphState,
  edge_type : String,
  property : String,
) -> Unit raise DatabaseError {
  let definition = EdgePropertyIndex::new(edge_type, property)
  match self.edge_property_indexes.get(definition) {
    Some(_) =>
      raise DatabaseError::IndexAlreadyExists(
        name=edge_property_index_name(edge_type, property),
      )
    None => ()
  }
  let values : Map[PropertyValue, Array[EdgeId]] = Map([])
  for edge_id, edge in self.edges {
    if edge.edge_type == edge_type {
      match edge.properties.get(property) {
        Some(value) => add_edge_property_index_value(values, value, edge_id)
        None => ()
      }
    }
  }
  self.edge_property_indexes.set(definition, values)
}

///|
/// Find matching IDs from a declared edge-property index, returning a detached bucket.
#warnings("-unused_value")
fn GraphState::edge_ids_by_property_index(
  self : GraphState,
  edge_type : String,
  property : String,
  value : PropertyValue,
) -> Array[EdgeId]? raise DatabaseError {
  let canonical = normalize_property_value(property, value)
  let definition = EdgePropertyIndex::new(edge_type, property)
  match self.edge_property_indexes.get(definition) {
    Some(values) =>
      Some(
        match values.get(canonical) {
          Some(ids) => ids.copy()
          None => []
        },
      )
    None => None
  }
}

///|
/// Validate a scalar and canonicalize negative zero before it enters graph state.
fn normalize_property_value(
  property : String,
  value : PropertyValue,
) -> PropertyValue raise DatabaseError {
  match value {
    PropertyValue::Float64(number) => {
      if number.is_nan() || number.is_inf() {
        raise DatabaseError::InvalidPropertyValue(
          property~,
          reason="Float64 properties must be finite",
        )
      }
      if number == 0.0 {
        PropertyValue::Float64(0.0)
      } else {
        value
      }
    }
    _ => value
  }
}

///|
/// Copy a property map while validating every stored scalar.
fn copy_validated_properties(
  properties : Properties,
) -> Properties raise DatabaseError {
  let copied : Properties = Map([])
  for key, value in properties {
    copied.set(key, normalize_property_value(key, value))
  }
  copied
}

///|
/// Compare node IDs by their wrapped unsigned numeric value.
fn compare_node_ids(left : NodeId, right : NodeId) -> Int {
  match (left, right) {
    (NodeId(left_raw), NodeId(right_raw)) => left_raw.compare(right_raw)
  }
}

///|
/// Add a node to one label bucket and keep its IDs unique and ordered.
fn GraphState::add_node_label_index_entry(
  self : GraphState,
  label : String,
  node_id : NodeId,
) -> Unit {
  let ids = match self.node_ids_by_label.get(label) {
    Some(ids) => ids.copy()
    None => []
  }
  ids.push(node_id)
  ids.sort_by((left, right) => compare_node_ids(left, right))
  ids.dedup()
  self.node_ids_by_label.set(label, ids)
}

///|
/// Remove a node from one label bucket and delete empty buckets.
fn GraphState::remove_node_label_index_entry(
  self : GraphState,
  label : String,
  node_id : NodeId,
) -> Unit {
  match self.node_ids_by_label.get(label) {
    Some(ids) => {
      let remaining : Array[NodeId] = []
      for existing_id in ids.iter() {
        if existing_id != node_id {
          remaining.push(existing_id)
        }
      }
      if remaining.length() == 0 {
        self.node_ids_by_label.remove(label)
      } else {
        self.node_ids_by_label.set(label, remaining)
      }
    }
    None => ()
  }
}

///|
#warnings("-unused_value")
fn GraphState::node_ids_with_label(
  self : GraphState,
  label : String,
) -> Array[NodeId] {
  match self.node_ids_by_label.get(label) {
    Some(ids) => ids.copy()
    None => []
  }
}

///|
/// Compare edge IDs by their wrapped unsigned numeric value.
fn compare_edge_ids(left : EdgeId, right : EdgeId) -> Int {
  match (left, right) {
    (EdgeId(left_raw), EdgeId(right_raw)) => left_raw.compare(right_raw)
  }
}

///|
/// Add an edge to one type bucket and keep its IDs unique and ordered.
fn GraphState::add_edge_type_index_entry(
  self : GraphState,
  edge_type : String,
  edge_id : EdgeId,
) -> Unit {
  let ids = match self.edge_ids_by_type.get(edge_type) {
    Some(ids) => ids.copy()
    None => []
  }
  ids.push(edge_id)
  ids.sort_by((left, right) => compare_edge_ids(left, right))
  ids.dedup()
  self.edge_ids_by_type.set(edge_type, ids)
}

///|
/// Remove an edge from one type bucket and delete empty buckets.
fn GraphState::remove_edge_type_index_entry(
  self : GraphState,
  edge_type : String,
  edge_id : EdgeId,
) -> Unit {
  match self.edge_ids_by_type.get(edge_type) {
    Some(ids) => {
      let remaining : Array[EdgeId] = []
      for existing_id in ids.iter() {
        if existing_id != edge_id {
          remaining.push(existing_id)
        }
      }
      if remaining.length() == 0 {
        self.edge_ids_by_type.remove(edge_type)
      } else {
        self.edge_ids_by_type.set(edge_type, remaining)
      }
    }
    None => ()
  }
}

///|
#warnings("-unused_value")
fn GraphState::edge_ids_with_type(
  self : GraphState,
  edge_type : String,
) -> Array[EdgeId] {
  match self.edge_ids_by_type.get(edge_type) {
    Some(ids) => ids.copy()
    None => []
  }
}

///|
/// Return the stored node or raise the structured missing-node error.
fn GraphState::require_node(
  self : GraphState,
  id : NodeId,
) -> Node raise DatabaseError {
  match self.nodes.get(id) {
    Some(node) => node
    None => raise DatabaseError::NodeNotFound(id)
  }
}

///|
/// Allocate an ID only after all caller data has been validated and copied.
#warnings("-unused_value")
fn GraphState::create_node(
  self : GraphState,
  labels : Array[String],
  properties : Properties,
) -> NodeId raise DatabaseError {
  let owned_labels = labels.copy()
  owned_labels.sort()
  owned_labels.dedup()
  let owned_properties = copy_validated_properties(properties)
  let id = match self.next_node_id {
    Some(id) => id
    None => raise DatabaseError::NodeIdExhausted
  }
  if id == 18446744073709551615UL {
    self.next_node_id = None
  } else {
    self.next_node_id = Some(id + 1UL)
  }
  let node_id = NodeId(id)
  self.nodes.set(
    node_id,
    Node::Node(id=node_id, labels=owned_labels, properties=owned_properties),
  )
  for label in owned_labels.iter() {
    self.add_node_label_index_entry(label, node_id)
    for key, value in owned_properties {
      self.add_node_property_posting(label, key, value, node_id)
    }
  }
  node_id
}

///|
/// Return a detached copy of a node without exposing graph-owned collections.
#warnings("-unused_value")
fn GraphState::get_node(
  self : GraphState,
  id : NodeId,
) -> Node raise DatabaseError {
  let node = self.require_node(id)
  Node::Node(
    id=node.id,
    labels=node.labels.copy(),
    properties=node.properties.copy(),
  )
}

///|
/// Add a node label. Re-adding an existing label is a no-op.
#warnings("-unused_value")
fn GraphState::add_label(
  self : GraphState,
  id : NodeId,
  label : String,
) -> Unit raise DatabaseError {
  let node = self.require_node(id)
  let labels = node.labels.copy()
  let has_label = labels.contains(label)
  labels.push(label)
  labels.sort()
  labels.dedup()
  self.nodes.set(
    id,
    Node::Node(id~, labels~, properties=node.properties.copy()),
  )
  self.add_node_label_index_entry(label, id)
  if !has_label {
    for key, value in node.properties {
      self.add_node_property_posting(label, key, value, id)
    }
  }
}

///|
/// Remove a node label. Removing an absent label is a no-op.
#warnings("-unused_value")
fn GraphState::remove_label(
  self : GraphState,
  id : NodeId,
  label : String,
) -> Unit raise DatabaseError {
  let node = self.require_node(id)
  let had_label = node.labels.contains(label)
  let labels : Array[String] = []
  for existing in node.labels.iter() {
    if existing != label {
      labels.push(existing)
    }
  }
  self.nodes.set(
    id,
    Node::Node(id~, labels~, properties=node.properties.copy()),
  )
  self.remove_node_label_index_entry(label, id)
  if had_label {
    for key, value in node.properties {
      self.remove_node_property_posting(label, key, value, id)
    }
  }
}

///|
/// Set or replace a node property after validating its scalar value.
#warnings("-unused_value")
fn GraphState::set_node_property(
  self : GraphState,
  id : NodeId,
  key : String,
  value : PropertyValue,
) -> Unit raise DatabaseError {
  let node = self.require_node(id)
  let normalized = normalize_property_value(key, value)
  let properties = node.properties.copy()
  properties.set(key, normalized)
  for label in node.labels.iter() {
    match node.properties.get(key) {
      Some(old_value) =>
        self.remove_node_property_posting(label, key, old_value, id)
      None => ()
    }
    self.add_node_property_posting(label, key, normalized, id)
  }
  self.nodes.set(id, Node::Node(id~, labels=node.labels.copy(), properties~))
}

///|
/// Remove a node property. Removing an absent property is a no-op.
#warnings("-unused_value")
fn GraphState::remove_node_property(
  self : GraphState,
  id : NodeId,
  key : String,
) -> Unit raise DatabaseError {
  let node = self.require_node(id)
  let properties = node.properties.copy()
  match node.properties.get(key) {
    Some(old_value) =>
      for label in node.labels.iter() {
        self.remove_node_property_posting(label, key, old_value, id)
      }
    None => ()
  }
  properties.remove(key)
  self.nodes.set(id, Node::Node(id~, labels=node.labels.copy(), properties~))
}

///|
/// Return the stored edge or raise the structured missing-edge error.
fn GraphState::require_edge(
  self : GraphState,
  id : EdgeId,
) -> Edge raise DatabaseError {
  match self.edges.get(id) {
    Some(edge) => edge
    None => raise DatabaseError::EdgeNotFound(id)
  }
}

///|
/// Create a directed edge after checking both endpoints and validating properties.
/// Its ID is appended to each endpoint's adjacency in increasing allocation order.
#warnings("-unused_value")
fn GraphState::create_edge(
  self : GraphState,
  from : NodeId,
  to : NodeId,
  edge_type : String,
  properties : Properties,
) -> EdgeId raise DatabaseError {
  match self.nodes.get(from) {
    Some(_) => ()
    None => raise DatabaseError::EndpointNodeNotFound(from)
  }
  match self.nodes.get(to) {
    Some(_) => ()
    None => raise DatabaseError::EndpointNodeNotFound(to)
  }
  let owned_properties = copy_validated_properties(properties)
  let id = match self.next_edge_id {
    Some(id) => id
    None => raise DatabaseError::EdgeIdExhausted
  }
  if id == 18446744073709551615UL {
    self.next_edge_id = None
  } else {
    self.next_edge_id = Some(id + 1UL)
  }
  let edge_id = EdgeId(id)
  self.edges.set(
    edge_id,
    Edge::Edge(id=edge_id, from~, to~, edge_type~, properties=owned_properties),
  )
  let outgoing = match self.outgoing_edge_ids.get(from) {
    Some(edge_ids) => edge_ids
    None => []
  }
  outgoing.push(edge_id)
  self.outgoing_edge_ids.set(from, outgoing)
  let incoming = match self.incoming_edge_ids.get(to) {
    Some(edge_ids) => edge_ids
    None => []
  }
  incoming.push(edge_id)
  self.incoming_edge_ids.set(to, incoming)
  self.add_edge_type_index_entry(edge_type, edge_id)
  for property, value in owned_properties {
    self.add_edge_property_posting(edge_type, property, value, edge_id)
  }
  edge_id
}

///|
/// Return a detached copy of an edge and its property map.
#warnings("-unused_value")
fn GraphState::get_edge(
  self : GraphState,
  id : EdgeId,
) -> Edge raise DatabaseError {
  let edge = self.require_edge(id)
  Edge::Edge(
    id=edge.id,
    from=edge.from,
    to=edge.to,
    edge_type=edge.edge_type,
    properties=edge.properties.copy(),
  )
}

///|
/// Return detached edges from one adjacency index in deterministic ID order.
fn GraphState::edges_from_index(
  self : GraphState,
  node_id : NodeId,
  index : Map[NodeId, Array[EdgeId]],
) -> Array[Edge] raise DatabaseError {
  match self.nodes.get(node_id) {
    Some(_) => ()
    None => raise DatabaseError::NodeNotFound(node_id)
  }
  let result : Array[Edge] = []
  match index.get(node_id) {
    Some(edge_ids) =>
      for edge_id in edge_ids.iter() {
        result.push(self.get_edge(edge_id))
      }
    None => ()
  }
  result
}

///|
/// Return a detached, deterministic list of edges leaving a node.
#warnings("-unused_value")
fn GraphState::outgoing_edges(
  self : GraphState,
  node_id : NodeId,
) -> Array[Edge] raise DatabaseError {
  self.edges_from_index(node_id, self.outgoing_edge_ids)
}

///|
/// Return a detached, deterministic list of edges entering a node.
#warnings("-unused_value")
fn GraphState::incoming_edges(
  self : GraphState,
  node_id : NodeId,
) -> Array[Edge] raise DatabaseError {
  self.edges_from_index(node_id, self.incoming_edge_ids)
}

///|
/// Remove an edge ID from one adjacency entry, deleting empty entries.
fn remove_edge_from_index(
  index : Map[NodeId, Array[EdgeId]],
  node_id : NodeId,
  edge_id : EdgeId,
) -> Unit {
  match index.get(node_id) {
    Some(current) => {
      let remaining : Array[EdgeId] = []
      for current_id in current.iter() {
        if current_id != edge_id {
          remaining.push(current_id)
        }
      }
      if remaining.length() == 0 {
        index.remove(node_id)
      } else {
        index.set(node_id, remaining)
      }
    }
    None => ()
  }
}

///|
/// Delete an edge from lookup and both adjacency directions.
#warnings("-unused_value")
fn GraphState::delete_edge(
  self : GraphState,
  id : EdgeId,
) -> Unit raise DatabaseError {
  let edge = self.require_edge(id)
  for property_key, value in edge.properties {
    self.remove_edge_property_posting(edge.edge_type, property_key, value, id)
  }
  self.remove_edge_type_index_entry(edge.edge_type, id)
  remove_edge_from_index(self.outgoing_edge_ids, edge.from, id)
  remove_edge_from_index(self.incoming_edge_ids, edge.to, id)
  self.edges.remove(id)
}

///|
/// Set or replace an edge property after validating its scalar value.
#warnings("-unused_value")
fn GraphState::set_edge_property(
  self : GraphState,
  id : EdgeId,
  key : String,
  value : PropertyValue,
) -> Unit raise DatabaseError {
  let edge = self.require_edge(id)
  let normalized = normalize_property_value(key, value)
  let properties = edge.properties.copy()
  match edge.properties.get(key) {
    Some(old_value) =>
      self.remove_edge_property_posting(edge.edge_type, key, old_value, id)
    None => ()
  }
  self.add_edge_property_posting(edge.edge_type, key, normalized, id)
  properties.set(key, normalized)
  self.edges.set(
    id,
    Edge::Edge(
      id=edge.id,
      from=edge.from,
      to=edge.to,
      edge_type=edge.edge_type,
      properties~,
    ),
  )
}

///|
/// Remove an edge property. Removing an absent property is a no-op.
#warnings("-unused_value")
fn GraphState::remove_edge_property(
  self : GraphState,
  id : EdgeId,
  key : String,
) -> Unit raise DatabaseError {
  let edge = self.require_edge(id)
  let properties = edge.properties.copy()
  match edge.properties.get(key) {
    Some(value) =>
      self.remove_edge_property_posting(edge.edge_type, key, value, id)
    None => ()
  }
  properties.remove(key)
  self.edges.set(
    id,
    Edge::Edge(
      id=edge.id,
      from=edge.from,
      to=edge.to,
      edge_type=edge.edge_type,
      properties~,
    ),
  )
}

///|
/// Collect a node's incoming and outgoing edges without counting self-loops twice.
fn GraphState::incident_edge_ids(
  self : GraphState,
  node_id : NodeId,
) -> Array[EdgeId] raise DatabaseError {
  match self.nodes.get(node_id) {
    Some(_) => ()
    None => raise DatabaseError::NodeNotFound(node_id)
  }
  let seen : Map[EdgeId, Unit] = Map([])
  let incident : Array[EdgeId] = []
  match self.incoming_edge_ids.get(node_id) {
    Some(edge_ids) =>
      for edge_id in edge_ids.iter() {
        match seen.get(edge_id) {
          Some(_) => ()
          None => {
            seen.set(edge_id, ())
            incident.push(edge_id)
          }
        }
      }
    None => ()
  }
  match self.outgoing_edge_ids.get(node_id) {
    Some(edge_ids) =>
      for edge_id in edge_ids.iter() {
        match seen.get(edge_id) {
          Some(_) => ()
          None => {
            seen.set(edge_id, ())
            incident.push(edge_id)
          }
        }
      }
    None => ()
  }
  incident
}

///|
/// Delete an unconnected node, rejecting the operation while any edge is incident.
#warnings("-unused_value")
fn GraphState::delete_node(
  self : GraphState,
  id : NodeId,
) -> Unit raise DatabaseError {
  let node = self.require_node(id)
  let incident = self.incident_edge_ids(id)
  if incident.length() > 0 {
    raise DatabaseError::NodeHasIncidentEdges(id)
  }
  for label in node.labels.iter() {
    self.remove_node_label_index_entry(label, id)
    for key, value in node.properties {
      self.remove_node_property_posting(label, key, value, id)
    }
  }
  self.nodes.remove(id)
  self.outgoing_edge_ids.remove(id)
  self.incoming_edge_ids.remove(id)
}

///|
/// Delete a node and explicitly cascade to each unique incoming/outgoing edge.
/// All referenced edges are checked before mutation so expected failures are atomic.
#warnings("-unused_value")
fn GraphState::delete_node_cascade(
  self : GraphState,
  id : NodeId,
) -> Unit raise DatabaseError {
  let node = self.require_node(id)
  let incident = self.incident_edge_ids(id)
  for edge_id in incident.iter() {
    match self.edges.get(edge_id) {
      Some(_) => ()
      None => raise DatabaseError::EdgeNotFound(edge_id)
    }
  }
  for edge_id in incident.iter() {
    self.delete_edge(edge_id)
  }
  for label in node.labels.iter() {
    self.remove_node_label_index_entry(label, id)
    for key, value in node.properties {
      self.remove_node_property_posting(label, key, value, id)
    }
  }
  self.nodes.remove(id)
  self.outgoing_edge_ids.remove(id)
  self.incoming_edge_ids.remove(id)
}