#[path = "common/harness.rs"]
mod harness;
use std::time::Duration;
use harness::TestHarness;
use macrame::error::DbError;
use macrame::graph::{EdgeAssertion, TraversalBuilder};
use macrame::integrity::audit_current;
use macrame::{BranchId, ConceptUpsert, Database};
const EPOCH: &str = "1970-01-01T00:00:00.000000Z";
const OPEN: &str = "9999-12-31T23:59:59.999999Z";
const T1: &str = "1970-01-02T00:00:00.000000Z";
const STEP: Duration = Duration::from_secs(3_600);
fn id(name: &str) -> BranchId {
BranchId::new(name).unwrap()
}
async fn seed(h: &TestHarness) -> Database {
let db = h.db_with_fake_clock().await;
db.write_concepts(
["a", "b", "c"]
.iter()
.map(|n| ConceptUpsert::new(*n, "n").valid_from(EPOCH))
.collect(),
)
.await
.unwrap();
db.bulk_import(vec![
EdgeAssertion::new("a", "b", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN),
EdgeAssertion::new("b", "c", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN),
])
.await
.unwrap();
h.advance(STEP);
db
}
async fn reached(db: &Database, branch: Option<&str>) -> Vec<String> {
let mut b = TraversalBuilder::new("a");
if let Some(n) = branch {
b = b.on_branch(id(n));
}
let mut v = b.execute_ids(db.read_conn(), EPOCH).await.unwrap();
v.sort();
v
}
async fn hot_rows(db: &Database, table: &str, branch: &str) -> i64 {
db.read_conn()
.query(
&format!("SELECT COUNT(*) FROM {table} WHERE branch_id = ?1"),
libsql::params![branch],
)
.await
.unwrap()
.next()
.await
.unwrap()
.unwrap()
.get(0)
.unwrap()
}
async fn cold_scalar(db: &Database, sql: &str) -> Option<String> {
let conn = libsql::Builder::new_local(db.archive_path())
.build()
.await
.unwrap()
.connect()
.unwrap();
conn.query(sql, ())
.await
.unwrap()
.next()
.await
.unwrap()
.and_then(|r| r.get::<String>(0).ok())
}
#[tokio::test]
async fn an_abandoned_lineage_leaves_and_the_trunk_does_not_notice() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.write_concepts(vec![ConceptUpsert::new("d", "n")
.valid_from(EPOCH)
.on_branch(id("alt"))])
.await
.unwrap();
db.assert_edge(
EdgeAssertion::new("c", "d", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN)
.on_branch(id("alt")),
)
.await
.unwrap();
assert_eq!(reached(&db, Some("alt")).await, ["a", "b", "c", "d"]);
assert_eq!(reached(&db, None).await, ["a", "b", "c"]);
db.archive_branch(id("alt")).await.unwrap();
assert_eq!(
reached(&db, None).await,
["a", "b", "c"],
"the trunk never named the branch and must not have lost anything to it"
);
for table in ["links", "concepts", "transaction_log", "links_current"] {
assert_eq!(
hot_rows(&db, table, "alt").await,
0,
"{table} still holds rows for a lineage the ledger has forgotten"
);
}
assert_eq!(
hot_rows(&db, "branches", "alt").await,
0,
"the lineage record is the row that makes the omission correct rather \
than short: while it is there, a fold that skips the branch is a fold \
that lost rows"
);
let names: Vec<String> = db
.branches()
.await
.unwrap()
.into_iter()
.map(|b| b.id.to_string())
.collect();
assert_eq!(names, ["main"]);
}
#[tokio::test]
async fn a_read_naming_a_forgotten_lineage_is_refused() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.archive_branch(id("alt")).await.unwrap();
let err = TraversalBuilder::new("a")
.on_branch(id("alt"))
.execute_ids(db.read_conn(), EPOCH)
.await
.unwrap_err();
assert!(
matches!(&err, DbError::UnknownBranch(b) if b == "alt"),
"expected UnknownBranch naming the lineage, got {err:?}"
);
}
#[tokio::test]
async fn a_shadow_retirement_leaves_with_the_lineage_that_wrote_it() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.retire_edge_on("b", "c", "LEADSTO", EPOCH, T1, id("alt"))
.await
.unwrap();
db.archive_branch(id("alt")).await.unwrap();
assert_eq!(
reached(&db, None).await,
["a", "b", "c"],
"the trunk's own open row was never the branch's to retire, and it is \
not the branch's to take with it either"
);
assert_eq!(hot_rows(&db, "links", "alt").await, 0);
}
#[tokio::test]
async fn the_cold_file_can_still_say_what_lineage_a_row_belonged_to() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.assert_edge(
EdgeAssertion::new("a", "c", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN)
.on_branch(id("alt")),
)
.await
.unwrap();
db.archive_branch(id("alt")).await.unwrap();
let resolved = cold_scalar(
&db,
"SELECT b.branch_id || ' from ' || b.parent_id \
FROM links l JOIN branches b ON b.branch_id = l.branch_id \
WHERE l.source_id = 'a' AND l.target_id = 'c'",
)
.await;
assert_eq!(
resolved.as_deref(),
Some("alt from main"),
"the cold link must join to a lineage record in the same file"
);
let archived_at = cold_scalar(
&db,
"SELECT archived_at FROM branches WHERE branch_id = 'alt'",
)
.await
.expect("the lineage record must carry when it was forgotten");
assert!(
archived_at.starts_with("1970-"),
"archived_at is the injected wall clock, not a ledger fact: {archived_at}"
);
}
#[tokio::test]
async fn forgetting_a_lineage_leaves_no_drift() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.assert_edge(
EdgeAssertion::new("a", "c", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN)
.on_branch(id("alt")),
)
.await
.unwrap();
db.archive_branch(id("alt")).await.unwrap();
assert_eq!(audit_current(db.read_conn()).await.unwrap(), 0);
}
#[tokio::test]
async fn the_trunk_is_refused() {
let h = TestHarness::new();
let db = seed(&h).await;
let err = db.archive_branch(BranchId::main()).await.unwrap_err();
assert!(
matches!(&err, DbError::BranchNotArchivable { branch, reason }
if branch == "main" && reason.contains("trunk")),
"expected a refusal naming the trunk, got {err:?}"
);
assert_eq!(reached(&db, None).await, ["a", "b", "c"]);
}
#[tokio::test]
async fn a_name_that_is_not_registered_is_refused_as_unknown() {
let h = TestHarness::new();
let db = seed(&h).await;
let err = db.archive_branch(id("ghost")).await.unwrap_err();
assert!(
matches!(&err, DbError::UnknownBranch(b) if b == "ghost"),
"expected UnknownBranch rather than a refusal type only this surface \
raises, got {err:?}"
);
}
#[tokio::test]
async fn a_lineage_with_descendants_is_refused() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
h.advance(STEP);
db.fork(id("alt_child"), id("alt")).await.unwrap();
db.assert_edge(
EdgeAssertion::new("a", "c", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN)
.on_branch(id("alt")),
)
.await
.unwrap();
let err = db.archive_branch(id("alt")).await.unwrap_err();
assert!(
matches!(&err, DbError::BranchNotArchivable { branch, reason }
if branch == "alt" && reason.contains("descendants")),
"expected a refusal naming descendants, got {err:?}"
);
assert_eq!(
reached(&db, Some("alt_child")).await,
["a", "b", "c"],
"a refusal must change nothing: the child still reads through its parent"
);
assert_eq!(hot_rows(&db, "links", "alt").await, 1);
assert_eq!(hot_rows(&db, "branches", "alt").await, 1);
}
#[tokio::test]
async fn a_lineage_whose_concept_another_lineage_names_is_refused() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.write_concepts(vec![ConceptUpsert::new("d", "n")
.valid_from(EPOCH)
.on_branch(id("alt"))])
.await
.unwrap();
db.assert_edge(
EdgeAssertion::new("c", "d", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN),
)
.await
.unwrap();
let err = db.archive_branch(id("alt")).await.unwrap_err();
assert!(
matches!(&err, DbError::BranchNotArchivable { branch, reason }
if branch == "alt" && reason.contains('d')),
"the refusal must name the concept that is holding the lineage here, \
because that is the thing the caller has to retire: {err:?}"
);
assert_eq!(
reached(&db, None).await,
["a", "b", "c", "d"],
"a refusal must change nothing"
);
assert_eq!(hot_rows(&db, "concepts", "alt").await, 1);
}
#[tokio::test]
async fn a_lineage_that_only_names_its_own_concepts_is_archivable() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.write_concepts(vec![
ConceptUpsert::new("d", "n")
.valid_from(EPOCH)
.on_branch(id("alt")),
ConceptUpsert::new("e", "n")
.valid_from(EPOCH)
.on_branch(id("alt")),
])
.await
.unwrap();
db.assert_edge(
EdgeAssertion::new("d", "e", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN)
.on_branch(id("alt")),
)
.await
.unwrap();
let report = db.archive_branch(id("alt")).await.unwrap();
assert_eq!(report.links_archived, 1);
assert_eq!(report.concepts_archived, 2);
assert!(
report.log_entries_archived >= 3,
"two concept inserts and one link assertion, at least: {report:?}"
);
assert_eq!(hot_rows(&db, "concepts", "alt").await, 0);
assert_eq!(reached(&db, None).await, ["a", "b", "c"]);
}
#[tokio::test]
async fn the_lineages_log_entries_go_with_its_links() {
let h = TestHarness::new();
let db = seed(&h).await;
db.fork(id("alt"), BranchId::main()).await.unwrap();
db.assert_edge(
EdgeAssertion::new("a", "c", "LEADSTO")
.valid_from(EPOCH)
.valid_to(OPEN)
.on_branch(id("alt")),
)
.await
.unwrap();
db.assert_edge(
EdgeAssertion::new("a", "c", "MENTIONS")
.valid_from(EPOCH)
.valid_to(OPEN),
)
.await
.unwrap();
assert!(hot_rows(&db, "transaction_log", "alt").await > 0);
let trunk_before = hot_rows(&db, "transaction_log", "main").await;
db.archive_branch(id("alt")).await.unwrap();
assert_eq!(hot_rows(&db, "transaction_log", "alt").await, 0);
assert_eq!(
hot_rows(&db, "transaction_log", "main").await,
trunk_before,
"the trunk's entries surround the branch's and must be untouched"
);
}