Load and type-check stored queries at server boot, refusing breakage

At startup the server now loads each graph's stored-query registry,
type-checks every query against that graph's live schema, and refuses to
boot if any query references a type/property the schema doesn't have
(same posture as bad policy YAML) — so schema drift surfaces at the
deploy boundary, not silently at invocation. Non-blocking warnings are
logged. The validated registry is attached to the GraphHandle (the two
production sites previously held `queries: None`).

Loading (parse + key==symbol identity) happens at settings-build time
where the config is in scope; the schema type-check happens after each
engine opens (single mode in `open_single_with_queries`, multi mode in
`open_single_graph`). `open_with_bearer_tokens_and_policy` delegates
with an empty registry so its 18 test callers are unchanged; the public
`new_*` constructors are unchanged (only the private build path threads
the registry).

- ServerConfigMode::Single / GraphStartupConfig carry the loaded registry
- boot tests: valid registry boots; type-broken query refuses boot + names it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-05-30 20:45:35 +02:00
parent 6a16b3c6ac
commit 9aa96cbb4b
No known key found for this signature in database
2 changed files with 213 additions and 10 deletions

View file

@ -16,6 +16,7 @@ use omnigraph_server::api::{
BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest,
IngestRequest, QueryRequest, ReadRequest, SchemaApplyRequest, SchemaOutput,
};
use omnigraph_server::queries::{QueryRegistry, RegistrySpec};
use omnigraph_server::{AppState, build_app};
use serde_json::{Value, json};
use serial_test::serial;
@ -141,6 +142,73 @@ fn graph_path(root: &Path) -> PathBuf {
root.join("server.omni")
}
fn stored_query_registry(specs: &[(&str, &str, bool)]) -> QueryRegistry {
QueryRegistry::from_specs(
specs
.iter()
.map(|(name, source, expose)| RegistrySpec {
name: name.to_string(),
source: source.to_string(),
expose: *expose,
tool_name: None,
})
.collect(),
)
.expect("specs parse and key==symbol")
}
#[tokio::test]
async fn server_boots_with_a_valid_stored_query_registry() {
// A stored query that type-checks against the fixture schema
// (`Person { name, age }`) must let the server boot.
let temp = init_loaded_graph().await;
let graph = graph_path(temp.path());
let registry = stored_query_registry(&[(
"find_person",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
false,
)]);
let state = AppState::open_single_with_queries(
graph.to_string_lossy().to_string(),
vec![],
None,
registry,
)
.await;
assert!(state.is_ok(), "valid registry should boot: {:?}", state.err());
}
#[tokio::test]
async fn server_refuses_boot_on_type_broken_stored_query() {
// A stored query referencing a type not in the schema (`Widget`)
// must abort boot, naming the offending query.
let temp = init_loaded_graph().await;
let graph = graph_path(temp.path());
let registry = stored_query_registry(&[(
"ghost",
"query ghost() { match { $w: Widget } return { $w.name } }",
false,
)]);
let result = AppState::open_single_with_queries(
graph.to_string_lossy().to_string(),
vec![],
None,
registry,
)
.await;
// `AppState` is not `Debug`, so match rather than `expect_err`.
let err = match result {
Ok(_) => panic!("type-broken stored query must refuse boot"),
Err(err) => err,
};
let msg = err.to_string();
assert!(msg.contains("ghost"), "error should name the broken query: {msg}");
assert!(
msg.contains("schema check"),
"error should mention the schema check: {msg}"
);
}
fn drifted_test_schema() -> String {
fs::read_to_string(fixture("test.pg"))
.unwrap()