use std::borrow::Cow;
#[cfg(not(target_family = "wasm"))]
use std::pin::pin;
use std::sync::Arc;
#[cfg(not(target_family = "wasm"))]
use std::task::{Poll, ready};
#[cfg(not(target_family = "wasm"))]
use std::{future::Future, path::PathBuf};
use async_channel::Sender;
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
use futures::StreamExt;
#[cfg(not(target_family = "wasm"))]
use futures::stream::poll_fn;
use surrealdb_core::dbs::{AuthPrincipalSnapshot, Session};
use surrealdb_core::iam;
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
use surrealdb_core::iam::check::check_ns_db;
use surrealdb_core::kvs::{Datastore, QueryRequest};
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
use surrealdb_engine_api::MlExportConfig;
use surrealdb_engine_api::{EngineContext, EngineFuture, SurrealEngine, single_result};
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
use surrealdb_iam::{Action, ResourceKind};
use surrealdb_kvs::TransactionType;
#[cfg(not(target_family = "wasm"))]
use surrealdb_rpc::export::Config as DbExportConfig;
use surrealdb_rpc::{QueryResult, QueryResultBuilder, QueryStreamItem, QueryType, Token};
use surrealdb_types::{Array, Error, Notification, Object, ToSql, Value, Variables};
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
use surrealml_core::storage::surml_file::SurMlFile;
#[cfg(not(target_family = "wasm"))]
use tokio::{
fs::OpenOptions,
io::{self, AsyncReadExt, AsyncWriteExt},
};
#[cfg(not(target_family = "wasm"))]
use tokio_util::bytes::BytesMut;
use uuid::Uuid;
use crate::session::{self, SessionRegistry, SessionState};
use crate::std_error_to_types_error;
pub struct LocalEngine {
pub(crate) kvs: Arc<Datastore>,
pub(crate) sessions: Arc<SessionRegistry>,
}
impl std::fmt::Debug for LocalEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LocalEngine").finish_non_exhaustive()
}
}
impl LocalEngine {
async fn state(&self, ctx: EngineContext) -> Result<Arc<SessionState>, Error> {
session::resolve(&self.sessions, ctx.session).await
}
}
fn transaction_not_found() -> Error {
Error::not_found(
"Transaction not found".to_string(),
Some(surrealdb_types::NotFoundError::Transaction),
)
}
fn transaction_abandoned() -> Error {
Error::internal(
"The transaction was rolled back: a query running in it was abandoned before it finished"
.to_string(),
)
}
const KILL_ID_PARAM: &str = "__kill_id";
pub(crate) async fn kill_live_query(
kvs: &Datastore,
id: Uuid,
session: &Session,
mut vars: Variables,
) -> Result<Vec<QueryResult>, Error> {
vars.insert(KILL_ID_PARAM.to_string(), Value::Uuid(id.into()));
let results = kvs.execute(&format!("KILL ${KILL_ID_PARAM}"), session, Some(vars)).await?;
Ok(results)
}
fn record_live_queries(state: &SessionState, results: &[QueryResult]) {
for result in results.iter().filter(|result| result.query_type == QueryType::Live) {
if let Ok(Value::Uuid(id)) = &result.result {
let id = id.into_inner();
if state.live_queries.get(&id).is_none() {
state.live_queries.insert(id, None);
}
}
}
}
async fn cleanup_lqs_on_principal_change(
kvs: &Datastore,
state: &SessionState,
before: &AuthPrincipalSnapshot,
) {
if !before.differs_from(&*state.session.read().await) {
return;
}
let mut gc = Vec::new();
state.live_queries.retain(|id, _| {
gc.push(*id);
false
});
if gc.is_empty() {
return;
}
if let Err(error) = kvs.delete_queries(gc).await {
warn!("Failed to end live queries after the session's principal changed; {error}");
}
}
async fn ensure_session_active(state: &SessionState) -> Result<(), Error> {
if state.session.read().await.expired() {
return Err(surrealdb_rpc::error::session_expired());
}
Ok(())
}
#[cfg(not(target_family = "wasm"))]
async fn export_file(
kvs: &Datastore,
sess: &Session,
chn: Sender<Vec<u8>>,
config: Option<DbExportConfig>,
) -> Result<(), Error> {
let res = match config {
Some(config) => {
kvs.export_with_config(sess, chn, config).await.map_err(std_error_to_types_error)?.await
}
None => kvs.export(sess, chn).await.map_err(std_error_to_types_error)?.await,
};
if let Err(error) = res {
let error_str = error.to_string();
if error_str.contains("channel") || error_str.contains("Channel") {
trace!("{error_str}");
return Ok(());
}
return Err(Error::internal(error.to_string()));
}
Ok(())
}
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
async fn export_ml(
kvs: &Datastore,
sess: &Session,
chn: Sender<Vec<u8>>,
MlExportConfig {
name,
version,
}: MlExportConfig,
) -> Result<(), Error> {
let (nsv, dbv) = check_ns_db(sess).map_err(|e| Error::internal(e.to_string()))?;
kvs.check(sess, Action::View, ResourceKind::Model.on_db(&nsv, &dbv))
.map_err(|e| Error::internal(e.to_string()))?;
let Some(model) = kvs
.get_db_model(&nsv, &dbv, &name, &version)
.await
.map_err(|e| Error::internal(e.to_string()))?
else {
return Err(Error::not_found("Model not found".to_string(), None));
};
let mut data = surrealdb_core::obs::stream(model.hash.to_string())
.await
.map_err(|e| Error::internal(e.to_string()))?;
while let Some(Ok(bytes)) = data.next().await {
if chn.send(bytes.to_vec()).await.is_err() {
break;
}
}
Ok(())
}
#[cfg(not(target_family = "wasm"))]
async fn copy<'a, R, W>(path: PathBuf, reader: &'a mut R, writer: &'a mut W) -> Result<(), Error>
where
R: tokio::io::AsyncRead + Unpin + ?Sized,
W: tokio::io::AsyncWrite + Unpin + ?Sized,
{
io::copy(reader, writer)
.await
.map(|_| ())
.map_err(|error| Error::internal(format!("Failed to read `{}`: {}", path.display(), error)))
}
#[cfg(not(target_family = "wasm"))]
fn export_to_channel<F, Fut>(bytes: Sender<Result<Vec<u8>, Error>>, export: F)
where
F: FnOnce(Sender<Vec<u8>>) -> Fut + Send + 'static,
Fut: Future<Output = Result<(), Error>> + Send,
{
crate::spawn(async move {
let (tx, rx) = async_channel::bounded(1);
let produce = async {
if let Err(error) = export(tx).await {
bytes.send(Err(error)).await.ok();
}
};
let bridge = async {
while let Ok(chunk) = rx.recv().await {
if bytes.send(Ok(chunk)).await.is_err() {
rx.close();
return;
}
}
};
tokio::join!(produce, bridge);
});
}
#[cfg(not(target_family = "wasm"))]
async fn export_to_file<F, Fut>(path: PathBuf, export: F) -> Result<(), Error>
where
F: FnOnce(Sender<Vec<u8>>) -> Fut,
Fut: Future<Output = Result<(), Error>>,
{
let (tx, rx) = async_channel::bounded(1);
let (mut writer, mut reader) = io::duplex(10_240);
let export = export(tx);
let bridge = async move {
while let Ok(value) = rx.recv().await {
if writer.write_all(&value).await.is_err() {
break;
}
}
Ok(())
};
let mut output =
match OpenOptions::new().write(true).create(true).truncate(true).open(&path).await {
Ok(file) => file,
Err(error) => {
return Err(Error::internal(format!(
"Failed to open `{}`: {}",
path.display(),
error
)));
}
};
let copy = copy(path, &mut reader, &mut output);
tokio::try_join!(export, bridge, copy)?;
Ok(())
}
struct AbandonOnCancel<'a> {
state: &'a SessionState,
txn: Uuid,
finished: bool,
}
impl<'a> AbandonOnCancel<'a> {
fn new(state: &'a SessionState, txn: Uuid) -> Self {
Self {
state,
txn,
finished: false,
}
}
fn finished(mut self) {
self.finished = true;
}
}
impl Drop for AbandonOnCancel<'_> {
fn drop(&mut self) {
if !self.finished
&& let Some(tx) = self.state.transactions.take(&self.txn)
{
self.state.abandoned.insert(self.txn, tx);
}
}
}
impl SurrealEngine for LocalEngine {
fn query(
&self,
ctx: EngineContext,
query: Cow<'static, str>,
variables: Variables,
) -> EngineFuture<'_, Vec<QueryResult>> {
Box::pin(async move {
let state = self.state(ctx).await?;
let mut vars = state.vars.read().await.clone();
vars.extend(variables);
let session = state.session.read().await;
let results = match ctx.transaction {
Some(txn) => match state.transactions.get(&txn) {
Some(tx) => {
let abandon = AbandonOnCancel::new(&state, txn);
let results = self
.kvs
.run(
QueryRequest::new(query.as_ref(), &session)
.with_variables(Some(vars))
.with_transaction(tx),
)
.await;
abandon.finished();
results
}
None => Ok(vec![
QueryResultBuilder::started_now()
.finish_with_result(Err(transaction_not_found())),
]),
},
None => self.kvs.execute(query.as_ref(), &session, Some(vars)).await,
};
if let Ok(results) = &results {
record_live_queries(&state, results);
}
results
})
}
fn query_stream(
&self,
ctx: EngineContext,
query: Cow<'static, str>,
variables: Variables,
items: Sender<QueryStreamItem>,
) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let mut vars = state.vars.read().await.clone();
vars.extend(variables);
let job = {
let session = state.session.read().await;
let transaction = match ctx.transaction {
Some(txn) => {
Some(state.transactions.get(&txn).ok_or_else(transaction_not_found)?)
}
None => None,
};
self.kvs.run_streaming(
QueryRequest::new(query.as_ref(), &session)
.with_variables(Some(vars))
.with_optional_transaction(transaction),
items,
)?
};
job.run.await?;
Ok(())
})
}
fn run(
&self,
ctx: EngineContext,
name: String,
version: Option<String>,
args: Array,
) -> EngineFuture<'_, Value> {
Box::pin(async move {
let state = self.state(ctx).await?;
let formatted_args = args.iter().map(|v| v.to_sql()).collect::<Vec<_>>().join(", ");
let sql = match version {
Some(v) => format!("{name}<{v}>({formatted_args})"),
None => format!("{name}({formatted_args})"),
};
let results = self
.kvs
.execute(&sql, &*state.session.read().await, Some(state.vars.read().await.clone()))
.await?;
single_result(results)
})
}
fn use_ns_db(
&self,
ctx: EngineContext,
namespace: Option<String>,
database: Option<String>,
) -> EngineFuture<'_, (Option<String>, Option<String>)> {
Box::pin(async move {
let state = self.state(ctx).await?;
let mut session = state.session.write().await;
self.kvs.process_use(None, &mut session, namespace, database).await?;
Ok((session.ns.clone(), session.db.clone()))
})
}
fn set(&self, ctx: EngineContext, key: String, value: Value) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
ensure_session_active(&state).await?;
surrealdb_rpc::check_protected_param(&key)
.map_err(|e| Error::internal(e.to_string()))?;
match value {
Value::None => state.vars.write().await.remove(&key),
v => state.vars.write().await.insert(key, v),
};
Ok(())
})
}
fn unset(&self, ctx: EngineContext, key: String) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
ensure_session_active(&state).await?;
state.vars.write().await.remove(&key);
Ok(())
})
}
fn signup(&self, ctx: EngineContext, credentials: Object) -> EngineFuture<'_, Token> {
Box::pin(async move {
let state = self.state(ctx).await?;
let before = AuthPrincipalSnapshot::capture(&*state.session.read().await);
let token = iam::signup::signup(
&self.kvs,
&mut *state.session.write().await,
credentials.into(),
)
.await
.map_err(surrealdb_core::err::anyhow_to_types_error);
cleanup_lqs_on_principal_change(&self.kvs, &state, &before).await;
token
})
}
fn signin(&self, ctx: EngineContext, credentials: Object) -> EngineFuture<'_, Token> {
Box::pin(async move {
let state = self.state(ctx).await?;
let before = AuthPrincipalSnapshot::capture(&*state.session.read().await);
let token = iam::signin::signin(
&self.kvs,
&mut *state.session.write().await,
credentials.into(),
)
.await
.map_err(surrealdb_core::err::anyhow_to_types_error);
cleanup_lqs_on_principal_change(&self.kvs, &state, &before).await;
token
})
}
fn authenticate(&self, ctx: EngineContext, token: Token) -> EngineFuture<'_, Token> {
Box::pin(async move {
let state = self.state(ctx).await?;
let before = AuthPrincipalSnapshot::capture(&*state.session.read().await);
let (access, with_refresh) = match &token {
Token::Access(access) => (access, false),
Token::WithRefresh {
access,
..
} => (access, true),
};
let verified = {
let mut session = state.session.write().await;
iam::verify::token(&self.kvs, &mut session, access).await
};
let result = match verified {
Ok(_) => Ok(token),
Err(error) => {
if with_refresh && iam::is_expired_token_error(&error) {
iam::token::refresh(token, &self.kvs, &mut *state.session.write().await)
.await
.map_err(|error| Error::internal(error.to_string()))
} else {
Err(Error::internal(error.to_string()))
}
}
};
cleanup_lqs_on_principal_change(&self.kvs, &state, &before).await;
result
})
}
fn refresh(&self, ctx: EngineContext, token: Token) -> EngineFuture<'_, Token> {
Box::pin(async move {
let state = self.state(ctx).await?;
let before = AuthPrincipalSnapshot::capture(&*state.session.read().await);
let result = iam::token::refresh(token, &self.kvs, &mut *state.session.write().await)
.await
.map_err(|error| Error::internal(error.to_string()));
cleanup_lqs_on_principal_change(&self.kvs, &state, &before).await;
result
})
}
fn revoke(&self, ctx: EngineContext, token: Token) -> EngineFuture<'_, ()> {
Box::pin(async move {
self.state(ctx).await?;
iam::token::revoke_refresh_token(token, &self.kvs)
.await
.map_err(|error| Error::internal(error.to_string()))
})
}
fn invalidate(&self, ctx: EngineContext) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let before = AuthPrincipalSnapshot::capture(&*state.session.read().await);
let result = iam::clear::clear(&mut *state.session.write().await)
.map_err(|error| Error::internal(error.to_string()));
cleanup_lqs_on_principal_change(&self.kvs, &state, &before).await;
result
})
}
fn begin(&self, ctx: EngineContext) -> EngineFuture<'_, Uuid> {
Box::pin(async move {
let state = self.state(ctx).await?;
let txn = self
.kvs
.transaction(TransactionType::Write)
.await
.map_err(|error| Error::internal(error.to_string()))?;
let id = Uuid::now_v7();
state.transactions.insert(id, Arc::new(txn));
Ok(id)
})
}
fn commit(&self, ctx: EngineContext, txn: Uuid) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
if let Some(tx) = state.abandoned.take(&txn) {
tx.cancel().await.map_err(std_error_to_types_error)?;
return Err(transaction_abandoned());
}
let tx = state.transactions.take(&txn).ok_or_else(transaction_not_found)?;
tx.commit().await.map_err(std_error_to_types_error)?;
Ok(())
})
}
fn rollback(&self, ctx: EngineContext, txn: Uuid) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let tx = state
.abandoned
.take(&txn)
.or_else(|| state.transactions.take(&txn))
.ok_or_else(transaction_not_found)?;
tx.cancel().await.map_err(std_error_to_types_error)?;
Ok(())
})
}
fn health(&self, ctx: EngineContext) -> EngineFuture<'_, ()> {
Box::pin(async move {
self.state(ctx).await?;
Ok(())
})
}
fn version(&self, ctx: EngineContext) -> EngineFuture<'_, String> {
Box::pin(async move {
self.state(ctx).await?;
Ok(surrealdb_core::env::VERSION.to_string())
})
}
fn subscribe_live(
&self,
ctx: EngineContext,
uuid: Uuid,
notifications: Sender<Result<Notification, Error>>,
) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
state.live_queries.insert(uuid, Some(notifications));
Ok(())
})
}
fn kill(&self, ctx: EngineContext, uuid: Uuid) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
state.live_queries.remove(&uuid);
let results = kill_live_query(
&self.kvs,
uuid,
&*state.session.read().await,
state.vars.read().await.clone(),
)
.await?;
single_result(results)?;
Ok(())
})
}
#[cfg(not(target_family = "wasm"))]
fn export_file(
&self,
ctx: EngineContext,
path: PathBuf,
config: Option<DbExportConfig>,
) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let session = state.session.read().await.clone();
export_to_file(path, |chn| export_file(&self.kvs, &session, chn, config)).await
})
}
#[cfg(not(target_family = "wasm"))]
fn export_bytes(
&self,
ctx: EngineContext,
bytes: Sender<Result<Vec<u8>, Error>>,
config: Option<DbExportConfig>,
) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let kvs = Arc::clone(&self.kvs);
let session = state.session.read().await.clone();
export_to_channel(bytes, |chn| async move {
export_file(&kvs, &session, chn, config).await
});
Ok(())
})
}
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
fn export_ml_file(
&self,
ctx: EngineContext,
path: PathBuf,
config: MlExportConfig,
) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let session = state.session.read().await.clone();
export_to_file(path, |chn| export_ml(&self.kvs, &session, chn, config)).await
})
}
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
fn export_ml_bytes(
&self,
ctx: EngineContext,
bytes: Sender<Result<Vec<u8>, Error>>,
config: MlExportConfig,
) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let kvs = Arc::clone(&self.kvs);
let session = state.session.read().await.clone();
export_to_channel(
bytes,
|chn| async move { export_ml(&kvs, &session, chn, config).await },
);
Ok(())
})
}
#[cfg(not(target_family = "wasm"))]
fn import_file(&self, ctx: EngineContext, path: PathBuf) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let file = match OpenOptions::new().read(true).open(&path).await {
Ok(file) => file,
Err(error) => {
return Err(Error::internal(format!(
"Failed to open `{}`: {}",
path.display(),
error
)));
}
};
let mut file = pin!(file);
let mut buffer = BytesMut::with_capacity(4096);
let stream = poll_fn(|ctx| {
if buffer.capacity() == 0 {
buffer.reserve(4096);
}
let future = pin!(file.read_buf(&mut buffer));
match ready!(future.poll(ctx)) {
Ok(0) => Poll::Ready(None),
Ok(_) => Poll::Ready(Some(Ok(buffer.split().freeze()))),
Err(e) => Poll::Ready(Some(Err(anyhow::anyhow!("{}", e)))),
}
});
let responses = self
.kvs
.execute_import(
&*state.session.read().await,
Some(state.vars.read().await.clone()),
stream,
)
.await
.map_err(std_error_to_types_error)?;
for response in responses {
response.result?;
}
Ok(())
})
}
#[cfg(all(not(target_family = "wasm"), feature = "ml"))]
fn import_ml_file(&self, ctx: EngineContext, path: PathBuf) -> EngineFuture<'_, ()> {
Box::pin(async move {
let state = self.state(ctx).await?;
let mut file = match OpenOptions::new().read(true).open(&path).await {
Ok(file) => file,
Err(error) => {
return Err(Error::internal(format!(
"Failed to open `{}`: {}",
path.display(),
error
)));
}
};
let (nsv, dbv) =
check_ns_db(&*state.session.read().await).map_err(std_error_to_types_error)?;
self.kvs
.check(
&*state.session.read().await,
Action::Edit,
ResourceKind::Model.on_db(&nsv, &dbv),
)
.map_err(std_error_to_types_error)?;
let mut buffer = Vec::new();
if let Err(error) = file.read_to_end(&mut buffer).await {
return Err(Error::internal(format!(
"Failed to read `{}`: {}",
path.display(),
error
)));
}
let file = match SurMlFile::from_bytes(buffer) {
Ok(file) => file,
Err(error) => {
return Err(Error::internal(format!(
"Invalid SurrealML file: {}",
error.message
)));
}
};
let data = file.to_bytes();
self.kvs
.put_ml_model(
&*state.session.read().await,
&file.header.name.to_string(),
&file.header.version.to_string(),
&file.header.description.to_string(),
data,
)
.await
.map_err(std_error_to_types_error)?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use surrealdb_rpc::QueryResultBuilder;
use surrealdb_types::Uuid as PublicUuid;
use super::*;
#[test]
fn a_live_query_is_recorded_before_its_subscriber_arrives() {
let state = SessionState::new(Uuid::now_v7());
let live = Uuid::now_v7();
record_live_queries(
&state,
&[
QueryResultBuilder::started_now()
.with_query_type(QueryType::Live)
.finish_with_result(Ok(Value::Uuid(PublicUuid::from(live)))),
QueryResultBuilder::instant_none(),
],
);
assert!(
matches!(state.live_queries.get(&live), Some(None)),
"the live query is the session's, with no subscriber yet"
);
assert_eq!(state.live_queries.len(), 1, "only the live statement registers one");
}
#[test]
fn recording_does_not_displace_an_existing_subscriber() {
let state = SessionState::new(Uuid::now_v7());
let live = Uuid::now_v7();
let (sender, _receiver) = async_channel::bounded(1);
state.live_queries.insert(live, Some(sender));
record_live_queries(
&state,
&[QueryResultBuilder::started_now()
.with_query_type(QueryType::Live)
.finish_with_result(Ok(Value::Uuid(PublicUuid::from(live))))],
);
assert!(
matches!(state.live_queries.get(&live), Some(Some(_))),
"the subscriber must survive a later recording pass"
);
}
}
#[cfg(all(test, feature = "kv-mem"))]
mod live_query_tests {
use std::time::Duration;
use async_channel::{Receiver, Sender};
use surrealdb_engine_api::SessionId;
use surrealdb_types::Action;
use tokio::time::timeout;
use super::*;
struct Harness {
engine: Arc<LocalEngine>,
ctx: EngineContext,
notifications: Receiver<Notification>,
_sessions: Sender<SessionId>,
}
impl Harness {
async fn new() -> Self {
let (notify_tx, notifications) = async_channel::bounded(100);
let kvs = Datastore::builder()
.with_auth(false)
.with_notify(notify_tx)
.build_with_path("memory")
.await
.expect("an in-memory datastore should open");
let (sessions_tx, sessions_rx) = async_channel::unbounded();
let engine = crate::from_datastore(kvs, None, sessions_rx);
let session = Uuid::now_v7();
sessions_tx.send(SessionId::Initial(session)).await.expect("the engine is running");
let ctx = EngineContext::new(session);
engine
.use_ns_db(ctx, Some("test".to_string()), Some("test".to_string()))
.await
.expect("selecting a namespace and database should succeed");
let harness = Self {
engine,
ctx,
notifications,
_sessions: sessions_tx,
};
harness.query("DEFINE TABLE person").await;
harness
}
async fn query(&self, sql: &'static str) -> Value {
let mut results = self
.engine
.query(self.ctx, Cow::Borrowed(sql), Variables::new())
.await
.unwrap_or_else(|error| panic!("`{sql}` failed: {error}"));
results
.remove(0)
.result
.unwrap_or_else(|error| panic!("`{sql}` returned an error: {error}"))
}
async fn notification(&self, expected: &str) -> Notification {
timeout(Duration::from_secs(10), self.notifications.recv())
.await
.unwrap_or_else(|_| panic!("timed out waiting for {expected}"))
.expect("the notification channel is open")
}
async fn live(&self) -> Uuid {
match self.query("LIVE SELECT * FROM person").await {
Value::Uuid(id) => id.into_inner(),
other => panic!("LIVE SELECT should return a uuid, got {other:?}"),
}
}
}
#[test_log::test(tokio::test)]
async fn kill_removes_the_subscription_from_the_datastore() {
let harness = Harness::new().await;
let live = harness.live().await;
harness.query("CREATE person:one").await;
let created = harness.notification("the change on the live subscription").await;
assert_eq!(created.id.into_inner(), live, "the notification belongs to this subscription");
assert_eq!(created.action, Action::Create);
harness.engine.kill(harness.ctx, live).await.expect("kill should end the subscription");
let killed = harness.notification("the killed notification").await;
assert_eq!(killed.id.into_inner(), live, "the kill names this subscription");
assert_eq!(killed.action, Action::Killed);
assert!(killed.session.is_none(), "the kill notification carries no session");
harness.query("CREATE person:two").await;
let leaked = timeout(Duration::from_millis(500), harness.notifications.recv()).await;
assert!(
leaked.is_err(),
"the subscription outlived the kill and is still capturing changes: {:?}",
leaked.map(|notification| notification.map(|n| (n.id, n.action))),
);
}
#[test_log::test(tokio::test)]
async fn a_killed_subscription_cannot_be_killed_again() {
let harness = Harness::new().await;
let live = harness.live().await;
harness.engine.kill(harness.ctx, live).await.expect("kill should end the subscription");
assert!(
harness.engine.kill(harness.ctx, live).await.is_err(),
"the datastore still holds a subscription the kill was supposed to remove"
);
}
}