use nodedb_client::NodeDb;
use nodedb_lite::{NodeDbLite, PagedbStorageMem};
use nodedb_types::document::Document;
use nodedb_types::value::Value;
use nodedb_lite::engine::document::history::ops::is_bitemporal;
async fn open_db() -> std::sync::Arc<NodeDbLite<PagedbStorageMem>> {
let storage = PagedbStorageMem::open_in_memory().await.unwrap();
NodeDbLite::open(storage).await.unwrap()
}
async fn open_db_with_storage() -> (
std::sync::Arc<NodeDbLite<PagedbStorageMem>>,
PagedbStorageMem,
) {
let storage = PagedbStorageMem::open_in_memory().await.unwrap();
let storage_clone = storage.clone();
let db = NodeDbLite::open(storage).await.unwrap();
(db, storage_clone)
}
#[tokio::test]
async fn create_bitemporal_collection_persists_flag() {
let (db, storage) = open_db_with_storage().await;
db.execute_sql(
"CREATE COLLECTION bitemp_flag_test WITH (bitemporal=true)",
&[],
)
.await
.unwrap();
let flag = is_bitemporal(&storage, "bitemp_flag_test").await.unwrap();
assert!(flag, "bitemporal flag must be persisted after DDL");
}
#[tokio::test]
async fn put_then_get_returns_doc() {
let db = open_db().await;
db.execute_sql("CREATE COLLECTION bt_roundtrip WITH (bitemporal=true)", &[])
.await
.unwrap();
let mut doc = Document::new("doc1");
doc.set("title", Value::String("hello".into()));
db.document_put("bt_roundtrip", doc).await.unwrap();
let result = db.document_get("bt_roundtrip", "doc1").await.unwrap();
let fetched = result.expect("document must be present after put");
assert_eq!(fetched.id, "doc1");
assert_eq!(fetched.get_str("title"), Some("hello"));
}
#[tokio::test]
async fn put_v1_then_v2_then_get_as_of_returns_correct_version() {
use nodedb_lite::engine::document::history::ops::versioned_put;
let (db, storage) = open_db_with_storage().await;
db.execute_sql("CREATE COLLECTION bt_asof WITH (bitemporal=true)", &[])
.await
.unwrap();
let body_v1 =
nodedb_types::json_msgpack::json_to_msgpack_or_empty(&serde_json::json!({"version": "v1"}));
versioned_put(&storage, "bt_asof", "doc1", &body_v1, 100, None, None)
.await
.unwrap();
let body_v2 =
nodedb_types::json_msgpack::json_to_msgpack_or_empty(&serde_json::json!({"version": "v2"}));
versioned_put(&storage, "bt_asof", "doc1", &body_v2, 200, None, None)
.await
.unwrap();
let v1 = db
.document_get_as_of("bt_asof", "doc1", Some(150), None)
.await
.unwrap()
.expect("v1 must be visible at t=150");
assert_eq!(v1.get_str("version"), Some("v1"));
let v2 = db
.document_get_as_of("bt_asof", "doc1", Some(250), None)
.await
.unwrap()
.expect("v2 must be visible at t=250");
assert_eq!(v2.get_str("version"), Some("v2"));
}
#[tokio::test]
async fn delete_on_bitemporal_appends_tombstone_not_hard_delete() {
use nodedb_lite::engine::document::history::ops::{versioned_put, versioned_tombstone};
let (db, storage) = open_db_with_storage().await;
db.execute_sql("CREATE COLLECTION bt_delete WITH (bitemporal=true)", &[])
.await
.unwrap();
let body =
nodedb_types::json_msgpack::json_to_msgpack_or_empty(&serde_json::json!({"name": "alive"}));
versioned_put(&storage, "bt_delete", "doc1", &body, 100, None, None)
.await
.unwrap();
versioned_tombstone(&storage, "bt_delete", "doc1", 200, None)
.await
.unwrap();
let current = db.document_get("bt_delete", "doc1").await.unwrap();
assert!(
current.is_none(),
"document must not be returned after tombstone"
);
let historical = db
.document_get_as_of("bt_delete", "doc1", Some(150), None)
.await
.unwrap()
.expect("document must be visible before tombstone timestamp");
assert_eq!(historical.get_str("name"), Some("alive"));
}
#[tokio::test]
async fn put_with_valid_time_then_query_with_valid_filter() {
let db = open_db().await;
db.execute_sql(
"CREATE COLLECTION bt_valid_time WITH (bitemporal=true)",
&[],
)
.await
.unwrap();
let mut doc = Document::new("evt1");
doc.set("event", Value::String("scheduled".into()));
db.document_put_with_valid_time(
"bt_valid_time",
doc,
Some(300), Some(500), )
.await
.unwrap();
let found = db
.document_get_as_of("bt_valid_time", "evt1", None, Some(400))
.await
.unwrap()
.expect("event must be visible at valid_time=400");
assert_eq!(found.get_str("event"), Some("scheduled"));
let not_found = db
.document_get_as_of("bt_valid_time", "evt1", None, Some(200))
.await
.unwrap();
assert!(
not_found.is_none(),
"valid_time 200 is before valid_from 300"
);
let not_found_at_boundary = db
.document_get_as_of("bt_valid_time", "evt1", None, Some(500))
.await
.unwrap();
assert!(
not_found_at_boundary.is_none(),
"valid_time 500 == valid_until is excluded"
);
}
#[tokio::test]
async fn get_as_of_on_non_bitemporal_collection_errors() {
let db = open_db().await;
let mut doc = Document::new("x1");
doc.set("field", Value::String("value".into()));
db.document_put("plain_docs", doc).await.unwrap();
let result = db
.document_get_as_of("plain_docs", "x1", Some(9999), None)
.await;
assert!(
result.is_err(),
"AS-OF on a non-bitemporal collection must return an error"
);
}
#[tokio::test]
async fn non_bitemporal_collection_still_works_unchanged() {
let db = open_db().await;
let mut doc = Document::new("n1");
doc.set("body", Value::String("original".into()));
db.document_put("notes", doc).await.unwrap();
let fetched = db.document_get("notes", "n1").await.unwrap();
let fetched = fetched.expect("document must exist after put");
assert_eq!(fetched.get_str("body"), Some("original"));
let mut updated = Document::new("n1");
updated.set("body", Value::String("updated".into()));
db.document_put("notes", updated).await.unwrap();
let after_update = db
.document_get("notes", "n1")
.await
.unwrap()
.expect("document must exist after update");
assert_eq!(after_update.get_str("body"), Some("updated"));
db.document_delete("notes", "n1").await.unwrap();
let after_delete = db.document_get("notes", "n1").await.unwrap();
assert!(
after_delete.is_none(),
"document must be absent after hard delete on plain collection"
);
}
#[tokio::test]
async fn sql_ddl_bitemporal_collection_is_queryable_and_persists_meta() {
use nodedb_lite::PagedbStorageMem;
use nodedb_lite::storage::engine::StorageEngine;
let storage = PagedbStorageMem::open_in_memory().await.unwrap();
let storage_clone = storage.clone();
let db = NodeDbLite::open(storage).await.unwrap();
db.execute_sql("CREATE COLLECTION entries WITH (bitemporal=true)", &[])
.await
.expect("create bitemporal collection via SQL DDL");
let raw = storage_clone
.get(nodedb_types::Namespace::Meta, b"collection:entries")
.await
.expect("meta read")
.expect("CollectionMeta must be persisted for a SQL-DDL bitemporal collection");
let meta: nodedb_lite::nodedb::collection::CollectionMeta =
sonic_rs::from_slice(&raw).expect("decode CollectionMeta");
assert!(
meta.bitemporal,
"persisted meta must carry bitemporal=true, got {meta:?}"
);
for i in 0..3 {
let mut doc = Document::new(format!("d{i}"));
doc.set("body", Value::String(format!("row {i}")));
db.document_put("entries", doc).await.unwrap();
}
let r = db
.execute_sql("SELECT id FROM entries", &[])
.await
.expect("SELECT on a bitemporal document collection must resolve, not error");
assert_eq!(
r.rows.len(),
3,
"SELECT id FROM entries must return the three inserted documents"
);
}
#[tokio::test]
async fn same_millisecond_writes_keep_distinct_versions() {
use nodedb_lite::storage::engine::StorageEngine;
use nodedb_types::Namespace;
let (db, storage) = open_db_with_storage().await;
db.execute_sql("CREATE COLLECTION bt_samems WITH (bitemporal=true)", &[])
.await
.unwrap();
let mut doc = Document::new("doc1");
doc.set("state", Value::String("open".into()));
db.document_put("bt_samems", doc).await.unwrap();
let mut closed = Document::new("doc1");
closed.set("state", Value::String("open".into()));
db.document_put_with_valid_time("bt_samems", closed, Some(1), Some(1_000))
.await
.unwrap();
let prefix = b"bt_samems:doc1\x00".to_vec();
let rows = storage
.scan_prefix(Namespace::DocumentHistory, &prefix)
.await
.unwrap();
assert_eq!(
rows.len(),
2,
"same-millisecond put + valid-time close must retain 2 distinct history \
versions, got {}",
rows.len()
);
}