use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use async_trait::async_trait;
use boltr::error::BoltError;
use boltr::server::{
AuthInfo, BoltBackend, BoltRecord, ResultMetadata, ResultStream, RoutingTable, SessionConfig,
SessionHandle, SessionProperty, TransactionHandle,
};
use boltr::types::{BoltDict, BoltValue};
use kglite::api::session::CsvImportPolicy;
use kglite::api::{cypher, Value};
use crate::error_map::kg_to_bolt;
use crate::value_adapter;
const NEO4J_COMPAT_VERSION: &str = "5.26.0";
const AGENT_GATED_DRIVER_MARKERS: &[&str] = &["neo4j-java/"];
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ServerIdentity {
#[default]
Kglite,
Neo4jCompatible,
}
impl ServerIdentity {
pub(crate) fn product_string(self, version: &str) -> String {
match self {
Self::Kglite => format!("kglite-bolt-server/{version}"),
Self::Neo4jCompatible => {
format!("Neo4j/{NEO4J_COMPAT_VERSION} (kglite-bolt-server/{version})")
}
}
}
fn is_rejected_by_agent_gate(self) -> bool {
matches!(self, Self::Kglite)
}
pub(crate) fn components_row(self, version: &str) -> (String, String) {
match self {
Self::Kglite => ("kglite-bolt-server".to_string(), version.to_string()),
Self::Neo4jCompatible => ("Neo4j Kernel".to_string(), NEO4J_COMPAT_VERSION.to_string()),
}
}
}
const COMPONENTS_EDITION: &str = "community";
mod intercepts;
use intercepts::{
checkpoint_stream, parse_checkpoint_call, parse_server_facts_call, plan_from_explain_rows,
server_facts_stream, CheckpointCall, ServerFactsCall, ServerFactsVerb,
};
pub struct KgliteBackend {
session: Arc<kglite::api::session::Session>,
graph_path: std::path::PathBuf,
readonly: bool,
last_checkpoint_version: CheckpointState,
transactions: Arc<Mutex<HashMap<String, Arc<Mutex<TxState>>>>>,
session_counter: AtomicU64,
tx_counter: AtomicU64,
advertised_addr: String,
csv_import: CsvImportPolicy,
identity: ServerIdentity,
auth_user: Option<String>,
}
struct TxState {
inner: Option<kglite::api::session::Transaction>,
session_id: String,
meta: TxMeta,
}
#[derive(Clone, Debug, Default)]
struct TxMeta {
write_scope: Option<HashSet<String>>,
git_sha: Option<String>,
modified_by: Option<String>,
}
impl TxMeta {
fn from_extra(extra: &BoltDict) -> Result<Self, BoltError> {
let nested = match extra.get("tx_metadata") {
Some(BoltValue::Dict(d)) => Some(d),
None | Some(BoltValue::Null) => None,
Some(other) => {
return Err(BoltError::Protocol(format!(
"tx_metadata must be a map, got {other:?}"
)))
}
};
let lookup = |key: &str| nested.and_then(|d| d.get(key)).or_else(|| extra.get(key));
let string_field = |key: &str| -> Result<Option<String>, BoltError> {
match lookup(key) {
None | Some(BoltValue::Null) => Ok(None),
Some(BoltValue::String(s)) => Ok(Some(s.clone())),
Some(other) => Err(BoltError::Protocol(format!(
"tx metadata key {key:?} must be a string, got {other:?}"
))),
}
};
let write_scope = match lookup("write_scope") {
None | Some(BoltValue::Null) => None,
Some(BoltValue::List(items)) => {
let mut scope = HashSet::with_capacity(items.len());
for item in items {
let BoltValue::String(s) = item else {
return Err(BoltError::Protocol(format!(
"tx metadata key \"write_scope\" must be a list of \
strings, got element {item:?}"
)));
};
scope.insert(s.clone());
}
Some(scope)
}
Some(other) => {
return Err(BoltError::Protocol(format!(
"tx metadata key \"write_scope\" must be a list of strings, \
got {other:?}"
)))
}
};
Ok(Self {
write_scope,
git_sha: string_field("git_sha")?,
modified_by: string_field("modified_by")?,
})
}
}
fn reject_unsupported_tx_timeout(extra: &BoltDict) -> Result<(), BoltError> {
match extra.get("tx_timeout") {
None | Some(BoltValue::Null) | Some(BoltValue::Integer(0)) => Ok(()),
Some(BoltValue::Integer(value)) => Err(BoltError::Protocol(format!(
"tx_timeout={value} is not supported; omit it or send 0 for no timeout"
))),
Some(other) => Err(BoltError::Protocol(format!(
"tx_timeout must be integer milliseconds, null, or absent, got {other:?}"
))),
}
}
impl KgliteBackend {
pub fn new(
session: kglite::api::session::Session,
graph_path: std::path::PathBuf,
readonly: bool,
advertised_addr: String,
csv_import: CsvImportPolicy,
identity: ServerIdentity,
auth_user: Option<String>,
) -> Self {
Self {
session: Arc::new(session),
graph_path,
readonly,
last_checkpoint_version: Arc::new(Mutex::new(None)),
transactions: Arc::new(Mutex::new(HashMap::new())),
session_counter: AtomicU64::new(0),
tx_counter: AtomicU64::new(0),
advertised_addr,
csv_import,
identity,
auth_user,
}
}
pub(crate) fn session_handle(&self) -> Arc<kglite::api::session::Session> {
Arc::clone(&self.session)
}
pub(crate) fn graph_path(&self) -> &std::path::Path {
&self.graph_path
}
pub(crate) fn checkpoint_state(&self) -> CheckpointState {
Arc::clone(&self.last_checkpoint_version)
}
fn warn_if_driver_gates_on_agent(&self, user_agent: &str) {
if !self.identity.is_rejected_by_agent_gate() {
return;
}
let lowered = user_agent.to_ascii_lowercase();
if !AGENT_GATED_DRIVER_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
{
return;
}
tracing::warn!(
user_agent = %user_agent,
server_agent = %self.identity.product_string(env!("CARGO_PKG_VERSION")),
"this client's driver rejects any server whose agent does not start with \
`Neo4j/` and will fail with UntrustedServerException before running a query. \
Enable Neo4j compatibility mode to serve it: pass --neo4j-compat, or set \
KGLITE_BOLT_NEO4J_COMPAT=1 in the environment. The identity is deliberately \
NOT switched automatically — honest identification is the default."
);
}
}
#[async_trait]
impl BoltBackend for KgliteBackend {
async fn create_session(&self, config: &SessionConfig) -> Result<SessionHandle, BoltError> {
let id = self.session_counter.fetch_add(1, Ordering::Relaxed);
let handle = SessionHandle(format!("bolt-{id}"));
tracing::debug!(
session_id = %handle.0,
user_agent = %config.user_agent,
database = ?config.database,
"create_session"
);
self.warn_if_driver_gates_on_agent(&config.user_agent);
Ok(handle)
}
async fn set_session_auth(
&self,
session: &SessionHandle,
auth_info: AuthInfo,
) -> Result<(), BoltError> {
tracing::debug!(
session_id = %session.0,
principal = %auth_info.principal,
"set_session_auth (principal validated at LOGON; not stored — no per-session principal model)"
);
Ok(())
}
async fn close_session(&self, session: &SessionHandle) -> Result<(), BoltError> {
let to_drop: Vec<String> = {
let txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.iter()
.filter_map(|(handle, state_arc)| {
let state = state_arc.lock().unwrap_or_else(|p| p.into_inner());
(state.session_id == session.0).then(|| handle.clone())
})
.collect()
};
{
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
for handle in &to_drop {
txs.remove(handle);
tracing::debug!(
session_id = %session.0,
tx = %handle,
"rolled back in-flight transaction on session close"
);
}
}
tracing::debug!(
session_id = %session.0,
rolled_back = to_drop.len(),
"close_session"
);
Ok(())
}
async fn configure_session(
&self,
session: &SessionHandle,
property: SessionProperty,
) -> Result<(), BoltError> {
match property {
SessionProperty::Database(db) => {
tracing::debug!(
session_id = %session.0,
database = %db,
"configure_session: database property accepted but ignored (single-graph server)"
);
}
}
Ok(())
}
async fn reset_session(&self, session: &SessionHandle) -> Result<(), BoltError> {
let to_drop: Vec<String> = {
let txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.iter()
.filter_map(|(handle, state_arc)| {
let state = state_arc.lock().unwrap_or_else(|p| p.into_inner());
(state.session_id == session.0).then(|| handle.clone())
})
.collect()
};
{
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
for handle in &to_drop {
txs.remove(handle);
}
}
tracing::debug!(
session_id = %session.0,
rolled_back = to_drop.len(),
"reset_session"
);
Ok(())
}
async fn execute(
&self,
_session: &SessionHandle,
query: &str,
parameters: &HashMap<String, BoltValue>,
extra: &BoltDict,
transaction: Option<&TransactionHandle>,
) -> Result<ResultStream, BoltError> {
let trimmed = query.trim();
tracing::debug!(query = %trimmed, in_tx = transaction.is_some(), "execute");
if trimmed.is_empty() {
return Err(BoltError::Protocol(
"empty Cypher query — RUN requires a non-empty statement".into(),
));
}
if _query_appears_multi_statement(trimmed) {
return Err(BoltError::Protocol(
"multi-statement queries not supported — send one Cypher \
statement per RUN message (or open a transaction and \
issue separate RUNs)"
.into(),
));
}
reject_unsupported_tx_timeout(extra)?;
if let Some(call) = parse_checkpoint_call(trimmed) {
return self.run_checkpoint(&call, transaction.is_some());
}
if let Some(call) = parse_server_facts_call(trimmed) {
return Ok(self.run_server_facts(&call));
}
let kg_params: HashMap<String, Value> = parameters
.iter()
.map(|(k, v)| value_adapter::from_bolt(v).map(|kv| (k.clone(), kv)))
.collect::<Result<HashMap<_, _>, _>>()?;
let elapsed_start = Instant::now();
let (result, type_str, explain) = if let Some(handle) = transaction.map(|t| t.0.clone()) {
self.execute_in_tx(&handle, query, kg_params)?
} else {
let meta = TxMeta::from_extra(extra)?;
self.execute_auto_commit(query, kg_params, &meta)?
};
let elapsed_ms = elapsed_start.elapsed().as_millis() as i64;
let mut records: Vec<BoltRecord> = result
.rows
.iter()
.map(|row| {
row.iter()
.map(value_adapter::to_bolt)
.collect::<Result<Vec<_>, _>>()
.map(|values| BoltRecord { values })
})
.collect::<Result<Vec<_>, _>>()?;
let mut summary = BoltDict::from([
("type".to_string(), BoltValue::String(type_str.to_string())),
("t_last".to_string(), BoltValue::Integer(elapsed_ms)),
]);
if let Some(d) = &result.diagnostics {
if !d.retrieval.is_empty() {
summary.insert("kglite.retrieval".into(), retrieval_metadata(&d.retrieval));
}
if let Some(temporal) = &d.temporal {
summary.insert("kglite.temporal".into(), temporal_metadata(temporal));
}
}
let mut columns = result.columns;
if explain {
if let Some(plan) = plan_from_explain_rows(&columns, &result.rows) {
summary.insert("plan".to_string(), plan);
records.clear();
columns = Vec::new();
}
}
if let Some(stats) = &result.stats {
let stats_dict = BoltDict::from([
(
"nodes-created".to_string(),
BoltValue::Integer(stats.nodes_created as i64),
),
(
"nodes-deleted".to_string(),
BoltValue::Integer(stats.nodes_deleted as i64),
),
(
"relationships-created".to_string(),
BoltValue::Integer(stats.relationships_created as i64),
),
(
"relationships-deleted".to_string(),
BoltValue::Integer(stats.relationships_deleted as i64),
),
(
"properties-set".to_string(),
BoltValue::Integer(stats.properties_set as i64),
),
]);
summary.insert("stats".to_string(), BoltValue::Dict(stats_dict));
}
Ok(ResultStream {
metadata: ResultMetadata {
columns,
extra: BoltDict::new(),
},
records,
summary,
})
}
async fn begin_transaction(
&self,
session: &SessionHandle,
extra: &BoltDict,
) -> Result<TransactionHandle, BoltError> {
reject_unsupported_tx_timeout(extra)?;
if self.readonly {
return Err(BoltError::Forbidden(
"server is read-only — explicit transactions rejected (--readonly flag)".into(),
));
}
let meta = TxMeta::from_extra(extra)?;
let id = self.tx_counter.fetch_add(1, Ordering::Relaxed);
let handle = TransactionHandle(format!("tx-{id}"));
let state = TxState {
inner: Some(self.session.begin()),
session_id: session.0.clone(),
meta,
};
{
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.insert(handle.0.clone(), Arc::new(Mutex::new(state)));
}
tracing::debug!(
session_id = %session.0,
tx = %handle.0,
"begin_transaction"
);
Ok(handle)
}
async fn commit(
&self,
session: &SessionHandle,
transaction: &TransactionHandle,
) -> Result<BoltDict, BoltError> {
let state_arc = {
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.remove(&transaction.0).ok_or_else(|| {
BoltError::Transaction(format!(
"commit: unknown transaction handle: {}",
transaction.0
))
})?
};
let mut state = match Arc::try_unwrap(state_arc) {
Ok(mutex) => mutex.into_inner().unwrap_or_else(|p| p.into_inner()),
Err(arc) => {
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.insert(transaction.0.clone(), arc);
return Err(BoltError::Transaction(format!(
"commit: transaction {} has a query in flight — cannot \
COMMIT while a RUN is executing on this transaction; \
retry after it completes",
transaction.0
)));
}
};
if state.session_id != session.0 {
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.insert(transaction.0.clone(), Arc::new(Mutex::new(state)));
return Err(BoltError::Transaction(format!(
"commit: transaction {} doesn't belong to session {}",
transaction.0, session.0
)));
}
let Some(tx) = state.inner.take() else {
return Ok(BoltDict::new());
};
match self.session.commit(tx, true) {
kglite::api::session::CommitOutcome::NoWritesNoOp => {
tracing::debug!(
session_id = %session.0,
tx = %transaction.0,
"commit (no-op; no mutations)"
);
}
kglite::api::session::CommitOutcome::Committed { new_version } => {
tracing::debug!(
session_id = %session.0,
tx = %transaction.0,
new_version,
"commit (with mutations)"
);
}
kglite::api::session::CommitOutcome::DurabilityFailed { ref error } => {
tracing::error!(
session_id = %session.0,
tx = %transaction.0,
error = %error,
"commit rejected: the write could not be logged, so it was not applied"
);
return Err(BoltError::Backend(format!(
"commit was NOT applied — the write-ahead log rejected it and the \
server does not acknowledge writes it cannot log: {error}"
)));
}
kglite::api::session::CommitOutcome::ConflictDetected {
current_version,
base_version,
} => {
tracing::debug!(
session_id = %session.0,
tx = %transaction.0,
current_version,
base_version,
"commit conflict — another writer committed first"
);
return Err(BoltError::Query {
code: kglite::api::KgErrorCode::TransactionConflict
.neo4j_status_code()
.into(),
message: format!(
"Transaction conflict: graph was modified by another committer \
since this transaction's BEGIN (base version {base_version}, \
current version {current_version}). Retry the transaction."
),
});
}
ref other => {
tracing::error!(
session_id = %session.0,
tx = %transaction.0,
outcome = ?other,
"commit returned an outcome this build does not recognise"
);
return Err(BoltError::Backend(
"commit returned an unrecognised outcome; the transaction was not applied"
.to_string(),
));
}
}
Ok(BoltDict::new())
}
async fn rollback(
&self,
session: &SessionHandle,
transaction: &TransactionHandle,
) -> Result<(), BoltError> {
let state_arc = {
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.remove(&transaction.0).ok_or_else(|| {
BoltError::Transaction(format!(
"rollback: unknown transaction handle: {}",
transaction.0
))
})?
};
let (session_id, had_mutations) = {
let state = state_arc.lock().unwrap_or_else(|p| p.into_inner());
(
state.session_id.clone(),
state.inner.as_ref().is_some_and(|t| t.has_writes()),
)
};
if session_id != session.0 {
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.insert(transaction.0.clone(), state_arc);
return Err(BoltError::Transaction(format!(
"rollback: transaction {} doesn't belong to session {}",
transaction.0, session.0
)));
}
match Arc::try_unwrap(state_arc) {
Ok(mutex) => {
let mut state = mutex.into_inner().unwrap_or_else(|p| p.into_inner());
if let Some(tx) = state.inner.take() {
self.session.rollback(tx);
}
}
Err(arc) => {
let mut txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.insert(transaction.0.clone(), arc);
return Err(BoltError::Transaction(format!(
"rollback: transaction {} has a query in flight — cannot \
ROLLBACK while a RUN is executing on this transaction; \
retry after it completes",
transaction.0
)));
}
}
tracing::debug!(
session_id = %session.0,
tx = %transaction.0,
had_mutations = had_mutations,
"rollback"
);
Ok(())
}
async fn get_server_info(&self) -> Result<BoltDict, BoltError> {
let version = env!("CARGO_PKG_VERSION");
let product = self.identity.product_string(version);
let bolt_agent = BoltDict::from([
(
"product".to_string(),
BoltValue::String(format!("kglite-bolt-server/{version}")),
),
(
"version".to_string(),
BoltValue::String(version.to_string()),
),
]);
let info = BoltDict::from([
("server".to_string(), BoltValue::String(product)),
("bolt_agent".to_string(), BoltValue::Dict(bolt_agent)),
]);
Ok(info)
}
async fn route(
&self,
_routing_context: &BoltDict,
_bookmarks: &[String],
db: Option<&str>,
) -> Result<RoutingTable, BoltError> {
let db_name = db.unwrap_or("neo4j").to_string();
let ttl = 300;
let single_server = boltr::server::RoutingServer {
addresses: vec![self.advertised_addr.clone()],
role: String::new(), };
let mut servers = Vec::with_capacity(3);
for role in ["WRITE", "READ", "ROUTE"] {
servers.push(boltr::server::RoutingServer {
addresses: single_server.addresses.clone(),
role: role.to_string(),
});
}
Ok(RoutingTable {
ttl,
db: db_name,
servers,
})
}
}
fn _query_appears_multi_statement(query: &str) -> bool {
let mut in_quote: Option<char> = None;
let mut chars = query.chars().peekable();
while let Some(c) = chars.next() {
match (c, in_quote) {
('\\', Some(_)) => {
let _ = chars.next();
}
('\'', None) => in_quote = Some('\''),
('"', None) => in_quote = Some('"'),
(c, Some(q)) if c == q => in_quote = None,
(';', None) => {
let rest: String = chars.collect();
if !rest.trim().is_empty() {
return true;
}
return false;
}
_ => {}
}
}
false
}
pub(crate) type CheckpointState = Arc<Mutex<Option<u64>>>;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CheckpointOutcome {
Written(u64),
Skipped(u64),
}
pub(crate) fn checkpoint_if_changed(
session: &kglite::api::session::Session,
path: &std::path::Path,
last: &Mutex<Option<u64>>,
) -> Result<CheckpointOutcome, String> {
let mut last = last.lock().unwrap_or_else(|p| p.into_inner());
let version = session.version();
if *last == Some(version) {
return Ok(CheckpointOutcome::Skipped(version));
}
session.save(&path.to_string_lossy(), true)?;
*last = Some(version);
Ok(CheckpointOutcome::Written(version))
}
impl KgliteBackend {
fn run_server_facts(&self, call: &ServerFactsCall) -> ResultStream {
let started = Instant::now();
match call.verb {
ServerFactsVerb::DbmsComponents => {
let (name, version) = self.identity.components_row(env!("CARGO_PKG_VERSION"));
server_facts_stream(
&call.columns,
&[
("name", BoltValue::String(name)),
(
"versions",
BoltValue::List(vec![BoltValue::String(version)]),
),
("edition", BoltValue::String(COMPONENTS_EDITION.to_string())),
],
started,
)
}
ServerFactsVerb::DbmsShowCurrentUser => {
let username = self
.auth_user
.clone()
.unwrap_or_else(|| "neo4j".to_string());
server_facts_stream(
&call.columns,
&[
("username", BoltValue::String(username)),
("roles", BoltValue::List(Vec::new())),
("flags", BoltValue::List(Vec::new())),
],
started,
)
}
ServerFactsVerb::ShowDatabases => {
let access = if self.readonly {
"read-only"
} else {
"read-write"
};
server_facts_stream(
&call.columns,
&[
("name", BoltValue::String("neo4j".to_string())),
("type", BoltValue::String("standard".to_string())),
("aliases", BoltValue::List(Vec::new())),
("access", BoltValue::String(access.to_string())),
("address", BoltValue::String(self.advertised_addr.clone())),
("role", BoltValue::String("primary".to_string())),
("writer", BoltValue::Boolean(!self.readonly)),
("requestedStatus", BoltValue::String("online".to_string())),
("currentStatus", BoltValue::String("online".to_string())),
("statusMessage", BoltValue::String(String::new())),
("default", BoltValue::Boolean(true)),
("home", BoltValue::Boolean(true)),
("constituents", BoltValue::List(Vec::new())),
],
started,
)
}
}
}
fn execute_opts<'a>(
&self,
kg_params: &'a HashMap<String, Value>,
meta: &'a TxMeta,
) -> kglite::api::session::ExecuteOptions<'a> {
let mut opts = kglite::api::session::ExecuteOptions::eager(kg_params);
opts.streaming = true;
opts.write_scope = meta.write_scope.as_ref();
opts.git_sha = meta.git_sha.as_deref();
opts.modified_by = meta.modified_by.as_deref();
opts.csv_import = self.csv_import.clone();
opts
}
fn run_checkpoint(
&self,
call: &CheckpointCall,
in_transaction: bool,
) -> Result<ResultStream, BoltError> {
let started = Instant::now();
if in_transaction {
return Err(BoltError::Protocol(
"db.checkpoint() cannot run inside an explicit transaction — it \
writes the committed graph, which does not include this \
transaction's uncommitted writes; COMMIT first, then call it \
in auto-commit"
.into(),
));
}
if self.readonly {
return Err(BoltError::Forbidden(
"server is read-only — db.checkpoint() rejected (--readonly flag)".into(),
));
}
let mode = kglite::api::storage::live_storage_mode(&self.session.snapshot());
if mode == kglite::api::storage::StorageMode::Disk {
return Err(BoltError::Forbidden(
"db.checkpoint() is not supported for disk-mode graphs: every disk \
save publishes a new on-disk generation and nothing prunes the old \
ones, so repeated checkpoints grow the directory without bound"
.into(),
));
}
let outcome = checkpoint_if_changed(
&self.session,
&self.graph_path,
&self.last_checkpoint_version,
)
.map_err(|e| {
BoltError::Backend(format!(
"db.checkpoint() failed writing {}: {e}",
self.graph_path.display()
))
})?;
match outcome {
CheckpointOutcome::Skipped(version) => {
tracing::debug!(
graph_version = version,
"db.checkpoint(): skipped (graph unchanged since the last checkpoint)"
);
Ok(checkpoint_stream(
call,
format!("skipped: graph unchanged since version {version}"),
"r",
started,
))
}
CheckpointOutcome::Written(version) => {
tracing::info!(
path = %self.graph_path.display(),
graph_version = version,
"db.checkpoint(): graph written"
);
Ok(checkpoint_stream(
call,
format!("checkpoint written: version {version}"),
"w",
started,
))
}
}
}
fn execute_auto_commit(
&self,
query: &str,
kg_params: HashMap<String, Value>,
meta: &TxMeta,
) -> Result<(cypher::CypherResult, &'static str, bool), BoltError> {
let (_, is_mutation) = cypher::parse_with_mutation_check(query).map_err(kg_to_bolt)?;
if is_mutation {
if self.readonly {
return Err(BoltError::Forbidden(
"server is read-only — mutations rejected (--readonly flag)".into(),
));
}
return Err(BoltError::Session(
"auto-commit mutations not supported by kglite-bolt-server — \
wrap CREATE/SET/DELETE in an explicit transaction \
(session.begin_transaction)"
.into(),
));
}
let snapshot = self.session.snapshot();
let opts = self.execute_opts(&kg_params, meta);
let outcome =
kglite::api::session::execute_read(&snapshot, query, &opts).map_err(kg_to_bolt)?;
Ok((outcome.result, "r", outcome.explain))
}
fn execute_in_tx(
&self,
handle: &str,
query: &str,
kg_params: HashMap<String, Value>,
) -> Result<(cypher::CypherResult, &'static str, bool), BoltError> {
let state_arc: Arc<Mutex<TxState>> = {
let txs = self.transactions.lock().unwrap_or_else(|p| p.into_inner());
txs.get(handle)
.ok_or_else(|| {
BoltError::Transaction(format!("unknown transaction handle: {handle}"))
})
.map(Arc::clone)?
};
let mut state = state_arc.lock().unwrap_or_else(|p| p.into_inner());
let meta = state.meta.clone();
let tx_inner = state.inner.as_mut().ok_or_else(|| {
BoltError::Transaction(format!("tx {handle} already committed or rolled back"))
})?;
let (_, is_mutation) = cypher::parse_with_mutation_check(query).map_err(kg_to_bolt)?;
if is_mutation && self.readonly {
return Err(BoltError::Forbidden(
"server is read-only — mutations rejected (--readonly flag)".into(),
));
}
let opts = self.execute_opts(&kg_params, &meta);
if is_mutation {
let working = tx_inner.working_mut().map_err(kg_to_bolt)?;
let outcome =
kglite::api::session::execute_mut(working, query, &opts).map_err(kg_to_bolt)?;
Ok((outcome.result, "w", outcome.explain))
} else {
let graph = tx_inner.current().ok_or_else(|| {
BoltError::Backend(format!(
"tx {handle} lost its graph view mid-read — bolt-server internal bug"
))
})?;
let outcome =
kglite::api::session::execute_read(graph, query, &opts).map_err(kg_to_bolt)?;
Ok((outcome.result, "r", outcome.explain))
}
}
}
fn temporal_metadata(echo: &kglite::api::cypher::TemporalDiagnostics) -> BoltValue {
let text = |value: &str| BoltValue::String(value.to_string());
BoltValue::Dict(BoltDict::from([
("axis".into(), text(&echo.axis)),
("instant".into(), text(&echo.instant)),
(
"targets".into(),
BoltValue::List(echo.targets.iter().map(|t| text(t)).collect()),
),
("route".into(), text(&echo.route)),
(
"retrieval".into(),
echo.retrieval.as_deref().map_or(BoltValue::Null, text),
),
("slice".into(), BoltValue::Boolean(echo.slice)),
(
"session_version".into(),
BoltValue::Integer(echo.session_version as i64),
),
]))
}
fn retrieval_metadata(records: &[kglite::api::cypher::RetrievalDiagnostics]) -> BoltValue {
BoltValue::List(
records
.iter()
.map(|r| {
BoltValue::Dict(BoltDict::from([
(
"requested_policy".into(),
BoltValue::String(r.requested_policy.clone()),
),
(
"actual_mode".into(),
BoltValue::String(r.actual_mode.clone()),
),
(
"fallback_reason".into(),
r.fallback_reason
.clone()
.map(BoltValue::String)
.unwrap_or(BoltValue::Null),
),
(
"store".into(),
r.store
.clone()
.map(BoltValue::String)
.unwrap_or(BoltValue::Null),
),
]))
})
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::intercepts::SHOW_DATABASES_COLUMNS;
use super::*;
use kglite::api::storage::{new_dir_graph_in_mode, StorageMode};
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_disk_path() -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"kglite-bolt-disk-tx-{}-{nonce}",
std::process::id()
))
}
async fn mutate_and_finish(
backend: &KgliteBackend,
session: &SessionHandle,
query: &str,
commit: bool,
) {
let tx = backend
.begin_transaction(session, &BoltDict::new())
.await
.expect("begin disk transaction");
backend
.execute_in_tx(&tx.0, query, HashMap::new())
.expect("execute disk transaction mutation");
if commit {
backend
.commit(session, &tx)
.await
.expect("commit disk transaction");
} else {
backend
.rollback(session, &tx)
.await
.expect("rollback disk transaction");
}
}
#[tokio::test]
async fn retrieval_diagnostics_reach_bolt_summary() {
let mut graph = new_dir_graph_in_mode(StorageMode::Memory, None).unwrap();
let params = HashMap::new();
kglite::api::session::execute_mut(
&mut graph,
"CREATE (:Doc {id:1, body:'a'}), (:Doc {id:2, body:'b'})",
&kglite::api::session::ExecuteOptions::eager(¶ms),
)
.unwrap();
kglite::api::embeddings::set_embeddings(
&mut graph,
"Doc",
"body",
None,
vec![
(Value::Int64(1), vec![1., 0.]),
(Value::Int64(2), vec![0., 1.]),
],
)
.unwrap();
let backend = KgliteBackend::new(
kglite::api::session::Session::new(graph),
unique_disk_path().join("memory.kgl"),
false,
"127.0.0.1:0".into(),
CsvImportPolicy::Denied,
ServerIdentity::default(),
None,
);
let handle = SessionHandle("retrieval".into());
let query = "MATCH (d:Doc) RETURN vector_score(d,'body_emb',[1.0,0.0],{exact:true}) AS s ORDER BY s DESC LIMIT 1";
let result = backend
.execute(&handle, query, &HashMap::new(), &BoltDict::new(), None)
.await
.unwrap();
let records = &result.summary["kglite.retrieval"];
let BoltValue::List(records) = records else {
panic!("expected retrieval list")
};
assert_eq!(records.len(), 1);
let BoltValue::Dict(record) = &records[0] else {
panic!("expected retrieval object")
};
assert_eq!(record["actual_mode"], BoltValue::String("exact".into()));
assert_eq!(
record["requested_policy"],
BoltValue::String("exact".into())
);
assert_eq!(
record["fallback_reason"],
BoltValue::String("forced_exact".into())
);
assert_eq!(record["store"], BoltValue::Null);
let clean = backend
.execute(
&handle,
"RETURN 1 AS n",
&HashMap::new(),
&BoltDict::new(),
None,
)
.await
.unwrap();
assert!(!clean.summary.contains_key("kglite.retrieval"));
}
#[tokio::test]
async fn disk_transactions_reuse_writer_lineage_after_prior_commit() {
let path = unique_disk_path();
let graph = new_dir_graph_in_mode(StorageMode::Disk, Some(&path))
.expect("create disk-backed graph");
let backend = KgliteBackend::new(
kglite::api::session::Session::new(graph),
path.clone(),
false,
"127.0.0.1:0".into(),
CsvImportPolicy::Denied,
ServerIdentity::default(),
None,
);
let session = SessionHandle("disk-session".into());
mutate_and_finish(&backend, &session, "CREATE (:Person {id: 1})", true).await;
mutate_and_finish(&backend, &session, "CREATE (:Person {id: 2})", true).await;
mutate_and_finish(&backend, &session, "CREATE (:Person {id: 3})", false).await;
assert_eq!(count_nodes(&backend, "Person"), 2);
drop(backend);
std::fs::remove_dir_all(path).expect("remove disk transaction fixture");
}
fn count_nodes(backend: &KgliteBackend, node_type: &str) -> i64 {
let snapshot = backend.session.snapshot();
let params = HashMap::new();
let meta = TxMeta::default();
let opts = backend.execute_opts(¶ms, &meta);
let result = kglite::api::session::execute_read(
&snapshot,
&format!("MATCH (n:{node_type}) RETURN count(n) AS count"),
&opts,
)
.expect("count query")
.result;
match result.rows.first().and_then(|r| r.first()) {
Some(Value::Int64(n)) => *n,
other => panic!("expected Int64 count, got {other:?}"),
}
}
fn memory_backend() -> KgliteBackend {
memory_backend_at(unique_disk_path().join("memory.kgl"), false)
}
fn memory_backend_at(path: std::path::PathBuf, readonly: bool) -> KgliteBackend {
let graph = new_dir_graph_in_mode(StorageMode::Memory, None).expect("create memory graph");
KgliteBackend::new(
kglite::api::session::Session::new(graph),
path,
readonly,
"127.0.0.1:0".into(),
CsvImportPolicy::Denied,
ServerIdentity::default(),
None,
)
}
#[tokio::test]
async fn commit_with_query_in_flight_errors_instead_of_dropping_writes() {
let backend = memory_backend();
let session = SessionHandle("s".into());
let tx = backend
.begin_transaction(&session, &BoltDict::new())
.await
.expect("begin");
backend
.execute_in_tx(&tx.0, "CREATE (:Person {id: 1})", HashMap::new())
.expect("tx mutation");
let in_flight = {
let txs = backend.transactions.lock().unwrap();
Arc::clone(txs.get(&tx.0).expect("tx registered"))
};
let err = backend
.commit(&session, &tx)
.await
.expect_err("COMMIT with a query in flight must fail, not silently drop the tx");
assert!(
matches!(&err, BoltError::Transaction(msg) if msg.contains("in flight")),
"unexpected error: {err:?}"
);
assert_eq!(
count_nodes(&backend, "Person"),
0,
"failed COMMIT must not have committed anything"
);
drop(in_flight);
backend
.commit(&session, &tx)
.await
.expect("retry COMMIT after the in-flight query completes");
assert_eq!(count_nodes(&backend, "Person"), 1);
}
#[tokio::test]
async fn rollback_with_query_in_flight_errors_and_keeps_tx() {
let backend = memory_backend();
let session = SessionHandle("s".into());
let tx = backend
.begin_transaction(&session, &BoltDict::new())
.await
.expect("begin");
backend
.execute_in_tx(&tx.0, "CREATE (:Person {id: 1})", HashMap::new())
.expect("tx mutation");
let in_flight = {
let txs = backend.transactions.lock().unwrap();
Arc::clone(txs.get(&tx.0).expect("tx registered"))
};
let err = backend
.rollback(&session, &tx)
.await
.expect_err("ROLLBACK with a query in flight must fail");
assert!(
matches!(&err, BoltError::Transaction(msg) if msg.contains("in flight")),
"unexpected error: {err:?}"
);
drop(in_flight);
backend
.rollback(&session, &tx)
.await
.expect("retry ROLLBACK after the in-flight query completes");
assert_eq!(count_nodes(&backend, "Person"), 0);
}
#[tokio::test]
async fn begin_tx_metadata_write_scope_gates_mutations() {
let backend = memory_backend();
let session = SessionHandle("s".into());
let extra = BoltDict::from([(
"tx_metadata".to_string(),
BoltValue::Dict(BoltDict::from([
(
"write_scope".to_string(),
BoltValue::List(vec![BoltValue::String("Plan".into())]),
),
("git_sha".to_string(), BoltValue::String("abc123".into())),
(
"modified_by".to_string(),
BoltValue::String("test-agent".into()),
),
])),
)]);
let tx = backend
.begin_transaction(&session, &extra)
.await
.expect("begin with tx_metadata");
let err = backend
.execute_in_tx(&tx.0, "CREATE (:Person {id: 1})", HashMap::new())
.expect_err("out-of-scope CREATE must be rejected");
assert!(
format!("{err:?}").contains("write scope"),
"expected a write-scope violation, got: {err:?}"
);
backend
.execute_in_tx(&tx.0, "CREATE (:Plan {id: 1})", HashMap::new())
.expect("in-scope CREATE");
backend.commit(&session, &tx).await.expect("commit");
assert_eq!(count_nodes(&backend, "Plan"), 1);
assert_eq!(count_nodes(&backend, "Person"), 0);
}
#[test]
fn tx_meta_parses_nested_and_top_level_locations() {
let extra = BoltDict::from([(
"tx_metadata".to_string(),
BoltValue::Dict(BoltDict::from([
(
"write_scope".to_string(),
BoltValue::List(vec![
BoltValue::String("Plan".into()),
BoltValue::String("Task".into()),
]),
),
("git_sha".to_string(), BoltValue::String("deadbeef".into())),
])),
)]);
let meta = TxMeta::from_extra(&extra).expect("nested parse");
assert_eq!(
meta.write_scope,
Some(HashSet::from(["Plan".to_string(), "Task".to_string()]))
);
assert_eq!(meta.git_sha.as_deref(), Some("deadbeef"));
assert_eq!(meta.modified_by, None);
let extra = BoltDict::from([
(
"modified_by".to_string(),
BoltValue::String("agent-7".into()),
),
("git_sha".to_string(), BoltValue::String("cafe".into())),
]);
let meta = TxMeta::from_extra(&extra).expect("top-level parse");
assert_eq!(meta.modified_by.as_deref(), Some("agent-7"));
assert_eq!(meta.git_sha.as_deref(), Some("cafe"));
assert_eq!(meta.write_scope, None);
let meta = TxMeta::from_extra(&BoltDict::new()).expect("empty parse");
assert_eq!(meta.write_scope, None);
assert_eq!(meta.git_sha, None);
assert_eq!(meta.modified_by, None);
let extra = BoltDict::from([(
"write_scope".to_string(),
BoltValue::String("not-a-list".into()),
)]);
assert!(TxMeta::from_extra(&extra).is_err());
let extra = BoltDict::from([("git_sha".to_string(), BoltValue::Integer(7))]);
assert!(TxMeta::from_extra(&extra).is_err());
let extra = BoltDict::from([("tx_metadata".to_string(), BoltValue::Integer(1))]);
assert!(TxMeta::from_extra(&extra).is_err());
}
#[test]
fn transaction_timeout_admission_is_explicit_and_top_level_only() {
for extra in [
BoltDict::new(),
BoltDict::from([("tx_timeout".into(), BoltValue::Null)]),
BoltDict::from([("tx_timeout".into(), BoltValue::Integer(0))]),
BoltDict::from([(
"tx_metadata".into(),
BoltValue::Dict(BoltDict::from([(
"tx_timeout".into(),
BoltValue::Integer(10),
)])),
)]),
] {
reject_unsupported_tx_timeout(&extra).expect("unlimited or user metadata");
}
for value in [BoltValue::Integer(1), BoltValue::Integer(-1)] {
let error =
reject_unsupported_tx_timeout(&BoltDict::from([("tx_timeout".into(), value)]))
.expect_err("a nonzero unsupported timeout must be refused");
assert!(error.to_string().contains("not supported"), "{error}");
}
let error = reject_unsupported_tx_timeout(&BoltDict::from([(
"tx_timeout".into(),
BoltValue::String("10".into()),
)]))
.expect_err("a mistyped timeout must be refused");
assert!(
error.to_string().contains("integer milliseconds"),
"{error}"
);
}
#[tokio::test]
async fn unsupported_timeout_is_refused_before_begin_intercepts_and_tx_run() {
let backend = memory_backend();
let session = SessionHandle("timeout".into());
let unsupported = BoltDict::from([("tx_timeout".into(), BoltValue::Integer(1))]);
let checkpoint = backend
.execute(
&session,
"CALL db.checkpoint()",
&HashMap::new(),
&unsupported,
None,
)
.await
.expect_err("the checkpoint intercept must not bypass timeout admission");
assert!(
checkpoint.to_string().contains("tx_timeout"),
"{checkpoint}"
);
backend
.begin_transaction(&session, &unsupported)
.await
.expect_err("BEGIN must refuse before creating state");
assert!(backend.transactions.lock().unwrap().is_empty());
let tx = backend
.begin_transaction(&session, &BoltDict::new())
.await
.expect("ordinary BEGIN");
backend
.execute(
&session,
"RETURN 1",
&HashMap::new(),
&unsupported,
Some(&tx),
)
.await
.expect_err("RUN inside a transaction must use the shared admission check");
backend.rollback(&session, &tx).await.expect("rollback");
}
#[test]
fn default_identity_is_honest() {
assert_eq!(ServerIdentity::default(), ServerIdentity::Kglite);
assert_eq!(
ServerIdentity::Kglite.product_string("1.2.3"),
"kglite-bolt-server/1.2.3"
);
}
#[test]
fn compat_identity_satisfies_the_java_driver_gate() {
let agent = ServerIdentity::Neo4jCompatible.product_string("1.2.3");
assert!(
agent.starts_with("Neo4j/"),
"the Java driver rejects any agent without this prefix: {agent}"
);
}
#[test]
fn compat_identity_keeps_attribution() {
let agent = ServerIdentity::Neo4jCompatible.product_string("1.2.3");
assert!(agent.contains("kglite-bolt-server/1.2.3"), "{agent}");
assert_eq!(agent, "Neo4j/5.26.0 (kglite-bolt-server/1.2.3)");
}
#[test]
fn only_the_honest_identity_trips_the_gate() {
assert!(ServerIdentity::Kglite.is_rejected_by_agent_gate());
assert!(!ServerIdentity::Neo4jCompatible.is_rejected_by_agent_gate());
}
#[test]
fn agent_gate_markers_match_java_but_not_javascript() {
let matches = |ua: &str| {
let lowered = ua.to_ascii_lowercase();
AGENT_GATED_DRIVER_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
};
assert!(
matches("neo4j-java/5.28.5"),
"the Java driver must be matched"
);
assert!(
matches("MyApp (neo4j-java/5.28.5)"),
"case/wrapping tolerated"
);
assert!(
!matches("neo4j-javascript/5.28.0"),
"the JavaScript driver does not gate on the agent and must not warn"
);
assert!(!matches("neo4j-python/6.2.0"));
assert!(!matches(""));
}
fn unique_kgl_path(tag: &str) -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"kglite-bolt-checkpoint-{tag}-{}-{nonce}.kgl",
std::process::id()
))
}
async fn run_checkpoint_query(
backend: &KgliteBackend,
session: &SessionHandle,
query: &str,
) -> Result<ResultStream, BoltError> {
backend
.execute(session, query, &HashMap::new(), &BoltDict::new(), None)
.await
}
fn summary_type(stream: &ResultStream) -> String {
match stream.summary.get("type") {
Some(BoltValue::String(t)) => t.clone(),
other => panic!("expected a string summary type, got {other:?}"),
}
}
fn checkpoint_message(stream: &ResultStream) -> String {
let index = stream
.metadata
.columns
.iter()
.position(|c| c == "message")
.expect("result carries a message column");
match &stream.records[0].values[index] {
BoltValue::String(m) => m.clone(),
other => panic!("expected a string message, got {other:?}"),
}
}
#[test]
fn checkpoint_normalization_accepts_the_verbs_spellings() {
let both = vec!["success", "message"];
let cases: &[(&str, Vec<&str>)] = &[
("CALL db.checkpoint()", both.clone()),
("call db.checkpoint()", both.clone()),
(" CALL db.checkpoint( ) ", both.clone()),
("CALL db.checkpoint();", both.clone()),
("CALL db.checkpoint() ; ", both.clone()),
("CALL DB.CheckPoint()", both.clone()),
("CALL db.checkpoint() YIELD success, message", both.clone()),
("call db.checkpoint() yield success,message", both.clone()),
(
"CALL db.checkpoint() YIELD message, success",
vec!["message", "success"],
),
("CALL db.checkpoint() YIELD success", vec!["success"]),
("CALL db.checkpoint() YIELD message;", vec!["message"]),
];
for (query, expected) in cases {
let call = parse_checkpoint_call(query)
.unwrap_or_else(|| panic!("must be recognized as the checkpoint verb: {query:?}"));
assert_eq!(&call.columns, expected, "columns for {query:?}");
}
}
#[test]
fn checkpoint_normalization_rejects_everything_else() {
let cases = [
"CALL db.checkpoint(true)",
"CALL db.checkpoint('/tmp/other.kgl')",
"CALL db.checkpoints()",
"CALL db.checkpoint",
"CALL db.labels()",
"CALLdb.checkpoint()",
"CALL db.checkpoint() YIELD",
"CALL db.checkpoint() YIELD success, other",
"CALL db.checkpoint() YIELD Success",
"CALL db.checkpoint() YIELD success AS ok",
"CALL db.checkpoint() YIELD success, success",
"CALL db.checkpoint() RETURN 1",
"CALL db.checkpoint() YIELD success, message RETURN success",
"RETURN 'CALL db.checkpoint()' AS s",
"MATCH (n) RETURN n",
];
for query in cases {
assert_eq!(
parse_checkpoint_call(query),
None,
"must fall through to the engine: {query:?}"
);
}
}
#[test]
fn server_facts_recognizes_the_verbs() {
let cases: [(&str, ServerFactsVerb, &[&str]); 6] = [
(
"CALL dbms.components()",
ServerFactsVerb::DbmsComponents,
&["name", "versions", "edition"],
),
(
"CALL dbms.components() YIELD name, versions, edition",
ServerFactsVerb::DbmsComponents,
&["name", "versions", "edition"],
),
(
"call DBMS.COMPONENTS() yield edition",
ServerFactsVerb::DbmsComponents,
&["edition"],
),
(
"CALL dbms.showCurrentUser()",
ServerFactsVerb::DbmsShowCurrentUser,
&["username", "roles", "flags"],
),
(
"SHOW DATABASES",
ServerFactsVerb::ShowDatabases,
&SHOW_DATABASES_COLUMNS,
),
(
"show databases;",
ServerFactsVerb::ShowDatabases,
&SHOW_DATABASES_COLUMNS,
),
];
for (query, verb, columns) in cases {
let call = parse_server_facts_call(query)
.unwrap_or_else(|| panic!("must intercept: {query:?}"));
assert_eq!(call.verb, verb, "{query:?}");
assert_eq!(call.columns, columns, "{query:?}");
}
}
#[test]
fn server_facts_rejects_everything_else() {
let cases = [
"CALL dbms.components(true)",
"CALL dbms.components() YIELD name AS n",
"CALL dbms.components() YIELD nope",
"CALL dbms.components() YIELD name, name",
"CALL dbms.componentsExtra()",
"CALL dbms.components() RETURN 1",
"SHOW DATABASE",
"SHOW DATABASES YIELD name",
"SHOW DATABASES WHERE name = 'neo4j'",
"SHOW DEFAULT DATABASE",
"RETURN 'CALL dbms.components()' AS s",
];
for query in cases {
assert_eq!(
parse_server_facts_call(query),
None,
"must fall through to the engine: {query:?}"
);
}
}
#[test]
fn components_row_follows_identity() {
let (name, version) = ServerIdentity::Kglite.components_row("9.9.9");
assert_eq!(name, "kglite-bolt-server");
assert_eq!(version, "9.9.9");
let (name, version) = ServerIdentity::Neo4jCompatible.components_row("9.9.9");
assert_eq!(name, "Neo4j Kernel");
assert_eq!(version, NEO4J_COMPAT_VERSION);
}
#[test]
fn server_facts_rows_answer_from_server_state() {
let backend = memory_backend();
let user_call = parse_server_facts_call("CALL dbms.showCurrentUser()").unwrap();
let stream = backend.run_server_facts(&user_call);
assert_eq!(
stream.records[0].values[0],
BoltValue::String("neo4j".to_string()),
"auth none reports the conventional neo4j principal"
);
let mut with_user = memory_backend();
with_user.auth_user = Some("ops".to_string());
let stream = with_user.run_server_facts(&user_call);
assert_eq!(
stream.records[0].values[0],
BoltValue::String("ops".to_string())
);
let db_call = parse_server_facts_call("SHOW DATABASES").unwrap();
let readonly = memory_backend_at(unique_disk_path().join("ro.kgl"), true);
let stream = readonly.run_server_facts(&db_call);
let row = &stream.records[0].values;
let col = |name: &str| {
SHOW_DATABASES_COLUMNS
.iter()
.position(|c| *c == name)
.unwrap()
};
assert_eq!(row[col("name")], BoltValue::String("neo4j".to_string()));
assert_eq!(
row[col("access")],
BoltValue::String("read-only".to_string())
);
assert_eq!(row[col("writer")], BoltValue::Boolean(false));
assert_eq!(row[col("default")], BoltValue::Boolean(true));
assert_eq!(row[col("home")], BoltValue::Boolean(true));
}
#[tokio::test]
async fn checkpoint_writes_then_skips_until_the_graph_changes() {
let path = unique_kgl_path("skip");
let backend = memory_backend_at(path.clone(), false);
let session = SessionHandle("checkpoint-session".into());
mutate_and_finish(&backend, &session, "CREATE (:Person {id: 1})", true).await;
let saved_version = backend.session.version();
let first = run_checkpoint_query(&backend, &session, "CALL db.checkpoint()")
.await
.expect("first checkpoint");
assert_eq!(summary_type(&first), "w", "a real save is a write");
assert_eq!(
checkpoint_message(&first),
format!("checkpoint written: version {saved_version}")
);
assert!(path.exists(), "the checkpoint must reach the served path");
let first_written = std::fs::metadata(&path)
.expect("checkpoint file metadata")
.modified()
.expect("modification time");
let second = run_checkpoint_query(&backend, &session, "CALL db.checkpoint()")
.await
.expect("second checkpoint");
assert_eq!(summary_type(&second), "r", "a skip did not write");
assert_eq!(
checkpoint_message(&second),
format!("skipped: graph unchanged since version {saved_version}")
);
assert_eq!(
std::fs::metadata(&path)
.expect("checkpoint file metadata")
.modified()
.expect("modification time"),
first_written,
"a skipped checkpoint must not rewrite the file"
);
mutate_and_finish(&backend, &session, "CREATE (:Person {id: 2})", true).await;
let bumped_version = backend.session.version();
assert_ne!(
bumped_version, saved_version,
"the write bumped the version"
);
let third = run_checkpoint_query(&backend, &session, "CALL db.checkpoint()")
.await
.expect("third checkpoint");
assert_eq!(summary_type(&third), "w");
assert_eq!(
checkpoint_message(&third),
format!("checkpoint written: version {bumped_version}")
);
drop(backend);
std::fs::remove_file(&path).expect("remove checkpoint fixture");
}
#[tokio::test]
async fn checkpoint_projects_the_yielded_columns() {
let path = unique_kgl_path("yield");
let backend = memory_backend_at(path.clone(), false);
let session = SessionHandle("yield-session".into());
let stream = run_checkpoint_query(
&backend,
&session,
"CALL db.checkpoint() YIELD message, success",
)
.await
.expect("checkpoint with a reordered YIELD");
assert_eq!(stream.metadata.columns, vec!["message", "success"]);
assert_eq!(stream.records.len(), 1);
assert!(matches!(stream.records[0].values[0], BoltValue::String(_)));
assert_eq!(stream.records[0].values[1], BoltValue::Boolean(true));
drop(backend);
std::fs::remove_file(&path).expect("remove checkpoint fixture");
}
#[tokio::test]
async fn checkpoint_inside_a_transaction_is_refused() {
let path = unique_kgl_path("in-tx");
let backend = memory_backend_at(path.clone(), false);
let session = SessionHandle("tx-session".into());
let tx = backend
.begin_transaction(&session, &BoltDict::new())
.await
.expect("begin");
backend
.execute_in_tx(&tx.0, "CREATE (:Person {id: 1})", HashMap::new())
.expect("tx mutation");
let err = backend
.execute(
&session,
"CALL db.checkpoint()",
&HashMap::new(),
&BoltDict::new(),
Some(&tx),
)
.await
.expect_err("db.checkpoint() inside a transaction must be refused");
assert!(
matches!(&err, BoltError::Protocol(msg) if msg.contains("explicit transaction")),
"unexpected error: {err:?}"
);
assert!(
!path.exists(),
"a refused checkpoint must not have written anything"
);
}
#[tokio::test]
async fn checkpoint_is_refused_on_a_readonly_server() {
let path = unique_kgl_path("readonly");
let backend = memory_backend_at(path.clone(), true);
let session = SessionHandle("ro-session".into());
let err = run_checkpoint_query(&backend, &session, "CALL db.checkpoint()")
.await
.expect_err("a read-only server must refuse the checkpoint verb");
assert!(
matches!(&err, BoltError::Forbidden(msg) if msg.contains("--readonly")),
"unexpected error: {err:?}"
);
assert!(!path.exists(), "a refused checkpoint writes nothing");
}
#[tokio::test]
async fn auto_commit_mutation_is_refused_as_a_client_request_error() {
let backend = memory_backend();
let session = SessionHandle("auto-commit".into());
let err = backend
.execute(
&session,
"CREATE (:Person {id: 1})",
&HashMap::new(),
&BoltDict::new(),
None,
)
.await
.expect_err("auto-commit mutations are not supported");
assert!(
matches!(&err, BoltError::Session(msg) if msg.contains("explicit transaction")),
"unexpected error: {err:?}"
);
assert_eq!(
err.to_failure_metadata().get("code"),
Some(&BoltValue::String(
"Neo.ClientError.Request.Invalid".to_string()
)),
"a refusal with a client-side remedy must not be a DatabaseError"
);
}
#[tokio::test]
async fn checkpoint_is_refused_for_a_disk_graph() {
let path = unique_disk_path();
let graph = new_dir_graph_in_mode(StorageMode::Disk, Some(&path))
.expect("create disk-backed graph");
let backend = KgliteBackend::new(
kglite::api::session::Session::new(graph),
path.clone(),
false,
"127.0.0.1:0".into(),
CsvImportPolicy::Denied,
ServerIdentity::default(),
None,
);
let session = SessionHandle("disk-session".into());
let err = run_checkpoint_query(&backend, &session, "CALL db.checkpoint()")
.await
.expect_err("a disk graph must refuse the checkpoint verb");
assert!(
matches!(&err, BoltError::Forbidden(msg) if msg.contains("generation")),
"unexpected error: {err:?}"
);
drop(backend);
std::fs::remove_dir_all(path).expect("remove disk checkpoint fixture");
}
}