mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
A second `Omnigraph::init` against an existing graph URI today destroys the existing graph's schema artifacts. `init_storage_phase` overwrites `_schema.pg` before any preflight, and on the inner `GraphCoordinator::init` failure that follows, `best_effort_cleanup_init_artifacts` deletes all three schema files. The existing Lance datasets and `__manifest/` survive but the schema metadata is gone — unrecoverable without operator surgery. This test exercises that path and currently fails with "_schema.pg must not be deleted by a failed re-init", confirming the destructive cleanup branch fires. The fix in the next commit makes the test pass by preflighting with `storage.exists()` and returning a typed error before any write touches disk. Per AGENTS.md rule 12, the test commit lands just before the fix commit so the red → green pair is visible in `git log` and a reviewer can check out this commit alone to reproduce. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
9.1 KiB
Rust
253 lines
9.1 KiB
Rust
mod helpers;
|
|
|
|
use std::fs;
|
|
|
|
use omnigraph::db::{Omnigraph, ReadTarget};
|
|
use omnigraph_compiler::schema::parser::parse_schema;
|
|
use omnigraph_compiler::{build_schema_ir, schema_ir_pretty_json};
|
|
|
|
use helpers::*;
|
|
|
|
#[tokio::test]
|
|
async fn init_creates_graph() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
|
|
let db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
assert!(dir.path().join("_schema.pg").exists());
|
|
assert!(dir.path().join("_schema.ir.json").exists());
|
|
assert!(dir.path().join("__schema_state.json").exists());
|
|
|
|
let snap = snapshot_main(&db).await.unwrap();
|
|
assert!(snap.entry("node:Person").is_some());
|
|
assert!(snap.entry("node:Company").is_some());
|
|
assert!(snap.entry("edge:Knows").is_some());
|
|
assert!(snap.entry("edge:WorksAt").is_some());
|
|
|
|
assert_eq!(db.catalog().node_types.len(), 2);
|
|
assert_eq!(db.catalog().edge_types.len(), 2);
|
|
assert_eq!(
|
|
db.catalog().node_types["Person"].key_property(),
|
|
Some("name")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_reads_existing_graph() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
|
|
Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
let db = Omnigraph::open(uri).await.unwrap();
|
|
assert_eq!(db.catalog().node_types.len(), 2);
|
|
assert_eq!(db.catalog().edge_types.len(), 2);
|
|
let snap = snapshot_main(&db).await.unwrap();
|
|
assert!(snap.entry("node:Person").is_some());
|
|
assert!(snap.entry("edge:Knows").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_bootstraps_legacy_schema_state_for_main_only_graph() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
fs::remove_file(dir.path().join("_schema.ir.json")).unwrap();
|
|
fs::remove_file(dir.path().join("__schema_state.json")).unwrap();
|
|
|
|
let db = Omnigraph::open(uri).await.unwrap();
|
|
assert_eq!(db.catalog().node_types.len(), 2);
|
|
assert!(dir.path().join("_schema.ir.json").exists());
|
|
assert!(dir.path().join("__schema_state.json").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_rejects_legacy_graph_with_public_branch() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
let mut db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
db.branch_create("feature").await.unwrap();
|
|
|
|
fs::remove_file(dir.path().join("_schema.ir.json")).unwrap();
|
|
fs::remove_file(dir.path().join("__schema_state.json")).unwrap();
|
|
|
|
let err = match Omnigraph::open(uri).await {
|
|
Ok(_) => panic!("expected legacy graph with public branch to fail schema bootstrap"),
|
|
Err(err) => err,
|
|
};
|
|
assert!(
|
|
err.to_string()
|
|
.contains("public branches block schema evolution entirely")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn long_lived_handle_rejects_schema_source_drift() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
let db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
let drifted = TEST_SCHEMA.replace("age: I32?", "age: I64?");
|
|
fs::write(dir.path().join("_schema.pg"), drifted).unwrap();
|
|
|
|
let err = match db.snapshot_of(ReadTarget::branch("main")).await {
|
|
Ok(_) => panic!("expected schema source drift to be rejected"),
|
|
Err(err) => err,
|
|
};
|
|
assert!(
|
|
err.to_string()
|
|
.contains("current _schema.pg no longer matches the accepted compiled schema")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn long_lived_handle_rejects_schema_ir_drift() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
let db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
fs::write(dir.path().join("_schema.ir.json"), "{not valid json").unwrap();
|
|
|
|
let err = match db.snapshot_of(ReadTarget::branch("main")).await {
|
|
Ok(_) => panic!("expected schema IR drift to be rejected"),
|
|
Err(err) => err,
|
|
};
|
|
assert!(
|
|
err.to_string()
|
|
.contains("accepted compiled schema contract in _schema.ir.json is invalid")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn long_lived_handle_rejects_ir_and_source_updates_without_state_update() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
let db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
let drifted = TEST_SCHEMA.replace("age: I32?", "age: I64?");
|
|
let drifted_ast = parse_schema(&drifted).unwrap();
|
|
let drifted_ir = build_schema_ir(&drifted_ast).unwrap();
|
|
let drifted_ir_json = schema_ir_pretty_json(&drifted_ir).unwrap();
|
|
fs::write(dir.path().join("_schema.pg"), drifted).unwrap();
|
|
fs::write(dir.path().join("_schema.ir.json"), drifted_ir_json).unwrap();
|
|
|
|
let err = match db.snapshot_of(ReadTarget::branch("main")).await {
|
|
Ok(_) => panic!("expected schema state mismatch to be rejected"),
|
|
Err(err) => err,
|
|
};
|
|
assert!(
|
|
err.to_string()
|
|
.contains("accepted compiled schema does not match the recorded schema state")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn comment_only_schema_edit_keeps_schema_state_valid() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
let db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
let commented = format!("// comment-only drift\n{}", TEST_SCHEMA);
|
|
fs::write(dir.path().join("_schema.pg"), commented).unwrap();
|
|
|
|
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
|
assert!(snapshot.entry("node:Person").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_nonexistent_fails() {
|
|
let result = Omnigraph::open("/tmp/nonexistent_omnigraph_test_xyz").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn snapshot_version_is_pinned() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
|
|
let mut db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
|
|
let snap1 = snapshot_main(&db).await.unwrap();
|
|
let v1 = snap1.version();
|
|
|
|
omnigraph::loader::load_jsonl(
|
|
&mut db,
|
|
r#"{"type": "Person", "data": {"name": "Alice", "age": 30}}"#,
|
|
omnigraph::loader::LoadMode::Overwrite,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snap2 = snapshot_main(&db).await.unwrap();
|
|
assert!(snap2.version() > v1);
|
|
|
|
assert_eq!(snap1.version(), v1);
|
|
}
|
|
|
|
/// Regression for the `Omnigraph::init` re-init footgun (MR-668
|
|
/// follow-up): a second `init` against a URI that already holds a
|
|
/// graph must NOT modify or destroy the existing graph's schema
|
|
/// artifacts. Today's behavior is destructive either way — the
|
|
/// `write_text(_schema.pg, ...)` call at the top of
|
|
/// `init_storage_phase` overwrites the existing file before any
|
|
/// preflight, and `best_effort_cleanup_init_artifacts` will later
|
|
/// delete all three files if the inner `GraphCoordinator::init`
|
|
/// fails. Both outcomes corrupt an existing graph.
|
|
///
|
|
/// After the fix: strict-mode `init` (no `force` flag) errors out
|
|
/// before touching any file, and the original schema artifacts
|
|
/// match their pre-attempt contents byte-for-byte.
|
|
#[tokio::test]
|
|
async fn init_on_existing_graph_uri_does_not_destroy_existing_schema() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
|
|
// Establish the first graph and snapshot its three schema files.
|
|
Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
|
|
let original_schema_pg = fs::read_to_string(dir.path().join("_schema.pg")).unwrap();
|
|
let original_schema_ir = fs::read_to_string(dir.path().join("_schema.ir.json")).unwrap();
|
|
let original_schema_state = fs::read_to_string(dir.path().join("__schema_state.json")).unwrap();
|
|
|
|
// Attempt a re-init with a deliberately different schema so any
|
|
// overwrite would be observable in the file contents.
|
|
let different_schema = "node Other { id: String @key }\n";
|
|
let result = Omnigraph::init(uri, different_schema).await;
|
|
|
|
// The new init must report the conflict, not silently mutate.
|
|
assert!(
|
|
result.is_err(),
|
|
"init against an existing graph URI must error, not silently overwrite"
|
|
);
|
|
|
|
// The three schema files must remain present and byte-identical to
|
|
// their pre-attempt contents.
|
|
assert!(
|
|
dir.path().join("_schema.pg").exists(),
|
|
"_schema.pg must not be deleted by a failed re-init"
|
|
);
|
|
assert!(
|
|
dir.path().join("_schema.ir.json").exists(),
|
|
"_schema.ir.json must not be deleted by a failed re-init"
|
|
);
|
|
assert!(
|
|
dir.path().join("__schema_state.json").exists(),
|
|
"__schema_state.json must not be deleted by a failed re-init"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join("_schema.pg")).unwrap(),
|
|
original_schema_pg,
|
|
"_schema.pg contents must be preserved when re-init is rejected"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join("_schema.ir.json")).unwrap(),
|
|
original_schema_ir,
|
|
"_schema.ir.json contents must be preserved when re-init is rejected"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join("__schema_state.json")).unwrap(),
|
|
original_schema_state,
|
|
"__schema_state.json contents must be preserved when re-init is rejected"
|
|
);
|
|
}
|