fraiseql_cli/schema/intermediate/mod.rs
1//! Intermediate Schema Format
2//!
3//! Language-agnostic schema representation that all language libraries output.
4//! See `docs/architecture/intermediate-schema.md` for full specification.
5
6pub mod advanced_types;
7pub mod analytics;
8pub mod drift_guard;
9pub mod fragments;
10pub mod operations;
11pub mod subscriptions;
12pub mod types;
13
14pub use advanced_types::{
15 IntermediateInputField, IntermediateInputObject, IntermediateInterface, IntermediateUnion,
16};
17pub use analytics::{
18 IntermediateAggregateQuery, IntermediateDimensionPath, IntermediateDimensions,
19 IntermediateFactTable, IntermediateFilter, IntermediateMeasure,
20};
21pub use drift_guard::reject_drifted_keys;
22pub use fragments::{
23 IntermediateAppliedDirective, IntermediateDirective, IntermediateFragment,
24 IntermediateFragmentField, IntermediateFragmentFieldDef,
25};
26use fraiseql_core::schema::{
27 DebugConfig, McpConfig, NamingConvention, RestConfig, SessionVariablesConfig,
28 SubscriptionsConfig, ValidationConfig,
29};
30pub use operations::{
31 IntermediateArgument, IntermediateAutoParams, IntermediateInjectDefaults, IntermediateMutation,
32 IntermediateQuery, IntermediateQueryDefaults, IntermediateRest,
33};
34use serde::{Deserialize, Serialize};
35pub use subscriptions::{
36 IntermediateFilterCondition, IntermediateObserver, IntermediateObserverAction,
37 IntermediateRetryConfig, IntermediateSubscription, IntermediateSubscriptionFilter,
38};
39pub use types::{
40 IntermediateDeprecation, IntermediateEnum, IntermediateEnumValue, IntermediateField,
41 IntermediateRelationship, IntermediateScalar, IntermediateType,
42};
43
44/// The `[functions]` settings lowered into the intermediate schema (#1325).
45///
46/// Typed rather than a raw `serde_json::Value` so a misspelled key is a compile
47/// error naming it, not an empty default — the `#847` lesson applied to the newest
48/// section rather than only to the ones that had already gone wrong.
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct IntermediateFunctionsConfig {
52 /// Where the function modules live. Unset ⇒ the compiler's `functions/`.
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub module_dir: Option<std::path::PathBuf>,
55
56 /// Which dead-letter store backs function dispatch (#598).
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub dlq_store: Option<String>,
59}
60
61/// Intermediate schema - universal format from all language libraries
62///
63/// # `deny_unknown_fields` is the load-bearing attribute on this struct
64///
65/// Every field below carries `#[serde(default)]`, because an SDK legitimately omits most of
66/// them. Without `deny_unknown_fields`, that combination means **any** key the compiler does
67/// not read binds to an empty default and the compile reports success. That is not a
68/// hypothetical: it is the mechanism behind nine separate shipped defects.
69///
70/// * `#847` — seven SDKs emitted `inject_defaults`; the compiler had never heard of the key. An
71/// operator configuring a project-wide tenant predicate got `✓ Schema compiled successfully` and
72/// not one operation with a tenant filter.
73/// * `#848` — four SDKs emitted `is_input`; the type compiled as an *output* type and the served
74/// schema violated GraphQL §3.10.
75/// * `#756` — the merger wrote `args`/`required`, this struct read `arguments`/`nullable`. Every
76/// TOML-declared operation argument disappeared.
77/// * `#806`/`#807` — `inject` vs `inject_params`, and five spellings of `requires_scope`. Queries
78/// compiled with no tenant predicate; gated fields compiled as public.
79///
80/// In each case the *author was explicit* and the compiler was silent. Denying unknown
81/// fields converts every one of those into an error naming the offending key, and — the
82/// point of putting it here rather than patching each instance — it does the same for the
83/// tenth key, which has not been invented yet.
84///
85/// Consequence to accept deliberately: a key an SDK emits that this struct does not declare
86/// now **fails the compile**. That is the intended breaking change. A producer emitting a
87/// key no consumer reads was never doing anything; now it says so.
88#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct IntermediateSchema {
91 /// Schema format version
92 #[serde(default = "default_version")]
93 pub version: String,
94
95 /// GraphQL object types
96 #[serde(default)]
97 pub types: Vec<IntermediateType>,
98
99 /// GraphQL enum types
100 #[serde(default)]
101 pub enums: Vec<IntermediateEnum>,
102
103 /// GraphQL input object types
104 #[serde(default)]
105 pub input_types: Vec<IntermediateInputObject>,
106
107 /// GraphQL interface types (per GraphQL spec §3.7)
108 #[serde(default)]
109 pub interfaces: Vec<IntermediateInterface>,
110
111 /// GraphQL union types (per GraphQL spec §3.10)
112 #[serde(default)]
113 pub unions: Vec<IntermediateUnion>,
114
115 /// GraphQL queries
116 #[serde(default)]
117 pub queries: Vec<IntermediateQuery>,
118
119 /// GraphQL mutations
120 #[serde(default)]
121 pub mutations: Vec<IntermediateMutation>,
122
123 /// GraphQL subscriptions
124 #[serde(default)]
125 pub subscriptions: Vec<IntermediateSubscription>,
126
127 /// GraphQL fragments (reusable field selections)
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub fragments: Option<Vec<IntermediateFragment>>,
130
131 /// GraphQL directive definitions (custom directives)
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub directives: Option<Vec<IntermediateDirective>>,
134
135 /// Analytics fact tables (optional)
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub fact_tables: Option<Vec<IntermediateFactTable>>,
138
139 /// Analytics aggregate queries (optional)
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub aggregate_queries: Option<Vec<IntermediateAggregateQuery>>,
142
143 /// Observer definitions (database change event listeners)
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub observers: Option<Vec<IntermediateObserver>>,
146
147 /// Scheduled ingress source definitions (#573) — the dual of `observers`.
148 /// Reuses the compiled [`SourceDefinition`](fraiseql_core::schema::`SourceDefinition`)
149 /// shape directly (it already carries serde defaults); the converter copies them
150 /// into the compiled schema.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub sources: Option<Vec<fraiseql_core::schema::SourceDefinition>>,
153
154 /// Serverless function definitions (#1325) — the authoring dual of the compiled
155 /// `functions` section.
156 ///
157 /// Reuses the runtime shape
158 /// ([`FunctionDefinition`](fraiseql_functions::FunctionDefinition)) directly, as
159 /// `sources` does, so an authored declaration and the one the server dispatches
160 /// are the same struct. The compiler pairs this list with the `[functions]` TOML
161 /// group (`module_dir`, `dlq_store`) to emit the compiled section; the two halves
162 /// have one owner each and neither may set the other's keys.
163 ///
164 /// Before #1325 nothing could write this section: `IntermediateSchema` had no
165 /// field for it, so the only way to ship a function was to hand-edit
166 /// `schema.compiled.json` — which the next `fraiseql compile` overwrote.
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub functions: Option<Vec<fraiseql_functions::FunctionDefinition>>,
169
170 /// Functions settings from the `[functions]` TOML table (#1325).
171 ///
172 /// **Injected by the merger, never present in `schema.json`** — the same
173 /// arrangement `query_defaults` has. The compiler pairs it with
174 /// [`functions`](Self::functions) to emit the compiled section: the definitions
175 /// are schema and these are deployment settings, one owner each
176 /// (`docs/architecture/config-vs-settings.md`).
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub functions_config: Option<IntermediateFunctionsConfig>,
179
180 /// Custom scalar type definitions
181 ///
182 /// Defines custom GraphQL scalar types with validation rules.
183 /// Custom scalars can be defined in Python, `TypeScript`, Java, Go, and Rust SDKs,
184 /// and are compiled into the CompiledSchema's `CustomTypeRegistry`.
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub custom_scalars: Option<Vec<IntermediateScalar>>,
187
188 /// Security configuration (from fraiseql.toml)
189 /// Compiled from the security section of fraiseql.toml at compile time.
190 /// Optional - if not provided, defaults are used.
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub security: Option<serde_json::Value>,
193
194 /// PKCE OAuth-client configuration (the `[auth]` PKCE group, #621).
195 ///
196 /// The TOML merger emits the compiled `auth` object here (`discovery_url`,
197 /// `client_id`, `client_secret_env`, `server_redirect_uri`); the converter
198 /// carries it verbatim into `CompiledSchema.auth`. `None` when the group is
199 /// absent.
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub auth: Option<serde_json::Value>,
202
203 /// Observers/event system configuration (from fraiseql.toml).
204 ///
205 /// Contains backend connection settings (redis_url, nats_url, etc.) compiled
206 /// from the `[observers]` TOML section. Embedded verbatim into the compiled schema.
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub observers_config: Option<serde_json::Value>,
209
210 /// Federation configuration.
211 ///
212 /// Two authoring workflows feed this field:
213 /// - The TOML merger embeds the `[federation]` section under `federation_config`.
214 /// - Language SDKs (e.g. the Python SDK's `export_schema(federation=...)`) emit the block
215 /// under the top-level `federation` key in `schema.json`; the `alias` binds it here so the
216 /// legacy JSON workflow does not silently drop it.
217 ///
218 /// Carried verbatim by the converter into `CompiledSchema.federation`.
219 #[serde(default, alias = "federation", skip_serializing_if = "Option::is_none")]
220 pub federation_config: Option<serde_json::Value>,
221
222 /// WebSocket subscription configuration (hooks, limits).
223 ///
224 /// Compiled from the `[subscriptions]` TOML section. Embedded verbatim into
225 /// the compiled schema for server-side consumption.
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub subscriptions_config: Option<SubscriptionsConfig>,
228
229 /// Query validation config (depth/complexity limits).
230 ///
231 /// Compiled from `[validation]` in `fraiseql.toml`. Embedded into the compiled
232 /// schema for server-side consumption.
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub validation_config: Option<ValidationConfig>,
235
236 /// Debug/development configuration.
237 ///
238 /// Compiled from `[debug]` in `fraiseql.toml`. Embedded into the compiled
239 /// schema for server-side consumption.
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub debug_config: Option<DebugConfig>,
242
243 /// MCP (Model Context Protocol) server configuration.
244 ///
245 /// Compiled from `[mcp]` in `fraiseql.toml`. Embedded into the compiled
246 /// schema for server-side consumption.
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub mcp_config: Option<McpConfig>,
249
250 /// REST transport configuration.
251 ///
252 /// Compiled from `[rest]` in `fraiseql.toml`. Embedded into the compiled
253 /// schema for server-side consumption.
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub rest_config: Option<RestConfig>,
256
257 /// gRPC transport configuration.
258 ///
259 /// Compiled from `[grpc]` in `fraiseql.toml`. Embedded verbatim into the compiled
260 /// schema, whose `grpc_config` gates the transport (#780).
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub grpc_config: Option<fraiseql_core::schema::GrpcConfig>,
263
264 /// Global auto-param defaults for list queries (injected from TOML by the merger).
265 ///
266 /// Never present in `schema.json` — populated at compile time from `[query_defaults]`
267 /// in `fraiseql.toml`. Used by the converter to resolve per-query `auto_params`.
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub query_defaults: Option<IntermediateQueryDefaults>,
270
271 /// Project-wide default injected parameters, from `[inject_defaults]`.
272 ///
273 /// Emitted as a top-level key by seven SDKs' `ConfigLoader`s and consumed by the
274 /// converter. See [`IntermediateInjectDefaults`] for the precedence rules and for why
275 /// the merge happens after `[fraiseql.tenancy]` validation rather than before it
276 /// (#847).
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub inject_defaults: Option<IntermediateInjectDefaults>,
279
280 /// Naming convention for GraphQL operation names.
281 ///
282 /// Compiled from `fraiseql.toml` top-level `naming_convention` setting.
283 #[serde(default)]
284 pub naming_convention: NamingConvention,
285
286 /// Session variable injection configuration.
287 ///
288 /// When populated, the executor calls `set_config()` before each query and
289 /// mutation to inject per-request values (JWT claims, HTTP headers, or literals)
290 /// as `PostgreSQL` transaction-scoped settings.
291 ///
292 /// Embedded verbatim from the `session_variables` key in `schema.json`.
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub session_variables: Option<SessionVariablesConfig>,
295
296 /// Hierarchy definitions for ID-based ltree operators.
297 ///
298 /// Compiled from the `[hierarchies]` TOML section. Maps hierarchy names
299 /// to table/path_column pairs for subquery generation.
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub hierarchies_config: Option<fraiseql_core::schema::HierarchiesConfig>,
302
303 /// Changelog GraphQL-exposure configuration.
304 ///
305 /// Compiled from the `[changelog]` TOML section by the merger. Drives the
306 /// converter's injection of the `EntityChangeLog` / `TransportCheckpoint`
307 /// types and their operations.
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub changelog_config: Option<fraiseql_core::schema::ChangelogConfig>,
310}
311
312fn default_version() -> String {
313 "2.0.0".to_string()
314}
315
316#[cfg(test)]
317mod tests;