From 625ae7c208dbd8e4fbbfa08c7c21b6d90077ad42 Mon Sep 17 00:00:00 2001 From: Andrew Altshuler Date: Mon, 15 Jun 2026 17:23:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20defaults.store=20=E2=80=94=20a=20z?= =?UTF-8?q?ero-flag=20local=20default=20scope=20(RFC-011)=20(#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator config gains defaults.store (a file:///s3:// graph storage URI), the local-dev counterpart of defaults.server + default_graph. Mutually exclusive with defaults.server, and a store cannot carry default_graph (both refused at load). The zero-flag local default that survives the upcoming removal of omnigraph.yaml's cli.graph. Additive, non-breaking. --- crates/omnigraph-cli/src/operator.rs | 73 +++++++++++++++++++++++++++- crates/omnigraph-cli/src/scope.rs | 40 +++++++++++++++ docs/user/cli/reference.md | 13 +++-- 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/crates/omnigraph-cli/src/operator.rs b/crates/omnigraph-cli/src/operator.rs index e48af50..929779e 100644 --- a/crates/omnigraph-cli/src/operator.rs +++ b/crates/omnigraph-cli/src/operator.rs @@ -18,7 +18,7 @@ use std::env; use std::path::{Path, PathBuf}; use color_eyre::Result; -use color_eyre::eyre::eyre; +use color_eyre::eyre::{bail, eyre}; use serde::Deserialize; use omnigraph_server::config::ReadOutputFormat; @@ -108,8 +108,14 @@ pub(crate) struct OperatorDefaults { pub(crate) table_cell_layout: Option, /// Default server scope (RFC-011): the everyday addressing when no /// `--profile` / primitive / legacy address is given. Names an entry - /// under `servers:`. + /// under `servers:`. Mutually exclusive with `store` — a scope binds one + /// entity. pub(crate) server: Option, + /// Default **store** scope (RFC-011): a `file://` / `s3://` graph storage + /// URI used as the zero-flag local default for graph commands when no + /// `--profile` / primitive address is given. The local-dev counterpart of + /// `server`; mutually exclusive with it. + pub(crate) store: Option, /// Default graph selected within a server/cluster scope when no /// `--graph` is passed (RFC-011). pub(crate) default_graph: Option, @@ -202,10 +208,36 @@ impl OperatorConfig { self.defaults.server.as_deref() } + /// The flat-default store scope URI, if set (RFC-011) — the zero-flag + /// local-dev default. + pub(crate) fn default_store(&self) -> Option<&str> { + self.defaults.store.as_deref() + } + /// The flat-default graph within a server/cluster scope, if set (RFC-011). pub(crate) fn default_graph(&self) -> Option<&str> { self.defaults.default_graph.as_deref() } + + /// A scope binds one entity (Decision 6): `defaults.server` and + /// `defaults.store` are mutually exclusive, and a `store` (already a single + /// graph) cannot carry a `default_graph`. Both are refused loudly rather + /// than silently dropped. + fn validate_defaults(&self) -> Result<()> { + if self.defaults.server.is_some() && self.defaults.store.is_some() { + bail!( + "operator config `defaults` sets both `server` and `store` — a default scope \ + binds one entity; keep one (use a `profile` if you need both)" + ); + } + if self.defaults.store.is_some() && self.defaults.default_graph.is_some() { + bail!( + "operator config `defaults` sets both `store` and `default_graph` — a store is \ + already a single graph; drop `default_graph` (it applies only to a server/cluster scope)" + ); + } + Ok(()) + } } impl OperatorProfile { @@ -282,6 +314,7 @@ pub(crate) fn load_operator_config_at(path: &Path) -> Result { for warning in config.unknown_key_warnings() { eprintln!("warning: {warning} in operator config '{}'", path.display()); } + config.validate_defaults()?; Ok(config) } @@ -560,6 +593,42 @@ mod tests { assert_eq!(config.output(), Some(ReadOutputFormat::Json)); } + #[test] + fn defaults_store_parses_and_is_accessible() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write(&path, "defaults:\n store: file:///tmp/dev.omni\n").unwrap(); + let config = load_operator_config_at(&path).unwrap(); + assert_eq!(config.default_store(), Some("file:///tmp/dev.omni")); + assert_eq!(config.default_server(), None); + } + + #[test] + fn defaults_server_and_store_together_is_a_loud_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write( + &path, + "defaults:\n server: prod\n store: file:///tmp/dev.omni\n", + ) + .unwrap(); + let err = load_operator_config_at(&path).unwrap_err().to_string(); + assert!(err.contains("binds one entity"), "{err}"); + } + + #[test] + fn defaults_store_with_default_graph_is_a_loud_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write( + &path, + "defaults:\n store: file:///tmp/dev.omni\n default_graph: knowledge\n", + ) + .unwrap(); + let err = load_operator_config_at(&path).unwrap_err().to_string(); + assert!(err.contains("already a single graph"), "{err}"); + } + #[test] fn unknown_keys_warn_but_load() { // A file written for a later slice (servers/aliases) must load diff --git a/crates/omnigraph-cli/src/scope.rs b/crates/omnigraph-cli/src/scope.rs index 9d7cf4a..91a1c24 100644 --- a/crates/omnigraph-cli/src/scope.rs +++ b/crates/omnigraph-cli/src/scope.rs @@ -140,6 +140,18 @@ pub(crate) fn resolve_scope( ); } + // 3b. Flat default store scope — the zero-flag local-dev default (RFC-011). + // Mutually exclusive with `defaults.server` (enforced at config load). + if let Some(store) = op.default_store() { + return scope_from_binding( + op, + capability, + ScopeBinding::Store(store.to_string()), + flags.graph.map(str::to_string), + "operator defaults", + ); + } + // 4. Nothing resolved — leave the tuple empty; downstream falls through to // today's behavior (legacy `cli.graph` default or a no-address error). Ok(ResolvedScope::default()) @@ -373,6 +385,34 @@ mod tests { } } + #[test] + fn flat_default_store_drives_local_verbs() { + // RFC-011: `defaults.store` is the zero-flag local default — no flags, + // no profile → the store URI resolves as the (single-graph) store scope. + let op = cfg("defaults:\n store: file:///tmp/dev.omni\n"); + let scope = resolve_scope(&op, Capability::Any, flags()).unwrap(); + assert_eq!(scope.uri.as_deref(), Some("file:///tmp/dev.omni")); + assert_eq!(scope.server, None); + } + + #[test] + fn flat_default_store_rejects_graph() { + // A store is already a single graph, so `--graph` against a default + // store is a loud error. + let op = cfg("defaults:\n store: file:///tmp/dev.omni\n"); + let err = resolve_scope( + &op, + Capability::Any, + ScopeFlags { + graph: Some("knowledge"), + ..flags() + }, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("does not apply to a store scope"), "{err}"); + } + #[test] fn flat_default_server_drives_data_verbs() { let op = cfg("defaults:\n server: prod\n default_graph: knowledge\nservers:\n prod:\n url: https://x\n"); diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 5e60476..f52ebaf 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -84,7 +84,10 @@ servers: # operator-owned endpoints; names key the credentials url: https://graph.example.com # no tokens in this file, ever defaults: output: table # read format default, below --json/--format/alias/legacy - server: prod # the everyday scope when no address is given (RFC-011) + server: prod # the everyday SERVED scope when no address is given (RFC-011) + # store: file:///data/dev.omni # OR a zero-flag LOCAL default (mutually + # # exclusive with `server`); the local-dev + # # counterpart of `server` default_graph: knowledge # graph selected in a server/cluster scope clusters: # admin-only: managed-cluster storage roots (RFC-011). brain: # the ONLY place a storage root lives in this file. @@ -105,9 +108,11 @@ graph in it; the served-vs-direct access path is derived from the scope, not toggled. The scope comes from one of (highest precedence first): an explicit address (a positional URI, `--server`, or `--store `); a named `--profile ` (or `$OMNIGRAPH_PROFILE`); or the flat `defaults.server` + -`defaults.default_graph`. A **profile** binds exactly one of `server` / `cluster` -/ `store` plus an optional default graph — config data, not state: every command -resolves its scope fresh, there is no sticky "current" mode. +`defaults.default_graph` (a served default) **or** `defaults.store` (a zero-flag +*local* default — mutually exclusive with `defaults.server`). A **profile** binds +exactly one of `server` / `cluster` / `store` plus an optional default graph — +config data, not state: every command resolves its scope fresh, there is no +sticky "current" mode. - `--store ` addresses a single graph's storage directly (ad-hoc / break-glass). - A `cluster`-bound profile reaches `optimize` / `repair` / `cleanup` for a managed