server: add saved-query CRUD at /queries#96
Conversation
Persist named .gq queries by name so clients (MCP, future UIs) can list
and invoke them as first-class endpoints rather than treating every
query as ad-hoc source. Storage follows the schema-source pattern — one
JSON file per saved query under `<root>/queries/<name>.json` via
`StorageAdapter::write_text`, with no Lance/manifest involvement.
The source must declare exactly one `query <name>(...)` block whose name
matches the URL key; the server parses it at save time via
`omnigraph_compiler::query::parser::parse_query` and persists the
extracted parameter signature alongside, so callers can build typed
inputs without re-parsing. Name validation is `^[a-z][a-z0-9_]{0,63}$`
with a small reserved list to prevent shadowing built-in MCP tool names.
Endpoints (all behind bearer auth + Cedar `query_read` / `query_write`):
GET /queries list all
GET /queries/{name} get one (404 if absent)
PUT /queries/{name} insert-or-overwrite
DELETE /queries/{name} idempotent delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Joan Reyero <joan@reyero.io>
Review feedback from ModernRelay#96: - list()/get() now check format_version, validate the parsed name, and verify it matches the filename it came from. list() skips invalid records with a tracing::warn so one tampered file doesn't poison the whole listing; get() rejects loudly since the caller already knows which name they wanted. Covers the case where an operator hand-edits the on-disk JSON (rename, format-version bump, etc.). 2 new unit tests pin both paths. - docs/user/server.md: four /queries rows added to the endpoint inventory; new "Saved queries" subsection covers the persistence model and naming rules; admission-control paragraph updated to be explicit that /queries writes are intentionally not gated. - docs/user/policy.md: query_read / query_write added to the actions list with a note that they are not branch-scoped. - openapi.rs: EXPECTED_SCHEMAS whitelist updated with the five new component schemas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Joan Reyero <joan@reyero.io>
|
Thanks @joanreyero — this is clean, well-tested work: good error taxonomy, tests at the right boundaries, OpenAPI regenerated, and the control/data separation is exemplary (pure out-of-band metadata, no Lance/manifest entanglement, snapshot semantics untouched). I'm closing it for a sequencing/architecture reason, not a quality one — it surfaces a bigger question we need to settle before committing to a write surface for this class of resource. The big question we need to address first: control plane + stateSaved queries are a control-plane resource — the same class as schema, policies, and (soon) UI. We're actively designing how the cluster's control plane and its state model should work: a declarative cluster spec, The conflict: imperative vs declarativeThis PR implements the resource imperatively — Concretely it's the "how does Bob know what Sarah did?" test: if one operator PUTs a query, another has no single spec, no git diff, and no attributed change record to learn from. The declarative model answers that by construction; the imperative one re-creates the fragmentation. This isn't a rejection of the featureIt's almost certainly coming back. The storage shape here — one JSON file per query, parsed param signature persisted alongside source — is a fine substrate that a declarative reconciler can target. The likely end state: this CRUD becomes the low-level mechanism
TL;DR — closing to avoid minting an imperative write surface for a control-plane resource ahead of the control-plane/state design. Let's settle that first; this returns as the mechanism behind it. |
Summary
GET /queries,GET/PUT/DELETE /queries/{name}so clients can persist named.gqqueries by name instead of passing the full source on every call. One JSON file per saved query under<root>/queries/<name>.jsonvia the existingStorageAdapter— same surface schema source already uses, no Lance/manifest involvement.omnigraph_compiler::query::parser::parse_queryand persists the declared param signature alongside it, so MCP / UI clients can render typed inputs without re-parsing.query <name>(...)block whose name matches the URL key. Names validated^[a-z][a-z0-9_]{0,63}$; a small reserved list blocks shadowing built-in MCP tool names (read,change, …).query_read/query_writemirror theread/schema_applywiring.Motivating use case
The Omnigraph TypeScript MCP server currently exposes only generic
read/changetools, so every saved query an agent or human authors lives in client memory. With this CRUD an MCP can list saved queries at startup and register one typed MCP tool per saved query (q_<name>prefix), making them discoverable in the agent's tool catalogue. The companion TS-side change lives in ModernRelay/omnigraph-ts (linked below).Persisted shape
{ "format_version": 1, "name": "find_person", "description": "by name", "source": "query find_person($name: String) { ... }", "params": [{ "name": "name", "type_name": "String", "nullable": false }], "updated_at_us": "1747315200000000" }Invariants checklist
StorageAdapter::write_text, no Lance datasets, no manifest entries, no new transaction manager.authorize_request(...)with newQueryRead/QueryWritePolicyActionvariants; engine API is transport-agnostic.BadRequest; missing →NotFound; mismatched source name →BadRequestwith the specific reason; multi-query source →BadRequest; idempotentDELETEreturns{deleted: false}rather than silent success.crates/omnigraph/src/db/saved_queries.rscover storage layer; 3 HTTP integration tests incrates/omnigraph-server/tests/server.rscover the route layer.Out of scope (deliberate)
kind: "change"flag can come later if needed.Test plan
cargo test -p omnigraph-engine --lib saved_queries— 6 unit tests pass.cargo test -p omnigraph-server— 47 server + 60 openapi tests pass, including 3 new HTTP integration tests.openapi.jsonregenerated viaOMNIGRAPH_UPDATE_OPENAPI=1 cargo test -p omnigraph-server --test openapi openapi_spec_is_up_to_date; path/security whitelist tests updated.q_<name>tools appear and dispatch correctly.🤖 Generated with Claude Code