use fathomdb_engine::{
DenseReadiness, Engine, EngineError, InitialState, LifecycleState, ProjectionFts,
ProjectionRole, ProjectionSpec, ProjectionVector, SourceId,
};
use fathomdb_schema::SQLITE_SUFFIX;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn db_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(format!("{name}{SQLITE_SUFFIX}"))
}
fn roles(rs: &[ProjectionRole]) -> BTreeSet<ProjectionRole> {
rs.iter().copied().collect()
}
fn spec(name: &str, rs: &[ProjectionRole], fts: bool, vector: bool) -> ProjectionSpec {
ProjectionSpec {
name: name.to_string(),
roles: roles(rs),
fts: fts.then_some(ProjectionFts { tokenizer: None }),
vector: vector.then_some(ProjectionVector { embedder: None, dense_readiness: None }),
source: None,
}
}
fn node(logical_id: &str, source: &str, body_json: &str) -> fathomdb_engine::PreparedWrite {
fathomdb_engine::PreparedWrite::Node {
kind: "doc".to_string(),
body: body_json.to_string(),
source_id: SourceId::new(source).expect("source id"),
logical_id: Some(logical_id.to_string()),
state: InitialState::Active,
reason: None,
valid_from: None,
valid_until: None,
}
}
fn node_state(
logical_id: &str,
source: &str,
body_json: &str,
state: InitialState,
) -> fathomdb_engine::PreparedWrite {
fathomdb_engine::PreparedWrite::Node {
kind: "doc".to_string(),
body: body_json.to_string(),
source_id: SourceId::new(source).expect("source id"),
logical_id: Some(logical_id.to_string()),
state,
reason: None,
valid_from: None,
valid_until: None,
}
}
fn active_cursor(path: &Path, logical_id: &str) -> i64 {
let conn = ro(path);
conn.query_row(
"SELECT write_cursor FROM canonical_nodes \
WHERE logical_id = ?1 AND superseded_at IS NULL",
[logical_id],
|r| r.get::<_, i64>(0),
)
.unwrap()
}
fn ro(path: &Path) -> rusqlite::Connection {
rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.expect("open read-only")
}
fn legacy_add_vector_subobject(path: &Path, name: &str) {
let conn = rusqlite::Connection::open(path).expect("open rw");
let n = conn
.execute(
"UPDATE _fathomdb_projection_registry SET vector_declared = 1 WHERE name = ?1",
[name],
)
.expect("legacy vector sub-object");
assert_eq!(n, 1, "the registry row must exist before the legacy sub-object is added");
}
fn eav_values(path: &Path, attr_name: &str) -> Vec<String> {
let conn = ro(path);
let mut stmt = conn
.prepare(
"SELECT attr_value FROM canonical_attributes WHERE attr_name = ?1 ORDER BY attr_value",
)
.unwrap();
let v: Vec<String> = stmt
.query_map([attr_name], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
v
}
fn eav_filter(path: &Path, attr_name: &str, value: &str) -> Vec<i64> {
let conn = ro(path);
let mut stmt = conn
.prepare(
"SELECT write_cursor FROM canonical_attributes
WHERE attr_name = ?1 AND attr_value = ?2 ORDER BY write_cursor",
)
.unwrap();
stmt.query_map([attr_name, value], |r| r.get::<_, i64>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
}
fn property_fts_match(path: &Path, attr_name: &str, query: &str) -> Vec<i64> {
let conn = ro(path);
let mut stmt = conn
.prepare(
"SELECT write_cursor FROM property_search_index
WHERE attr_name = ?1 AND property_search_index MATCH ?2 ORDER BY write_cursor",
)
.unwrap();
stmt.query_map([attr_name, query], |r| r.get::<_, i64>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
}
fn property_fts_rowcount(path: &Path, attr_name: &str) -> i64 {
let conn = ro(path);
conn.query_row(
"SELECT COUNT(*) FROM property_search_index WHERE attr_name = ?1",
[attr_name],
|r| r.get(0),
)
.unwrap()
}
fn body_fts_counts(path: &Path) -> (i64, i64) {
let conn = ro(path);
let a: i64 = conn.query_row("SELECT COUNT(*) FROM search_index", [], |r| r.get(0)).unwrap();
let b: i64 = conn.query_row("SELECT COUNT(*) FROM search_index_v2", [], |r| r.get(0)).unwrap();
(a, b)
}
#[test]
fn configure_and_read_projections_round_trip() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "round_trip")).unwrap();
let engine = &opened.engine;
let s = spec("status", &[ProjectionRole::Filterable, ProjectionRole::Searchable], true, false);
engine.configure_projections(std::slice::from_ref(&s), &[]).unwrap();
let back = engine.read_projections().unwrap();
assert_eq!(back, vec![s], "read.projections must round-trip the declared spec verbatim");
}
#[test]
fn idempotent_reregistration_is_a_noop() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "idempotent")).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
let s = spec("status", &[ProjectionRole::Filterable], false, false);
let first = engine.configure_projections(std::slice::from_ref(&s), &[]).unwrap();
assert!(!first.unchanged, "first apply builds the projection");
assert_eq!(first.built, vec!["status".to_string()]);
let second = engine.configure_projections(std::slice::from_ref(&s), &[]).unwrap();
assert!(second.unchanged, "identical re-registration must diff to a no-op");
assert!(second.built.is_empty() && second.dropped.is_empty() && second.deferred.is_empty());
}
#[test]
fn role_add_builds_and_explicit_drop_drops_exactly_one() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "add_drop");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open","title":"hello world"}"#)]).unwrap();
engine
.configure_projections(&[spec("status", &[ProjectionRole::Filterable], false, false)], &[])
.unwrap();
let d = engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
assert_eq!(d.built, vec!["status".to_string()], "the role add rebuilds exactly `status`");
engine
.configure_projections(&[spec("title", &[ProjectionRole::Searchable], true, false)], &[])
.unwrap();
let omit = engine
.configure_projections(&[spec("title", &[ProjectionRole::Searchable], true, false)], &[])
.unwrap();
assert!(omit.dropped.is_empty(), "omitting `status` must NOT drop it (C3)");
assert_eq!(
engine.read_projections().unwrap().len(),
2,
"both projections still declared after an omission"
);
let drop = engine.configure_projections(&[], &["status".to_string()]).unwrap();
assert_eq!(drop.dropped, vec!["status".to_string()]);
let remaining: Vec<String> =
engine.read_projections().unwrap().into_iter().map(|s| s.name).collect();
assert_eq!(remaining, vec!["title".to_string()], "only `status` dropped");
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert!(eav_values(&path, "status").is_empty(), "dropped attr's EAV rows are gone");
assert_eq!(property_fts_rowcount(&path, "status"), 0, "dropped attr's property-FTS rows gone");
assert_eq!(property_fts_rowcount(&path, "title"), 1, "the un-dropped projection is untouched");
}
#[test]
fn destructive_change_requires_explicit_drop() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "destructive")).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
let err = engine
.configure_projections(&[spec("status", &[ProjectionRole::Filterable], false, false)], &[])
.unwrap_err();
match err {
EngineError::ProjectionDestructive { name, .. } => assert_eq!(name, "status"),
other => panic!("expected ProjectionDestructive, got {other:?}"),
}
assert_eq!(
engine.read_projections().unwrap()[0].roles,
roles(&[ProjectionRole::Filterable, ProjectionRole::Searchable]),
"a refused destructive change must not partially apply"
);
let ok = engine
.configure_projections(
&[spec("status", &[ProjectionRole::Filterable], false, false)],
&["status".to_string()],
)
.unwrap();
assert_eq!(ok.dropped, vec!["status".to_string()]);
assert_eq!(
engine.read_projections().unwrap()[0].roles,
roles(&[ProjectionRole::Filterable]),
"the explicit drop+re-declare rebuilds with the reduced role set"
);
}
#[test]
fn rankable_is_graceful_deferred_never_blocking() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "rankable");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"importance":"high"}"#)]).unwrap();
let d = engine
.configure_projections(
&[spec("importance", &[ProjectionRole::Rankable], false, false)],
&[],
)
.unwrap();
assert!(d.built.is_empty(), "rankable builds no same-transaction projection");
assert_eq!(d.deferred, vec!["importance".to_string()], "rankable is reported deferred");
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert!(eav_values(&path, "importance").is_empty(), "rankable-only writes no EAV value");
}
#[test]
fn vector_subobject_is_stored_not_built() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "vector_stored");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"summary":"a dense meaning"}"#)]).unwrap();
let s = spec("summary", &[ProjectionRole::Searchable], false, true);
let d = engine.configure_projections(std::slice::from_ref(&s), &[]).unwrap();
assert_eq!(d.deferred, vec!["summary".to_string()], "the vector sub-target defers to Slice 20");
let back = engine.read_projections().unwrap();
assert_eq!(
back,
vec![ProjectionSpec {
vector: Some(ProjectionVector {
embedder: None,
dense_readiness: Some(DenseReadiness::Unavailable),
}),
..s.clone()
}],
"vector sub-object persists verbatim, plus the engine-set readiness"
);
assert_eq!(
back[0].vector.as_ref().unwrap().embedder,
s.vector.as_ref().unwrap().embedder,
"the declared part of the sub-object is unchanged by the readiness attach"
);
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert_eq!(eav_values(&path, "summary"), vec!["a dense meaning".to_string()]);
assert_eq!(
property_fts_rowcount(&path, "summary"),
0,
"no property-FTS built for a vector-only"
);
}
#[test]
fn property_filter_returns_correct_rows() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "filter");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("A", "src:a", r#"{"status":"open"}"#)]).unwrap();
engine.write(&[node("B", "src:b", r#"{"status":"closed"}"#)]).unwrap();
engine
.configure_projections(&[spec("status", &[ProjectionRole::Filterable], false, false)], &[])
.unwrap();
engine.write(&[node("C", "src:c", r#"{"status":"open"}"#)]).unwrap();
engine.write(&[node("D", "src:d", r#"{"other":"x"}"#)]).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert_eq!(
eav_values(&path, "status"),
vec!["closed".to_string(), "open".to_string(), "open".to_string()],
"backfill + same-transaction writes populate the EAV store; the attribute-less node adds none"
);
assert_eq!(eav_filter(&path, "status", "open"), vec![1, 3]);
assert_eq!(eav_filter(&path, "status", "closed"), vec![2]);
}
#[test]
fn property_fts_search_returns_correct_rows() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "pfts");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("A", "src:a", r#"{"title":"the quick brown fox"}"#)]).unwrap();
engine.write(&[node("B", "src:b", r#"{"title":"lazy dogs sleeping"}"#)]).unwrap();
engine
.configure_projections(&[spec("title", &[ProjectionRole::Searchable], true, false)], &[])
.unwrap();
engine.write(&[node("C", "src:c", r#"{"title":"a brown bear"}"#)]).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert_eq!(property_fts_match(&path, "title", "brown"), vec![1, 3]);
assert_eq!(property_fts_match(&path, "title", "fox"), vec![1]);
assert_eq!(property_fts_match(&path, "title", "sleep"), vec![2]);
}
#[test]
fn body_fts_behaviour_is_unchanged_by_projection_config() {
let dir = TempDir::new().unwrap();
let base = db_path(&dir, "body_base");
let with_proj = db_path(&dir, "body_proj");
{
let opened = Engine::open(base.clone()).unwrap();
opened.engine.write(&[node("A", "src:a", r#"{"status":"open"}"#)]).unwrap();
opened.engine.write(&[node("B", "src:b", r#"{"status":"closed"}"#)]).unwrap();
opened.engine.write(&[node("C", "src:c", r#"{"status":"open"}"#)]).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
}
{
let opened = Engine::open(with_proj.clone()).unwrap();
opened.engine.write(&[node("A", "src:a", r#"{"status":"open"}"#)]).unwrap();
opened.engine.write(&[node("B", "src:b", r#"{"status":"closed"}"#)]).unwrap();
opened
.engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
opened.engine.write(&[node("C", "src:c", r#"{"status":"open"}"#)]).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
}
assert_eq!(
body_fts_counts(&base),
body_fts_counts(&with_proj),
"body-FTS (search_index / search_index_v2) must be byte-stable whether or not a \
projection is declared — property projections are an independent channel"
);
}
#[test]
fn erase_source_reaches_attribute_projections() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "erase");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("A", "src:secret", r#"{"title":"sensitive personal note"}"#)]).unwrap();
engine.write(&[node("B", "src:other", r#"{"title":"unrelated public note"}"#)]).unwrap();
engine
.configure_projections(&[spec("title", &[ProjectionRole::Searchable], true, false)], &[])
.unwrap();
engine.erase_source("src:secret").unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert_eq!(
eav_values(&path, "title"),
vec!["unrelated public note".to_string()],
"the erased node's EAV attribute value must not survive on disk"
);
assert!(
property_fts_match(&path, "title", "sensitive").is_empty(),
"the erased node's property-FTS row must not survive on disk"
);
assert_eq!(
property_fts_match(&path, "title", "unrelated"),
vec![2],
"the un-erased node's property-FTS row survives"
);
let conn = ro(&path);
let leaked: i64 = conn
.query_row(
"SELECT COUNT(*) FROM canonical_attributes WHERE attr_value LIKE '%sensitive%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(leaked, 0, "no erased attribute value may remain at rest");
}
#[test]
fn scalar_json_attributes_project_number_bool_and_string() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "scalars");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[
spec(
"score",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
),
spec("flag", &[ProjectionRole::Filterable], false, false),
spec("label", &[ProjectionRole::Filterable], false, false),
],
&[],
)
.unwrap();
engine.write(&[node("N", "src:n", r#"{"score":3,"flag":true,"label":"open"}"#)]).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert_eq!(
eav_values(&path, "label"),
vec!["open".to_string()],
"string control still projects"
);
assert_eq!(
eav_values(&path, "score"),
vec!["3".to_string()],
"a JSON number attribute must project into canonical_attributes"
);
assert_eq!(
eav_values(&path, "flag"),
vec!["true".to_string()],
"a JSON bool attribute must project into canonical_attributes"
);
assert_eq!(eav_filter(&path, "score", "3"), vec![1]);
assert_eq!(eav_filter(&path, "flag", "true"), vec![1]);
assert_eq!(
property_fts_match(&path, "score", "3"),
vec![1],
"a searchable JSON number must populate property_search_index"
);
}
#[test]
fn supersession_purges_stale_attribute_projection_rows() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "supersede_purge");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
engine.write(&[node("L", "src:l", r#"{"status":"open"}"#)]).unwrap();
engine.write(&[node("L", "src:l", r#"{"status":"closed"}"#)]).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
let conn = ro(&path);
let superseded_cursor: i64 = conn
.query_row(
"SELECT write_cursor FROM canonical_nodes \
WHERE logical_id = 'L' AND superseded_at IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
let active_cursor: i64 = conn
.query_row(
"SELECT write_cursor FROM canonical_nodes \
WHERE logical_id = 'L' AND superseded_at IS NULL AND state = 'active'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
eav_values(&path, "status"),
vec!["closed".to_string()],
"only the active value must survive at rest"
);
let stale_eav: i64 = conn
.query_row(
"SELECT COUNT(*) FROM canonical_attributes WHERE write_cursor = ?1",
[superseded_cursor],
|r| r.get(0),
)
.unwrap();
assert_eq!(stale_eav, 0, "the superseded cursor's EAV rows must be purged");
let stale_fts: i64 = conn
.query_row(
"SELECT COUNT(*) FROM property_search_index WHERE write_cursor = ?1",
[superseded_cursor],
|r| r.get(0),
)
.unwrap();
assert_eq!(stale_fts, 0, "the superseded cursor's property-FTS rows must be purged");
assert_eq!(
eav_filter(&path, "status", "open"),
Vec::<i64>::new(),
"the stale 'open' value must not filter-match after supersession"
);
assert_eq!(eav_filter(&path, "status", "closed"), vec![active_cursor]);
assert_eq!(
property_fts_match(&path, "status", "open"),
Vec::<i64>::new(),
"the stale 'open' property-FTS row must not match after supersession"
);
assert_eq!(property_fts_match(&path, "status", "closed"), vec![active_cursor]);
}
#[test]
fn boot_rederive_converges_after_simulated_crash() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "rederive");
{
let opened = Engine::open(path.clone()).unwrap();
opened.engine.write(&[node("A", "src:a", r#"{"title":"alpha meaning"}"#)]).unwrap();
opened.engine.write(&[node("B", "src:b", r#"{"title":"beta meaning"}"#)]).unwrap();
opened
.engine
.configure_projections(
&[spec(
"title",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
}
assert_eq!(eav_values(&path, "title").len(), 2, "precondition: projection populated");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute("DELETE FROM canonical_attributes", []).unwrap();
conn.execute("DELETE FROM property_search_index", []).unwrap();
let regcount: i64 = conn
.query_row("SELECT COUNT(*) FROM _fathomdb_projection_registry", [], |r| r.get(0))
.unwrap();
assert_eq!(regcount, 1, "the durable registry row survives the simulated crash");
}
assert!(eav_values(&path, "title").is_empty(), "simulated-crash precondition: cache is empty");
{
let opened = Engine::open(path.clone()).unwrap();
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
}
assert_eq!(
eav_values(&path, "title"),
vec!["alpha meaning".to_string(), "beta meaning".to_string()],
"boot re-derive must rebuild the EAV store from canonical state"
);
assert_eq!(
property_fts_match(&path, "title", "beta"),
vec![2],
"boot re-derive must rebuild the property-FTS shadow too"
);
{
let opened = Engine::open(path.clone()).unwrap();
opened.engine.close().unwrap();
}
assert_eq!(
eav_values(&path, "title").len(),
2,
"boot re-derive is idempotent — a second open must not duplicate rows"
);
}
#[test]
fn pending_node_is_not_projected_until_promoted() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "pending_gate");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
engine
.write(&[node_state("P", "src:p", r#"{"status":"quarantined"}"#, InitialState::Pending)])
.unwrap();
engine.drain(5_000).unwrap();
assert!(
eav_values(&path, "status").is_empty(),
"a pending node must NOT project attributes into canonical_attributes"
);
assert_eq!(
property_fts_rowcount(&path, "status"),
0,
"a pending node must NOT populate property_search_index"
);
engine.transition("P", LifecycleState::Active, None).unwrap();
engine.drain(5_000).unwrap();
let cursor = active_cursor(&path, "P");
assert_eq!(
eav_values(&path, "status"),
vec!["quarantined".to_string()],
"promotion (pending → active) must project the withheld attribute"
);
assert_eq!(
eav_filter(&path, "status", "quarantined"),
vec![cursor],
"promoted node's attribute must be filter-matchable"
);
assert_eq!(
property_fts_match(&path, "status", "quarantined"),
vec![cursor],
"promotion must populate property_search_index"
);
opened.engine.close().unwrap();
}
#[test]
fn active_delete_purges_and_undelete_reprojects() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "delete_undelete");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
engine.write(&[node("A", "src:a", r#"{"status":"live"}"#)]).unwrap();
engine.drain(5_000).unwrap();
let cursor = active_cursor(&path, "A");
assert_eq!(
eav_values(&path, "status"),
vec!["live".to_string()],
"control: an active node projects at write"
);
assert_eq!(
property_fts_match(&path, "status", "live"),
vec![cursor],
"control: an active node populates property_search_index at write"
);
engine.transition("A", LifecycleState::Deleted, Some("removed".to_string())).unwrap();
engine.drain(5_000).unwrap();
assert!(
eav_values(&path, "status").is_empty(),
"soft-delete (active → deleted) must purge canonical_attributes rows"
);
assert_eq!(
property_fts_rowcount(&path, "status"),
0,
"soft-delete must purge property_search_index rows"
);
engine.transition("A", LifecycleState::Active, None).unwrap();
engine.drain(5_000).unwrap();
let cursor = active_cursor(&path, "A");
assert_eq!(
eav_values(&path, "status"),
vec!["live".to_string()],
"undelete (deleted → active) must re-project the attribute"
);
assert_eq!(
property_fts_match(&path, "status", "live"),
vec![cursor],
"undelete must re-populate property_search_index"
);
opened.engine.close().unwrap();
}
#[test]
fn pending_reject_projects_nothing() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "reject");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine
.configure_projections(&[spec("status", &[ProjectionRole::Filterable], false, false)], &[])
.unwrap();
engine
.write(&[node_state("P", "src:p", r#"{"status":"spam"}"#, InitialState::Pending)])
.unwrap();
engine.drain(5_000).unwrap();
assert!(eav_values(&path, "status").is_empty(), "pending write projects nothing");
engine.transition("P", LifecycleState::Deleted, Some("rejected".to_string())).unwrap();
engine.drain(5_000).unwrap();
assert!(
eav_values(&path, "status").is_empty(),
"reject (pending → deleted) must leave the projection empty"
);
opened.engine.close().unwrap();
}
#[test]
fn backslash_projection_name_is_rejected() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "backslash_name");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
let err = engine
.configure_projections(&[spec("a\\b", &[ProjectionRole::Filterable], false, false)], &[])
.unwrap_err();
match err {
EngineError::InvalidArgument { msg } => {
assert!(
msg.contains("projection") && msg.contains('\\'),
"the typed refusal must name the offending projection name, got: {msg}"
);
}
other => panic!("expected InvalidArgument for a backslash name, got {other:?}"),
}
let drop_err = engine.configure_projections(&[], &["c\\d".to_string()]).unwrap_err();
assert!(
matches!(drop_err, EngineError::InvalidArgument { .. }),
"a backslash drop name must be refused too, got {drop_err:?}"
);
assert!(
engine.read_projections().unwrap().is_empty(),
"a refused unsafe-name config must not partially register"
);
opened.engine.close().unwrap();
}
#[test]
fn duplicate_projection_name_in_one_request_is_rejected() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "dup_name");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
let err = engine
.configure_projections(
&[
spec("status", &[ProjectionRole::Searchable], true, false),
spec("status", &[ProjectionRole::Rankable], false, false),
],
&[],
)
.unwrap_err();
match err {
EngineError::InvalidArgument { msg } => assert!(
msg.contains("status") && msg.contains("duplicate"),
"the typed refusal must name the duplicated projection, got: {msg}"
),
other => panic!("expected InvalidArgument for a duplicate name, got {other:?}"),
}
assert!(
engine.read_projections().unwrap().is_empty(),
"a refused duplicate-name request must not partially register"
);
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
assert!(
eav_values(&path, "status").is_empty(),
"a refused duplicate-name request must write no EAV value"
);
}
#[test]
fn duplicate_drop_entry_in_one_request_is_rejected() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "dup_drop")).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
engine
.configure_projections(&[spec("status", &[ProjectionRole::Filterable], false, false)], &[])
.unwrap();
let err = engine
.configure_projections(&[], &["status".to_string(), "status".to_string()])
.unwrap_err();
match err {
EngineError::InvalidArgument { msg } => assert!(
msg.contains("status") && msg.contains("duplicate"),
"the typed refusal must name the duplicated drop, got: {msg}"
),
other => panic!("expected InvalidArgument for a duplicate drop, got {other:?}"),
}
assert_eq!(
engine.read_projections().unwrap().len(),
1,
"a refused duplicate-drop request must not partially drop"
);
opened.engine.close().unwrap();
}
#[test]
fn name_in_both_specs_and_drop_is_the_supported_rebuild() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "both_lists");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
engine
.configure_projections(
&[spec(
"status",
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
false,
)],
&[],
)
.unwrap();
let d = engine
.configure_projections(
&[spec("status", &[ProjectionRole::Filterable], false, false)],
&["status".to_string()],
)
.unwrap();
assert_eq!(d.dropped, vec!["status".to_string()], "the explicit drop is reported");
assert_eq!(d.built, vec!["status".to_string()], "the re-declared spec is (re)built");
assert_eq!(
engine.read_projections().unwrap()[0].roles,
roles(&[ProjectionRole::Filterable]),
"the registry reflects the re-declared (reduced) role set"
);
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
}
#[test]
fn empty_request_is_a_noop() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "empty_req")).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
let d = engine.configure_projections(&[], &[]).unwrap();
assert_eq!(d, fathomdb_engine::ProjectionDelta { unchanged: true, ..Default::default() });
assert!(engine.read_projections().unwrap().is_empty(), "an empty request declares nothing");
opened.engine.close().unwrap();
}
#[test]
fn dropping_an_absent_name_is_a_clean_noop() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "drop_absent")).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"status":"open"}"#)]).unwrap();
let d = engine.configure_projections(&[], &["ghost".to_string()]).unwrap();
assert!(d.dropped.is_empty(), "dropping an absent name reports no drop");
assert!(d.unchanged, "dropping an absent name is a no-op");
opened.engine.close().unwrap();
}
#[test]
fn idempotent_reregistration_holds_for_deferred_rankable() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "idem_rankable")).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"importance":"high"}"#)]).unwrap();
let s = spec("importance", &[ProjectionRole::Rankable], false, false);
let first = engine.configure_projections(std::slice::from_ref(&s), &[]).unwrap();
assert_eq!(first.deferred, vec!["importance".to_string()], "first apply defers rankable");
assert!(!first.unchanged);
let second = engine.configure_projections(std::slice::from_ref(&s), &[]).unwrap();
assert!(second.unchanged, "identical rankable re-registration must diff to a no-op");
assert!(
second.built.is_empty() && second.dropped.is_empty() && second.deferred.is_empty(),
"no redundant deferral re-report on an idempotent rankable re-apply"
);
opened.engine.close().unwrap();
}
#[test]
fn a_changed_registry_row_reports_its_deferral_even_when_already_deferred() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "deferred_mutation");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine.write(&[node("N1", "src:1", r#"{"importance":"high"}"#)]).unwrap();
let first = engine
.configure_projections(
&[spec("importance", &[ProjectionRole::Rankable], false, false)],
&[],
)
.unwrap();
assert_eq!(first.deferred, vec!["importance".to_string()], "first apply defers rankable");
assert!(!first.unchanged);
assert_eq!(
engine
.configure_projections(
&[spec("importance", &[ProjectionRole::Rankable], false, true)],
&[]
)
.expect_err("R-20-SV: a `vector` sub-object without `searchable` is an invalid spec"),
fathomdb_engine::EngineError::WriteValidation,
"every deferred-ONLY mutation needs roles ⊆ {{Rankable}} and a change in fts/vector, so \
the reject makes the whole class unconstructible through the verb"
);
legacy_add_vector_subobject(&path, "importance");
let second = engine
.configure_projections(
&[spec(
"importance",
&[ProjectionRole::Rankable, ProjectionRole::Searchable],
false,
true,
)],
&[],
)
.unwrap();
assert!(
!second.unchanged,
"a mutation that persisted a changed registry row must NOT report unchanged, got \
{second:?}"
);
assert!(
second.deferred.contains(&"importance".to_string()),
"THE PIN (fix-2 finding 2 [P2]): the `Some(existing)` branch must push `delta.deferred` \
even when `existing.has_deferred()` is ALREADY true — that extra conjunct is exactly \
what the pre-fix guard had, and it suppressed the report, got {second:?}"
);
let read = engine.read_projections().unwrap();
assert_eq!(read.len(), 1, "exactly one projection declared");
assert!(
read[0].vector.is_some(),
"the persisted registry row carries the vector sub-target: {read:?}"
);
let third = engine
.configure_projections(
&[spec(
"importance",
&[ProjectionRole::Rankable, ProjectionRole::Searchable],
false,
true,
)],
&[],
)
.unwrap();
assert!(third.unchanged, "identical re-registration must still diff to a no-op, got {third:?}");
assert!(
third.built.is_empty() && third.dropped.is_empty() && third.deferred.is_empty(),
"the same-spec re-registration delta must be empty, got {third:?}"
);
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
}