#![cfg(not(target_arch = "wasm32"))]
use minigraf::QueryResult;
use minigraf::db::{Minigraf, OpenOptions};
const PAGE_SIZE: usize = 4096;
fn count_results(result: QueryResult) -> usize {
match result {
QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
}
}
fn wal_path_for(db_path: &std::path::Path) -> std::path::PathBuf {
let mut p = db_path.as_os_str().to_owned();
p.push(".wal");
std::path::PathBuf::from(p)
}
#[test]
fn test_file_backed_basic_persistence() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("basic.graph");
{
let db = Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
}
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(n, 1, "Alice must survive close/reopen");
}
#[test]
fn test_wal_recovery_after_simulated_crash() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("crash.graph");
let wal_path = wal_path_for(&db_path);
{
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
},
)
.unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
std::mem::forget(db);
}
assert!(wal_path.exists(), "WAL must exist after simulated crash");
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n, 1,
"Alice must be recovered from WAL after simulated crash"
);
}
#[test]
fn test_no_duplicate_facts_after_post_checkpoint_crash() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("dedup.graph");
let wal_path = wal_path_for(&db_path);
{
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
},
)
.unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
std::mem::forget(db);
}
let wal_backup = std::fs::read(&wal_path).unwrap();
{
let db = Minigraf::open(&db_path).unwrap();
let n = count_results(
db.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(n, 1, "Alice must be visible in session 2");
}
assert!(!wal_path.exists(), "WAL must be deleted after normal close");
std::fs::write(&wal_path, &wal_backup).unwrap();
let db3 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db3.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n, 1,
"must have exactly 1 Alice — no duplicates after stale WAL replay"
);
}
#[test]
fn test_partial_wal_entry_discarded_earlier_entries_intact() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("partial.graph");
let wal_path = wal_path_for(&db_path);
{
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
},
)
.unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
std::mem::forget(db);
}
{
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&wal_path)
.unwrap();
file.write_all(&[0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF])
.unwrap();
}
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n, 1,
"exactly 1 fact (Alice) must survive despite partial WAL entry"
);
}
#[test]
fn test_manual_checkpoint_deletes_wal() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("manual_cp.graph");
let wal_path = wal_path_for(&db_path);
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
},
)
.unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
assert!(
wal_path.exists(),
"WAL must exist after write with high threshold"
);
db.checkpoint().unwrap();
assert!(
!wal_path.exists(),
"WAL must be deleted after manual checkpoint"
);
let n = count_results(
db.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(n, 1, "Alice must still be visible after checkpoint");
{
use std::io::Read;
let mut f = std::fs::File::open(&db_path).unwrap();
let mut page = vec![0u8; PAGE_SIZE];
f.read_exact(&mut page).unwrap();
let last_checkpointed_tx_count = u64::from_le_bytes(page[24..32].try_into().unwrap());
assert!(
last_checkpointed_tx_count > 0,
"last_checkpointed_tx_count must be set after checkpoint"
);
}
std::mem::forget(db);
let db2 = Minigraf::open(&db_path).unwrap();
let n2 = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n2, 1,
"Alice must be present after crash-reopen when already checkpointed"
);
}
#[test]
fn test_auto_checkpoint_fires_at_threshold() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("autocheckpoint.graph");
let wal_path = wal_path_for(&db_path);
{
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: 2,
..Default::default()
},
)
.unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
db.execute(r#"(transact [[:bob :name "Bob"]])"#).unwrap();
assert!(
!wal_path.exists(),
"WAL must be deleted after auto-checkpoint at threshold=2"
);
std::mem::forget(db);
}
assert!(
!wal_path.exists(),
"WAL must not exist after auto-checkpoint crash"
);
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n, 2,
"both Alice and Bob must survive via main file after auto-checkpoint"
);
}
#[test]
fn test_explicit_tx_all_or_nothing_commit() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("explicit_commit.graph");
{
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
},
)
.unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
tx.execute(r#"(transact [[:bob :name "Bob"]])"#).unwrap();
tx.commit().unwrap();
std::mem::forget(db);
}
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n, 2,
"both Alice and Bob must survive explicit commit + crash"
);
}
#[test]
fn test_explicit_tx_rollback_not_persisted() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("rollback.graph");
{
let db = Minigraf::open(&db_path).unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
tx.rollback();
}
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(n, 0, "rolled-back facts must not survive reopen");
}
#[test]
fn test_explicit_tx_multiple_transacts_rollback_not_persisted() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("multi_rollback.graph");
let opts = OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
};
{
let db = Minigraf::open_with_options(&db_path, opts).unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
tx.execute(r#"(transact [[:bob :name "Bob"]])"#).unwrap();
tx.rollback();
}
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(n, 0, "both rolled-back facts must not persist after reopen");
}
#[test]
fn test_concurrent_reads_while_writer_holds_lock() {
use std::sync::{Arc, Barrier};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
let db = Minigraf::open(&db_path).unwrap();
db.execute("(transact [[:alice :name \"Alice\"]])").unwrap();
db.checkpoint().unwrap();
let db2 = db.clone();
let barrier = Arc::new(Barrier::new(2));
let barrier2 = Arc::clone(&barrier);
let _tx = db.begin_write().unwrap();
let reader = std::thread::spawn(move || {
barrier2.wait(); count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
)
});
barrier.wait(); let n = reader.join().unwrap();
assert_eq!(
n, 1,
"reader must see committed state while writer holds the lock"
);
}
#[test]
fn test_implicit_tx_execute_survives_replay() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("implicit_tx.graph");
let wal_path = wal_path_for(&db_path);
{
let db = Minigraf::open_with_options(
&db_path,
OpenOptions {
wal_checkpoint_threshold: usize::MAX,
..Default::default()
},
)
.unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
std::mem::forget(db);
}
assert!(wal_path.exists(), "WAL must exist after simulated crash");
let db2 = Minigraf::open(&db_path).unwrap();
let n = count_results(
db2.execute("(query [:find ?name :where [?e :name ?name]])")
.unwrap(),
);
assert_eq!(
n, 1,
"Alice must be recovered via WAL replay after implicit execute() crash"
);
}
fn read_wal_bytes(db_path: &std::path::Path) -> Vec<u8> {
std::fs::read(wal_path_for(db_path)).unwrap_or_default()
}
fn write_wal_bytes(db_path: &std::path::Path, bytes: &[u8]) {
std::fs::write(wal_path_for(db_path), bytes).unwrap();
}
fn setup_db_with_one_fact() -> (tempfile::TempDir, std::path::PathBuf, Vec<u8>) {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Alice"]])"#).unwrap();
std::mem::forget(db);
}
let wal_bytes = read_wal_bytes(&db_path);
(dir, db_path, wal_bytes)
}
fn query_names(db_path: &std::path::Path) -> Vec<String> {
let db = minigraf::db::Minigraf::open(db_path).unwrap();
match db
.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap()
{
minigraf::QueryResult::QueryResults { results, .. } => results
.into_iter()
.flatten()
.filter_map(|v| match v {
minigraf::Value::String(s) => Some(s),
_ => None,
})
.collect(),
_ => vec![],
}
}
#[test]
fn wal_recover_truncated_length_header() {
let (_dir, db_path, wal_bytes) = setup_db_with_one_fact();
assert!(!wal_bytes.is_empty(), "WAL should have content");
write_wal_bytes(&db_path, &wal_bytes[..wal_bytes.len() / 2]);
let names = query_names(&db_path);
assert_eq!(names.len(), 0, "partial WAL entry must not be applied");
}
#[test]
fn wal_recover_truncated_payload() {
let (_dir, db_path, wal_bytes) = setup_db_with_one_fact();
let truncation_point = (wal_bytes.len() * 3) / 4;
write_wal_bytes(&db_path, &wal_bytes[..truncation_point]);
let names = query_names(&db_path);
assert_eq!(
names.len(),
0,
"entry with truncated payload must not be applied"
);
}
#[test]
fn wal_recover_bad_checksum_second_entry() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Alice"]])"#).unwrap();
db.execute(r#"(transact [[:e2 :name "Bob"]])"#).unwrap();
std::mem::forget(db);
}
let mut wal_bytes = read_wal_bytes(&db_path);
assert!(wal_bytes.len() > 36, "WAL too short to corrupt");
let n = wal_bytes.len();
wal_bytes[n - 4] ^= 0xFF;
wal_bytes[n - 3] ^= 0xFF;
write_wal_bytes(&db_path, &wal_bytes);
let names = query_names(&db_path);
assert_eq!(
names.len(),
1,
"only the entry before bad checksum should replay"
);
assert!(names.contains(&"Alice".to_string()), "Alice should survive");
}
#[test]
fn wal_recover_committed_tx_crash_before_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:e1 :name "Charlie"]])"#).unwrap();
tx.commit().unwrap();
std::mem::forget(db);
}
let names = query_names(&db_path);
assert_eq!(
names.len(),
1,
"committed tx must survive crash before checkpoint"
);
assert!(
names.contains(&"Charlie".to_string()),
"Charlie must be present"
);
}
#[test]
fn wal_recover_rollback_crash() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:e1 :name "Dave"]])"#).unwrap();
tx.rollback();
std::mem::forget(db);
}
let names = query_names(&db_path);
assert_eq!(
names.len(),
0,
"rolled-back fact must not appear after crash"
);
}
#[test]
fn wal_recover_multiple_committed_corrupt_tail() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Eve"]])"#).unwrap();
db.execute(r#"(transact [[:e2 :name "Frank"]])"#).unwrap();
std::mem::forget(db);
}
let mut wal_bytes = read_wal_bytes(&db_path);
wal_bytes.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00]);
write_wal_bytes(&db_path, &wal_bytes);
let names = query_names(&db_path);
assert_eq!(
names.len(),
2,
"both valid entries must replay; junk tail is discarded"
);
}
#[test]
fn wal_corrupt_tail_never_applied() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Grace"]])"#).unwrap();
std::mem::forget(db);
}
let mut wal_bytes = read_wal_bytes(&db_path);
let mut fake_entry: Vec<u8> = Vec::new();
fake_entry.extend_from_slice(&0u32.to_le_bytes());
fake_entry.extend_from_slice(&999u64.to_le_bytes());
fake_entry.extend_from_slice(&1000u64.to_le_bytes());
wal_bytes.extend_from_slice(&fake_entry);
write_wal_bytes(&db_path, &wal_bytes);
let names = query_names(&db_path);
assert!(names.contains(&"Grace".to_string()), "Grace should replay");
assert_eq!(names.len(), 1, "fake entry must not create phantom facts");
}
#[test]
fn write_lock_not_leaked_after_rollback() {
let db = minigraf::db::Minigraf::in_memory().unwrap();
let mut tx1 = db.begin_write().unwrap();
tx1.execute(r#"(transact [[:e1 :name "Temp"]])"#).unwrap();
tx1.rollback();
let mut tx2 = db.begin_write().unwrap();
tx2.execute(r#"(transact [[:e2 :name "Perm"]])"#).unwrap();
tx2.commit().unwrap();
let n = count_results(
db.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap(),
);
assert_eq!(n, 1, "only committed fact should be visible");
}
#[test]
fn write_state_clean_after_drop() {
let db = minigraf::db::Minigraf::in_memory().unwrap();
{
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:e1 :name "Ghost"]])"#).unwrap();
}
let mut tx2 = db.begin_write().unwrap();
tx2.execute(r#"(transact [[:e2 :name "Real"]])"#).unwrap();
tx2.commit().unwrap();
let n = count_results(
db.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap(),
);
assert_eq!(n, 1, "only committed fact visible after dropped tx");
}
#[test]
fn test_v2_file_opens_and_upgrades_to_v3_on_checkpoint() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("v2.graph");
{
let mut page = vec![0u8; PAGE_SIZE];
page[0..4].copy_from_slice(b"MGRF");
page[4..8].copy_from_slice(&2u32.to_le_bytes());
page[8..16].copy_from_slice(&1u64.to_le_bytes());
let mut file = std::fs::File::create(&db_path).unwrap();
file.write_all(&page).unwrap();
file.sync_all().unwrap();
}
{
let db = Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
db.checkpoint().unwrap();
}
let raw = std::fs::read(&db_path).unwrap();
assert!(
raw.len() >= PAGE_SIZE,
"file must be at least one page after checkpoint"
);
let magic = &raw[0..4];
let version = u32::from_le_bytes(raw[4..8].try_into().unwrap());
let last_checkpointed_tx_count = u64::from_le_bytes(raw[24..32].try_into().unwrap());
assert_eq!(version, 7, "file must be upgraded to v7 on checkpoint");
assert_eq!(magic, b"MGRF", "magic number must be preserved");
assert!(
last_checkpointed_tx_count > 0,
"last_checkpointed_tx_count must be set after checkpoint on v2→v6 upgrade"
);
}