appdb 0.2.22

Lightweight SurrealDB helper library for Tauri embedded database apps
Documentation
use super::{
    DbRuntime, InitDbOptions, LocalStorageSync, format_duration_param, get_db,
    local_datastore_config, local_engine_options, local_storage_datastore_path,
    make_schema_ddl_idempotent, reinit_db, reset_db, reset_db_and_remove_path,
};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use surrealdb::Surreal;

static TEST_DB_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));

#[test]
fn default_init_options_are_non_versioned() {
    let options = InitDbOptions::default();
    assert!(!options.versioned);
    assert!(options.version_retention.is_none());
    assert!(options.query_timeout.is_none());
    assert!(options.transaction_timeout.is_none());
    assert!(options.changefeed_gc_interval.is_none());
    assert!(!options.ast_payload);
    assert!(options.local_storage_sync.is_none());
    assert!(options.surreal_kv_max_memtable_size.is_none());
}

#[test]
fn init_options_builders_override_values() {
    let options = InitDbOptions::default()
        .versioned(true)
        .version_retention(Some(Duration::from_secs(60)))
        .query_timeout(Some(Duration::from_secs(3)))
        .transaction_timeout(Some(Duration::from_secs(9)))
        .changefeed_gc_interval(Some(Duration::from_secs(30)))
        .ast_payload(true)
        .local_storage_sync(Some(LocalStorageSync::Interval(Duration::from_millis(
            200,
        ))))
        .surreal_kv_max_memtable_size(Some(16 * 1024 * 1024));

    assert!(options.versioned);
    assert_eq!(options.version_retention, Some(Duration::from_secs(60)));
    assert_eq!(options.query_timeout, Some(Duration::from_secs(3)));
    assert_eq!(options.transaction_timeout, Some(Duration::from_secs(9)));
    assert_eq!(
        options.changefeed_gc_interval,
        Some(Duration::from_secs(30))
    );
    assert!(options.ast_payload);
    assert_eq!(
        options.local_storage_sync,
        Some(LocalStorageSync::Interval(Duration::from_millis(200)))
    );
    assert_eq!(options.surreal_kv_max_memtable_size, Some(16 * 1024 * 1024));
}

#[test]
fn local_storage_sync_formats_for_surrealdb_endpoint_params() {
    assert_eq!(LocalStorageSync::Never.to_string(), "never");
    assert_eq!(LocalStorageSync::Every.to_string(), "every");
    assert_eq!(
        LocalStorageSync::Interval(Duration::from_millis(200)).to_string(),
        "200ms"
    );
    assert_eq!(
        LocalStorageSync::Interval(Duration::from_secs(1)).to_string(),
        "1s"
    );
}

#[test]
fn duration_params_use_surrealdb_duration_units() {
    assert_eq!(format_duration_param(Duration::ZERO), "0");
    assert_eq!(format_duration_param(Duration::from_micros(250)), "250us");
    assert_eq!(format_duration_param(Duration::from_millis(200)), "200ms");
    assert_eq!(format_duration_param(Duration::from_secs(1)), "1s");
    assert_eq!(format_duration_param(Duration::from_secs(60)), "1m");
}

#[test]
fn local_storage_datastore_path_keeps_policy_out_of_the_path() {
    assert_eq!(
        local_storage_datastore_path(&PathBuf::from("data/surreal.db")),
        "surrealkv://data/surreal.db"
    );
}

#[test]
fn local_datastore_config_uses_core_config_keys() {
    let config = local_datastore_config(
        &InitDbOptions::default()
            .versioned(true)
            .version_retention(Some(Duration::from_secs(60)))
            .local_storage_sync(Some(LocalStorageSync::Every))
            .surreal_kv_max_memtable_size(Some(16 * 1024 * 1024)),
    );

    let serialized = format!("{config:?}");
    assert!(serialized.contains("\"datastore_versioned\": \"true\""));
    assert!(serialized.contains("\"datastore_retention\": \"1m\""));
    assert!(serialized.contains("\"datastore_sync\": \"every\""));
    assert!(serialized.contains("\"surrealkv_max_memtable_size\": \"16777216\""));
    assert!(!serialized.contains("datastore_surrealkv_max_memtable_size"));
}

#[test]
fn zero_changefeed_interval_is_preserved_for_engine_options() {
    let engine =
        local_engine_options(&InitDbOptions::default().changefeed_gc_interval(Some(Duration::ZERO)));

    assert_eq!(engine.changefeed_gc_interval, Duration::ZERO);
}

#[test]
fn idempotent_schema_rewrites_define_table() {
    let ddl = "DEFINE TABLE user SCHEMAFULL;";
    assert_eq!(
        make_schema_ddl_idempotent(ddl),
        "DEFINE TABLE IF NOT EXISTS user SCHEMAFULL;"
    );
}

#[test]
fn idempotent_schema_rewrites_define_field() {
    let ddl = "DEFINE FIELD email ON user TYPE string;";
    assert_eq!(
        make_schema_ddl_idempotent(ddl),
        "DEFINE FIELD IF NOT EXISTS email ON user TYPE string;"
    );
}

#[test]
fn idempotent_schema_rewrites_define_index() {
    let ddl = "DEFINE INDEX user_email ON user FIELDS email UNIQUE;";
    assert_eq!(
        make_schema_ddl_idempotent(ddl),
        "DEFINE INDEX IF NOT EXISTS user_email ON user FIELDS email UNIQUE;"
    );
}

#[test]
fn idempotent_schema_preserves_existing_if_not_exists() {
    let ddl = "DEFINE TABLE IF NOT EXISTS user SCHEMAFULL;";
    assert_eq!(make_schema_ddl_idempotent(ddl), ddl);
}

#[test]
fn idempotent_schema_leaves_other_statements_unchanged() {
    let ddl = "REMOVE TABLE user;";
    assert_eq!(make_schema_ddl_idempotent(ddl), ddl);
}

#[test]
fn runtime_wraps_existing_handle() {
    let handle = Arc::new(Surreal::init());
    let runtime = DbRuntime::from_handle(handle.clone());
    assert!(Arc::ptr_eq(&runtime.handle(), &handle));
}

#[test]
fn reinstall_global_for_tests_replaces_existing_handle() {
    let _guard = TEST_DB_LOCK
        .lock()
        .expect("test db lock should not be poisoned");
    reset_db();

    let first = DbRuntime::from_handle(Arc::new(Surreal::init()));
    first.reinstall_global_for_tests();
    let initial = get_db().expect("db should be installed");
    assert!(Arc::ptr_eq(&initial, &first.handle()));

    let second = DbRuntime::from_handle(Arc::new(Surreal::init()));
    second.reinstall_global_for_tests();
    let reinstalled = get_db().expect("db should be reinstalled");
    assert!(Arc::ptr_eq(&reinstalled, &second.handle()));
    assert!(!Arc::ptr_eq(&reinstalled, &first.handle()));

    reset_db();
}

#[test]
fn reset_db_clears_installed_handle() {
    let _guard = TEST_DB_LOCK
        .lock()
        .expect("test db lock should not be poisoned");
    reset_db();

    let runtime = DbRuntime::from_handle(Arc::new(Surreal::init()));
    runtime.reinstall_global_for_tests();

    reset_db();

    let err = get_db().expect_err("db should be reset");
    assert!(err.to_string().contains("not initialized"));
}

#[test]
fn reset_db_and_remove_path_removes_storage_artifact() {
    let _guard = TEST_DB_LOCK
        .lock()
        .expect("test db lock should not be poisoned");
    reset_db();

    let path = temp_path("appdb_connection_remove_artifact");
    std::fs::create_dir_all(path.join("wal")).expect("storage artifact should be created");
    std::fs::write(path.join("wal").join("00000000000000000000.wal"), b"test")
        .expect("storage file should be created");

    reset_db_and_remove_path(&path).expect("storage artifact should be removed");

    assert!(!path.exists());
}

#[test]
fn reinit_db_survives_sequential_runtime_teardown() {
    let _guard = TEST_DB_LOCK
        .lock()
        .expect("test db lock should not be poisoned");
    reset_db();

    fn open_and_reinstall(path: PathBuf) -> anyhow::Result<()> {
        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()?;

        runtime.block_on(async {
            reinit_db(path).await?;
            let db = get_db()?;
            db.query("RETURN 1;").await?;
            Ok(())
        })
    }

    let first_path = temp_path("appdb_connection_runtime_teardown");
    open_and_reinstall(first_path.clone()).expect("first runtime cycle should succeed");

    let stale = get_db().expect("first db should remain globally installed");
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime should build")
        .block_on(async {
            stale
                .query("RETURN 1;")
                .await
                .expect("stale handle should stay usable while its worker is still alive");
        });

    let second_path = temp_path("appdb_connection_runtime_teardown");
    open_and_reinstall(second_path.clone()).expect("second runtime cycle should succeed");

    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime should build")
        .block_on(async {
            let db = get_db().expect("second db should be installed");
            db.query("RETURN 2;")
                .await
                .expect("fresh handle should succeed on a new runtime");
        });

    reset_db();
    drop(stale);
    let _ = std::fs::remove_dir_all(first_path);
    let _ = std::fs::remove_dir_all(second_path);
}

#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn reinit_db_replaces_a_closed_runtime() {
    let _guard = TEST_DB_LOCK
        .lock()
        .expect("test db lock should not be poisoned");
    reset_db();

    let first_path = temp_path("appdb_connection_reinit");
    reinit_db(first_path.clone())
        .await
        .expect("first init should succeed");
    let first = get_db().expect("first db should be installed");
    first
        .query("RETURN 1;")
        .await
        .expect("first query should succeed");

    reset_db();
    drop(first);

    let second_path = temp_path("appdb_connection_reinit");
    reinit_db(second_path.clone())
        .await
        .expect("second init should succeed");
    let second = get_db().expect("second db should be installed");
    second
        .query("RETURN 2;")
        .await
        .expect("second query should succeed");

    reset_db();
    let _ = std::fs::remove_dir_all(first_path);
    let _ = std::fs::remove_dir_all(second_path);
}

#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn repeated_reinit_keeps_cleanup_queries_usable() {
    let _guard = TEST_DB_LOCK
        .lock()
        .expect("test db lock should not be poisoned");
    reset_db();

    let first_path = temp_path("appdb_connection_repeat");
    reinit_db(first_path.clone())
        .await
        .expect("first init should succeed");
    let first = get_db().expect("first db should be installed");
    first
        .query("DEFINE TABLE temp_test; DELETE temp_test;")
        .await
        .expect("first cleanup should succeed");

    reset_db();
    drop(first);

    let second_path = temp_path("appdb_connection_repeat");
    reinit_db(second_path.clone())
        .await
        .expect("second init should succeed");
    let second = get_db().expect("second db should be installed");
    second
        .query("DEFINE TABLE temp_test; DELETE temp_test;")
        .await
        .expect("second cleanup should succeed");
    second
        .query("RETURN 1;")
        .await
        .expect("second query should succeed");

    reset_db();
    let _ = std::fs::remove_dir_all(first_path);
    let _ = std::fs::remove_dir_all(second_path);
}

fn temp_path(prefix: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("clock before epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("{prefix}_{}_{}", std::process::id(), nanos))
}