fraiseql-core 2.19.1

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
//! Compiled schema types - pure Rust, no authoring-language references.
//!
//! These types represent GraphQL schemas after compilation from authoring languages.
//! All data is owned by Rust - no foreign object references.
//!
//! # Schema Freeze Invariant
//!
//! After `CompiledSchema::from_json()`, the schema is frozen:
//! - All data is Rust-owned
//! - No authoring-language callbacks or object references
//! - Safe to use from any Tokio worker thread
//!
//! This enables the Axum server to handle requests without any
//! interaction with the authoring-language runtime.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

use super::{directive::DirectiveDefinition, mutation::MutationDefinition, query::QueryDefinition};
use crate::{
    compiler::fact_table::FactTableMetadata,
    schema::{
        config_types::{
            ChangelogConfig, DebugConfig, FederationConfig, GrpcConfig, McpConfig,
            NamingConvention, ObserversConfig, RestConfig, SessionVariablesConfig,
            SubscriptionsConfig, ValidationConfig,
        },
        graphql_type_defs::{
            EnumDefinition, InputObjectDefinition, InterfaceDefinition, TypeDefinition,
            UnionDefinition,
        },
        hierarchy::HierarchiesConfig,
        observer_types::ObserverDefinition,
        security_config::SecurityConfig,
        source_types::SourceDefinition,
        subscription_types::SubscriptionDefinition,
    },
    validation::CustomTypeRegistry,
};

/// The fraiseql build this binary is part of (#1304).
///
/// Every crate in the workspace takes `version.workspace`, so a compiler and a
/// runtime that link the same `fraiseql-core` read the same value here — which
/// is the invariant [`ProducerVersion`] compares, and a sharper one than the
/// two binaries' own version strings.
pub const CURRENT_FRAISEQL_VERSION: &str = env!("CARGO_PKG_VERSION");

/// The fraiseql build that produced a compiled schema (#1304).
///
/// A compiled schema is a build artifact of the release that produced it, and
/// its meaning is not stable across releases — an *absent* field is a value the
/// runtime reads, not a gap it ignores. `pagination_order` is the worked
/// example: 2.14 wrote none, 2.15 reads its absence as "this query has no page
/// order", so a 2.14 artifact paginates a 2.15 runtime into overlapping pages
/// under a `200`, which is the defect 2.15 shipped to remove. Nothing about
/// that is visible to an operator.
///
/// The stamp is therefore *compared*, never interpreted: a runtime does not
/// decide whether some other build's artifact is close enough, because deciding
/// that is the judgement #1303 got wrong. What the type buys over the
/// `Option<u32>` format version it replaces is that the two producers of a
/// `CompiledSchema` can no longer collapse into one value:
///
/// * the compiler stamps the build it is part of into the artifact it writes;
/// * a `CompiledSchema` built in process **is** this build, so [`Default`] yields it;
/// * an artifact carrying no stamp is [`unstamped`](Self::unstamped), a third value — where `None`
///   previously meant both "compiled before versioning existed" and "freshly constructed", and was
///   accepted on the strength of the second reading.
///
/// See `docs/adr/0020-compiled-schema-build-identity.md`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProducerVersion(Option<String>);

impl Default for ProducerVersion {
    /// This build — the honest answer for a schema this binary just constructed.
    fn default() -> Self {
        Self(Some(CURRENT_FRAISEQL_VERSION.to_string()))
    }
}

impl ProducerVersion {
    /// The value an artifact carrying no `fraiseql_version` deserializes to.
    ///
    /// Reachable only through deserialization: [`Default`] is this build, so a
    /// missing JSON key is the one way to obtain it.
    #[must_use]
    pub const fn unstamped() -> Self {
        Self(None)
    }

    /// The build named by the stamp, or `None` when the artifact carries none.
    #[must_use]
    pub fn get(&self) -> Option<&str> {
        self.0.as_deref()
    }

    /// Whether the artifact carries no stamp.
    #[must_use]
    pub const fn is_unstamped(&self) -> bool {
        self.0.is_none()
    }
}

/// A `@subscribable` declaration in the compiled schema (#366).
///
/// Maps a GraphQL type to the physical base table(s) whose **external** writes
/// (a raw `INSERT`/`UPDATE`/`DELETE` from psql / a migration / a third-party
/// tool) should be captured onto the Change Spine by the shipped fallback trigger
/// `core.fn_entity_change_log_capture`. The compiler aggregates one of these per
/// type carrying `@subscribable(tables=[...])`; the
/// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
/// generator turns them into per-table statement-level triggers that stamp
/// `object_type = entity_type` — the GraphQL type name the reader and the
/// subscription matcher key on, never the table name — so a captured external
/// write fans out through the existing poller with no table→type lookup.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribableEntity {
    /// The GraphQL type name (e.g. `"Post"`) stamped as `object_type` on every
    /// captured change-log row.
    pub entity_type: String,

    /// The physical base table(s) backing `entity_type` (e.g. `["tb_post"]`,
    /// optionally schema-qualified `["public.tb_post"]`). A capture trigger is
    /// installed on each.
    pub tables: Vec<String>,

    /// Whether the capture triggers on this entity's tables also record the
    /// changed entity's **pre-image** (OLD) into `object_data_before` — the
    /// out-of-band parity for the per-mutation
    /// [`changelog_pre_image`](super::MutationDefinition::changelog_pre_image).
    ///
    /// The trigger always unifies `object_data` on the after-image (NEW)
    /// regardless of this flag; `pre_image` only adds the separate before-image
    /// column for opted-in tables, so audit-sensitive entities get an inline
    /// Debezium `{before, after}` even for raw external writes. Default `false`
    /// (opt in via `@subscribable(tables=[...], pre_image=True)`); an absent value
    /// is byte-identical to before this field existed, so it does not churn the
    /// codegen schema hash.
    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
    pub pre_image: bool,
}

/// Complete compiled schema - all type information for serving.
///
/// This is the central type that holds the entire GraphQL schema
/// after compilation from any supported authoring language.
///
/// # Example
///
/// ```
/// use fraiseql_core::schema::CompiledSchema;
///
/// let json = r#"{
///     "types": [],
///     "queries": [],
///     "mutations": [],
///     "subscriptions": []
/// }"#;
///
/// let schema = CompiledSchema::from_json(json, false).unwrap();
/// assert_eq!(schema.types.len(), 0);
/// ```
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CompiledSchema {
    /// GraphQL object type definitions.
    #[serde(default)]
    pub types: Vec<TypeDefinition>,

    /// GraphQL enum type definitions.
    #[serde(default)]
    pub enums: Vec<EnumDefinition>,

    /// GraphQL input object type definitions.
    #[serde(default)]
    pub input_types: Vec<InputObjectDefinition>,

    /// GraphQL interface type definitions.
    #[serde(default)]
    pub interfaces: Vec<InterfaceDefinition>,

    /// GraphQL union type definitions.
    #[serde(default)]
    pub unions: Vec<UnionDefinition>,

    /// GraphQL query definitions.
    #[serde(default)]
    pub queries: Vec<QueryDefinition>,

    /// GraphQL mutation definitions.
    #[serde(default)]
    pub mutations: Vec<MutationDefinition>,

    /// GraphQL subscription definitions.
    #[serde(default)]
    pub subscriptions: Vec<SubscriptionDefinition>,

    /// Custom directive definitions.
    /// These are user-defined directives beyond the built-in @skip, @include, @deprecated.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub directives: Vec<DirectiveDefinition>,

    /// Fact table metadata (for analytics queries).
    /// Key: table name (e.g., `tf_sales`)
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub fact_tables: HashMap<String, FactTableMetadata>,

    /// Observer definitions (database change event listeners).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub observers: Vec<ObserverDefinition>,

    /// Scheduled ingress source definitions (#573) — the dual of `observers`.
    ///
    /// Each runs its `function` on a cron schedule, pulling from an external system
    /// into the database via mutations with a durable cursor. Empty (and omitted
    /// from the compiled JSON) when no source is declared, so a schema that predates
    /// this field deserializes and re-serializes byte-for-byte unchanged.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sources: Vec<SourceDefinition>,

    /// `@subscribable` declarations (#366): GraphQL types whose underlying
    /// table(s) get the shipped external-write capture trigger.
    ///
    /// Aggregated by the compiler from each type's `@subscribable(tables=[...])`
    /// annotation; consumed by
    /// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
    /// to emit per-table capture triggers. Empty (and omitted from the compiled
    /// JSON) when no type is subscribable — so a schema that predates this field
    /// deserializes and re-serializes byte-for-byte unchanged.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub subscribable: Vec<SubscribableEntity>,

    /// Per-operation `@cost(weight: N)` overrides (#379): root query/mutation name
    /// → manual cost weight, consulted by the runtime per-tenant cost-budget check
    /// (`estimate_query_cost`) so a top-level operation counts as exactly `N`
    /// instead of its walked subtree complexity. Aggregated by the compiler from
    /// each operation's `@cost` annotation. Empty (and omitted from the compiled
    /// JSON) when no operation carries `@cost` — so a schema that predates this
    /// field deserializes and re-serializes byte-for-byte unchanged.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub operation_cost_weights: HashMap<String, usize>,

    /// Federation metadata for Apollo Federation v2 support.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub federation: Option<FederationConfig>,

    /// Security configuration (from fraiseql.toml).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub security: Option<SecurityConfig>,

    /// PKCE OAuth-client configuration for server-side login (`[auth]` PKCE group).
    ///
    /// Carries the OIDC client identity (`client_id`, `client_secret_env`,
    /// `server_redirect_uri`) and the provider's `discovery_url`. The runtime
    /// resolves the `authorization_endpoint` / `token_endpoint` by fetching the
    /// discovery document **at boot** from `discovery_url` (#621) — the compiler
    /// stays hermetic and the discovery document is always fresh. `None` when the
    /// `[auth]` PKCE client group is absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth: Option<AuthClientConfig>,

    /// Observers/event system configuration (from fraiseql.toml).
    ///
    /// Contains backend connection settings (`redis_url`, `nats_url`, etc.) and
    /// event handler definitions compiled from the `[observers]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub observers_config: Option<ObserversConfig>,

    /// `WebSocket` subscription configuration (hooks, limits).
    /// Compiled from the `[subscriptions]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subscriptions_config: Option<SubscriptionsConfig>,

    /// Query validation config (depth/complexity limits).
    /// Compiled from the `[validation]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub validation_config: Option<ValidationConfig>,

    /// Debug/development configuration.
    /// Compiled from the `[debug]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub debug_config: Option<DebugConfig>,

    /// MCP (Model Context Protocol) server configuration.
    /// Compiled from the `[mcp]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcp_config: Option<McpConfig>,

    /// REST transport configuration.
    /// Compiled from the `[rest]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rest_config: Option<RestConfig>,

    /// gRPC transport configuration.
    /// Compiled from the `[grpc]` TOML section.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub grpc_config: Option<GrpcConfig>,

    /// Changelog GraphQL-exposure configuration.
    ///
    /// Compiled from the `[changelog]` TOML section. When present with
    /// `expose = true`, the compiler injects the `EntityChangeLog` /
    /// `TransportCheckpoint` types plus their cursor query, point-lookup query, and
    /// checkpoint upsert mutation. `None` when the block is absent (the default).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub changelog: Option<ChangelogConfig>,

    /// Session variable injection configuration.
    ///
    /// When populated, the executor calls PostgreSQL `set_config()` before each
    /// mutation, injecting per-request values (JWT claims, HTTP headers, literals)
    /// as transaction-scoped settings.  SQL functions read these via
    /// `current_setting('app.tenant_id', true)`.
    ///
    /// Compiled from the `[session_variables]` TOML section.
    #[serde(default)]
    pub session_variables: SessionVariablesConfig,

    /// The request locale (`[locale]`, #1512): the locales served, how a request picks one,
    /// and the fallback chains derived from them. `None`: the deployment declares no locale,
    /// and nothing locale-dependent runs. Validated (and its chains derived) at load.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locale: Option<crate::schema::LocaleConfig>,

    /// Hierarchy definitions for ID-based ltree operators.
    ///
    /// Maps hierarchy names to `table`/`path_column` pairs. Compiled from the
    /// `[hierarchies]` TOML section. Used at runtime to resolve `HierarchyContext`
    /// for `descendantOfId` / `ancestorOfId` WHERE clause generation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hierarchies_config: Option<HierarchiesConfig>,

    /// Naming convention for GraphQL operation names.
    ///
    /// When set to `CamelCase`, operation names are converted from `snake_case`
    /// (e.g., `create_dns_server` → `createDnsServer`) in the introspection
    /// schema and lookup indexes. Compiled from `[fraiseql]` in `fraiseql.toml`.
    #[serde(default)]
    pub naming_convention: NamingConvention,

    /// Acronyms whose internal digit stays attached when resolving a GraphQL field
    /// name back to its `snake_case` JSONB key (e.g. `s3`, `ipv4`, `oauth2`). Added
    /// to the built-in defaults at boot via `fraiseql_db::utils::set_runtime_acronyms`.
    /// Skipped when empty so a schema with no project acronyms serializes byte-for-byte
    /// as before this field existed (no schema-hash churn; back-compat on load).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub naming_acronyms: Vec<String>,

    /// The fraiseql build that produced this compiled schema (#1304).
    ///
    /// Checked by [`validate_producer_version`](Self::validate_producer_version)
    /// at every seam that turns an artifact into a running executor. Anything
    /// this build did not produce is refused, including an artifact that names
    /// no build at all.
    #[serde(
        default = "ProducerVersion::unstamped",
        skip_serializing_if = "ProducerVersion::is_unstamped"
    )]
    pub fraiseql_version: ProducerVersion,

    /// Raw GraphQL schema as string (for SDL generation).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub schema_sdl: Option<String>,

    /// State built from other data and never serialized: the lookup indexes and filter
    /// surface `build_indexes()` derives from the sections above, and the custom-scalar
    /// registry the compiler fills. Equality ignores it (see [`DerivedState`]).
    #[serde(skip)]
    pub derived: DerivedState,
}

/// The never-serialized part of a [`CompiledSchema`].
///
/// `CompiledSchema` equality is equality of what it serializes, so this compares equal
/// whatever it holds: the indexes are functions of the serialized sections, and the
/// custom-scalar registry is compile-time state no loaded schema carries.
/// `every_serialized_section_participates_in_equality` pins the serialized half.
#[derive(Debug, Clone, Default)]
pub struct DerivedState {
    /// Custom scalar type registry, filled by the compiler from the authoring schema's
    /// `custom_scalars`. Not serialized, so it is empty on a loaded schema.
    pub custom_scalars: CustomTypeRegistry,

    /// O(1) lookup index: query name → index into the schema's `queries`.
    /// Built at construction time by `build_indexes()`; not serialized.
    /// Populated automatically by `from_json()`; call `build_indexes()` after
    /// direct mutation of `queries`.
    pub query_index: HashMap<String, usize>,

    /// O(1) lookup index: mutation name → index into the schema's `mutations`.
    /// Built at construction time by `build_indexes()`; not serialized.
    /// Populated automatically by `from_json()`; call `build_indexes()` after
    /// direct mutation of `mutations`.
    pub mutation_index: HashMap<String, usize>,

    /// O(1) lookup index: subscription name → index into the schema's `subscriptions`.
    /// Built at construction time by `build_indexes()`; not serialized.
    /// Populated automatically by `from_json()`; call `build_indexes()` after
    /// direct mutation of `subscriptions`.
    pub subscription_index: HashMap<String, usize>,

    /// The `where` keys of every type a nested predicate can descend into.
    ///
    /// A nested `where` level is adjudicated against the *target* type's keys —
    /// `{machine: {bogus: {eq: …}}}` is refused because `MachineWhereInput` has
    /// no `bogus`, rather than lowering to a JSON path that matches nothing.
    /// Built at construction time by `build_indexes()`; not serialized.
    pub where_relation_fields: fraiseql_db::where_clause::RelationFieldMaps,

    /// The aggregate row types the fact-table roots return (#1800). Not in
    /// [`types`](CompiledSchema::types): they are no entity, and nothing reads them but
    /// introspection, the SDL and the row projection. Built by `build_indexes()`.
    pub fact_rows: Vec<crate::schema::TypeDefinition>,

    /// The published fact-table roots (#1800). Not in [`queries`](CompiledSchema::queries):
    /// the dispatch routes them by name, before any declared query is looked up. Built by
    /// `build_indexes()`.
    pub fact_roots: Vec<crate::schema::fact_table_surface::FactRoot>,
}

// Reason: equality of a compiled schema is equality of its serialized form; this state is
// rebuilt from it or never present after a load.
impl PartialEq for DerivedState {
    fn eq(&self, _other: &Self) -> bool {
        true
    }
}

impl CompiledSchema {
    /// Create empty schema.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

/// OAuth-client configuration compiled from the `[auth]` client groups.
///
/// Two independent groups, each optional (at least one is present — the CLI
/// refuses to compile an empty group set): the PKCE server-side login client
/// (#621, `[auth]`'s `discovery_url`/`client_id`/`client_secret_env`/
/// `server_redirect_uri` quadruple, lowered into [`pkce`](Self::pkce)) and the
/// social-login providers (#368, `[auth.social.*]`, lowered into
/// [`social`](Self::social)). Client secrets are **never** carried here — each
/// group names the environment variable the runtime reads its secret from.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthClientConfig {
    /// PKCE OAuth client for server-side login (`/auth/start`, `/auth/callback`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pkce:     Option<PkceClientConfig>,
    /// Social-login provider registry (`/auth/v1/authorize`, `/auth/v1/callback`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub social:   Option<SocialAuthConfig>,
    /// First-party auth methods this server operates itself (`[auth.local]`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub local:    Option<LocalAuthConfig>,
    /// How long the sessions this server mints last, and whether they can be
    /// refreshed (`[auth.session]`, #1853).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session:  Option<SessionConfig>,
    /// The database first-party auth connects to, as its own role
    /// (`[auth.database]`, #1965).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub database: Option<AuthDatabaseConfig>,
}

/// `[auth.database]` — the database first-party auth connects to, as its own role
/// (#1965).
///
/// Every auth table lives in schema `auth`, owned by the role this connection
/// authenticates as; the engine's own role needs no grant on it. Only the name of the
/// environment variable holding the URL is compiled: the URL carries credentials.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthDatabaseConfig {
    /// The environment variable that holds the auth role's connection URL.
    pub url_env: String,

    /// Connections the pool keeps open. Unset: none until needed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pool_min: Option<u32>,

    /// The most connections the pool opens. Unset: [`Self::DEFAULT_POOL_MAX`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pool_max: Option<u32>,

    /// How long a request waits for a connection, in seconds. Unset:
    /// [`Self::DEFAULT_ACQUIRE_TIMEOUT_SECS`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub acquire_timeout_secs: Option<u64>,
}

impl AuthDatabaseConfig {
    /// How long a request waits for a connection when `acquire_timeout_secs` is unset.
    pub const DEFAULT_ACQUIRE_TIMEOUT_SECS: u64 = 30;
    /// The pool's size when `pool_max` is unset.
    pub const DEFAULT_POOL_MAX: u32 = 5;

    /// `[auth.database]` reading its URL from `url_env`, with the default pool.
    #[must_use]
    pub fn new(url_env: impl Into<String>) -> Self {
        Self {
            url_env:              url_env.into(),
            pool_min:             None,
            pool_max:             None,
            acquire_timeout_secs: None,
        }
    }

    /// Refuse values that could not work: a `url_env` that is not an environment
    /// variable's name, a pool that can hold no connection, and a minimum above the
    /// maximum.
    ///
    /// The compiler and the server's boot check both run this. A refusal never
    /// repeats `url_env`'s value when it is not a name, since that value may be the URL.
    ///
    /// # Errors
    ///
    /// Returns a message naming the key it refuses.
    pub fn check(&self) -> Result<(), String> {
        let name = self.url_env.trim();
        if name.is_empty() {
            return Err("[auth.database] url_env is empty: name the environment variable \
                        that holds the auth role's connection URL"
                .to_string());
        }
        let is_name = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
            && !name.starts_with(|c: char| c.is_ascii_digit());
        if !is_name {
            return Err("[auth.database] url_env is not an environment variable name: it \
                        names the variable that holds the URL, and must not hold the URL \
                        itself"
                .to_string());
        }
        let max = self.pool_max.unwrap_or(Self::DEFAULT_POOL_MAX);
        if max == 0 {
            return Err("[auth.database] pool_max = 0 leaves the pool no connection".to_string());
        }
        if let Some(min) = self.pool_min {
            if min > max {
                return Err(format!("[auth.database] pool_min = {min} is above pool_max = {max}"));
            }
        }
        Ok(())
    }
}

/// `[auth.session]` — the lifetime of the sessions this server mints (#1853).
///
/// Governs password, `OTP`, `MFA`, social and `SAML` sign-in. Anonymous guest
/// sessions keep their fixed lifetime.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionConfig {
    /// Lifetime of an access token, in seconds. Unset, each sign-in method keeps
    /// its own lifetime.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ttl_secs: Option<u64>,

    /// How long a session can be kept alive by refreshing, in seconds from
    /// sign-in. Setting it mounts `/auth/v1/session/refresh` and
    /// `/auth/v1/session/logout`; unset, neither is mounted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub absolute_ttl_secs: Option<u64>,
}

impl SessionConfig {
    /// The access-token lifetime when `ttl_secs` is unset.
    pub const DEFAULT_TTL_SECS: u64 = 3_600;
    /// The shortest `ttl_secs` accepted: below a minute the validator's 60-second
    /// leeway is longer than the lifetime itself.
    pub const MIN_TTL_SECS: u64 = 60;

    /// Refuse values that could not work: a lifetime under a minute, and a refresh
    /// limit that is not longer than the access token it would refresh.
    ///
    /// The compiler and the server's boot check both run this, so a schema the
    /// compiler accepts is one the server accepts.
    ///
    /// # Errors
    ///
    /// Returns a message naming the key and the value it refuses.
    pub fn check(&self) -> Result<(), String> {
        if let Some(ttl) = self.ttl_secs {
            if ttl < Self::MIN_TTL_SECS {
                return Err(format!(
                    "[auth.session] ttl_secs = {ttl} is under {} seconds: the token \
                     validator accepts a token for 60 seconds past its expiry, so a shorter \
                     lifetime does not mean what it says",
                    Self::MIN_TTL_SECS
                ));
            }
        }
        if let Some(absolute) = self.absolute_ttl_secs {
            let ttl = self.ttl_secs.unwrap_or(Self::DEFAULT_TTL_SECS);
            if absolute <= ttl {
                return Err(format!(
                    "[auth.session] absolute_ttl_secs = {absolute} is not longer than the \
                     access token's lifetime ({ttl} s): a session could never be refreshed"
                ));
            }
        }
        Ok(())
    }
}

/// `[auth.local]` — the auth methods `FraiseQL` operates itself, rather than
/// delegating to an `IdP` (#367).
///
/// Every enabled method mounts real `HTTP` routes and requires a database pool:
/// credentials, `MFA` enrollments, `OTP` budgets and sessions are all durable
/// state. A method enabled without what it needs refuses to boot rather than
/// mounting a flow that cannot complete.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalAuthConfig {
    /// Email + password sign-in: mounts `/auth/v1/password/{signup,login}` and
    /// the reset pair. Reset-link delivery additionally requires
    /// [`email_from`](Self::email_from).
    #[serde(default)]
    pub password: bool,

    /// Email `OTP` / magic-link sign-in: mounts `/auth/v1/otp` and
    /// `/auth/v1/verify`. Requires [`email_from`](Self::email_from) — a code
    /// nobody receives is not a login method.
    #[serde(default)]
    pub otp: bool,

    /// `TOTP` `MFA`: mounts `/auth/v1/mfa/{enroll,challenge,verify,unenroll}`,
    /// backed by the Postgres enrollment store (enrollments must survive a
    /// restart, or a deploy locks every user out of their own account).
    #[serde(default)]
    pub mfa: bool,

    /// Anonymous guest sessions: mounts `POST /auth/v1/signup`, which issues a
    /// session to any caller with no credentials at all. Off by default — this
    /// is a deliberate "anyone may hold a session" posture, not a convenience.
    #[serde(default)]
    pub anonymous: bool,

    /// Email verification for local-password accounts (#945): mounts
    /// `POST /auth/v1/email/verify/{start,confirm}`, both of which require an
    /// authenticated caller and act only on that caller's own account.
    ///
    /// Requires [`password`](Self::password) — verification proves the address a
    /// *local* identity claims, and an account without one has nothing to verify
    /// — plus [`email_from`](Self::email_from) and
    /// [`verification_url_template`](Self::verification_url_template).
    ///
    /// Confirming promotes the account's own `auth.tb_user.email`, which is what
    /// lets a later trusted social sign-in for the same address link into it. It
    /// never merges two accounts: an address already verified elsewhere is
    /// refused.
    #[serde(default)]
    pub email_verification: bool,

    /// Service name shown in authenticator apps for `MFA` enrollment
    /// (`otpauth://…?issuer=`). Defaults to `"`FraiseQL`"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mfa_issuer: Option<String>,

    /// The `[mailbox.<name>]` account whose `SMTP` half delivers `OTP` codes and
    /// password-reset links. Required whenever `otp` or `password` is enabled;
    /// naming a mailbox with no `[mailbox.<name>.smtp]` section refuses to boot.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email_from: Option<String>,

    /// The sender name shown beside [`email_from`](Self::email_from)'s address. Unset sends
    /// the bare address.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email_from_name: Option<String>,

    /// The subject and bodies of each mail these methods send (`[auth.local.email]`). Unset
    /// keeps the built-in English text. Boxed: every compiled schema carries this struct, and
    /// few carry mail templates.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<Box<super::LocalEmailConfig>>,

    /// Template for the password-reset link, with `{token}` substituted for the
    /// opaque reset token — e.g. `https://app.example.com/reset?token={token}`.
    /// Required when `password` is enabled: the link points at the operator's
    /// front end, which `FraiseQL` cannot guess.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reset_url_template: Option<String>,

    /// Template for the `OTP` magic link, with `{token}` substituted for a link
    /// token that signs in alone, and `{code}` for the six-digit code. Optional:
    /// when absent the email carries the bare six-digit code.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub magic_link_template: Option<String>,

    /// `SELECT` deciding who may sign in by `OTP`, with `$email` bound to the normalized
    /// address — e.g. `SELECT 1 FROM tb_member WHERE email = $email AND active`. A row
    /// admits the address; no row or a failed query refuses it. A refused address gets the
    /// same answers as an admitted one, and no mail and no account. Unset admits every
    /// address. It governs `OTP` only: password signup and social sign-in stay open.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub otp_allowed_query: Option<String>,

    /// Template for the email-verification link, with `{token}` substituted for
    /// the opaque verification token — e.g.
    /// `https://app.example.com/verify-email?token={token}`. Required when
    /// [`email_verification`](Self::email_verification) is enabled: the link
    /// points at the operator's front end, which `FraiseQL` cannot guess.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub verification_url_template: Option<String>,

    /// How these methods hand a session to the client. [`SessionTransport::Json`], the
    /// default, puts the tokens in the response body;
    /// [`SessionTransport::Cookie`] puts them in `HttpOnly` cookies instead.
    #[serde(default, skip_serializing_if = "SessionTransport::is_json")]
    pub session_transport: SessionTransport,
}

/// `[auth.local] session_transport`: how a session reaches the client.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionTransport {
    /// The access and refresh tokens are in the `JSON` response body, for the client to
    /// keep and send as a bearer token.
    #[default]
    Json,
    /// The access token is in the `__Host-access_token` cookie and the refresh token in
    /// `__Secure-refresh_token`, scoped to `/auth/v1/session`; both are `HttpOnly`,
    /// `Secure` and `SameSite=Strict`, and no response body carries either token.
    ///
    /// Requires `[auth.session] absolute_ttl_secs`: without it no logout route is
    /// mounted, and nothing could clear the cookies.
    Cookie,
}

impl SessionTransport {
    /// Whether this is the default, [`SessionTransport::Json`].
    #[must_use]
    pub const fn is_json(&self) -> bool {
        matches!(self, Self::Json)
    }
}

impl LocalAuthConfig {
    /// Refuse cookie transport without `[auth.session] absolute_ttl_secs`: that is what
    /// mounts the logout route, and without it nothing could clear an `HttpOnly` cookie.
    ///
    /// The compiler and the server's boot check both run this.
    ///
    /// # Errors
    ///
    /// Returns a message naming both keys.
    pub fn check_session_transport(&self, session: Option<&SessionConfig>) -> Result<(), String> {
        let refreshable = session.and_then(|s| s.absolute_ttl_secs).is_some();
        if self.session_transport == SessionTransport::Cookie && !refreshable {
            return Err("[auth.local] session_transport = \"cookie\" requires [auth.session] \
                        absolute_ttl_secs: it mounts the logout route, and without that \
                        nothing could clear the HttpOnly session cookies"
                .to_string());
        }
        Ok(())
    }
}

/// PKCE OAuth-client configuration compiled from the `[auth]` PKCE group (#621).
///
/// The client secret is **never** carried here — `client_secret_env` names the
/// environment variable the runtime reads it from. The `authorization_endpoint` /
/// `token_endpoint` are deliberately absent: the runtime resolves them at boot by
/// fetching `discovery_url`'s `.well-known/openid-configuration`, so the compiler
/// performs no network I/O and the endpoints cannot go stale in a cached schema.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PkceClientConfig {
    /// `OIDC` provider discovery base URL (e.g. `https://accounts.google.com`). The
    /// runtime appends `/.well-known/openid-configuration` at boot.
    pub discovery_url:       String,
    /// `OAuth2` `client_id` registered with the provider.
    pub client_id:           String,
    /// Name of the environment variable holding the client secret.
    pub client_secret_env:   String,
    /// This server's `/auth/callback` URL, registered with the provider.
    pub server_redirect_uri: String,
}

/// Social-login provider configuration compiled from `[auth.social]` (#368).
///
/// Each configured provider is auto-registered by the server and served through
/// the account-linking trust gate at `/auth/v1/authorize` / `/auth/v1/callback`.
/// Only implemented providers are typable: an `[auth.social.apple]` block is a
/// compile-time unknown-field error, not a silently-ignored table.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SocialAuthConfig {
    /// Allow-listed `redirect_uri` values for the post-login token hand-off
    /// (#427). Empty (the default) keeps the JSON-token response: the
    /// client-supplied `redirect_uri` is never used as a redirect target, so
    /// there is no open-redirect surface. Non-empty switches the callback to an
    /// implicit-style fragment redirect, refusing any URI not matched here.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub redirect_uri_allowlist: Vec<String>,

    /// `Google` `OIDC` social login.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub google: Option<GoogleSocialConfig>,

    /// `GitHub` `OAuth2` social login (non-`OIDC`; fixed well-known endpoints).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub github: Option<GitHubSocialConfig>,

    /// Sign in with `Apple`. Configuring it also mounts the `POST` variant of
    /// `/auth/v1/callback` that `Apple`'s `response_mode=form_post` requires.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub apple: Option<AppleSocialConfig>,

    /// `Discord` `OAuth2` social login (non-`OIDC`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub discord: Option<DiscordSocialConfig>,

    /// `Facebook` `OAuth2` social login (non-`OIDC`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub facebook: Option<FacebookSocialConfig>,
}

/// `[auth.social.google]` — `Google` `OIDC` social-login client (#368).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GoogleSocialConfig {
    /// `OAuth2` `client_id` from the `Google` Cloud Console.
    pub client_id:         String,
    /// Name of the environment variable holding the client secret.
    pub client_secret_env: String,
    /// This server's `/auth/v1/callback` URL, registered with `Google`.
    pub redirect_uri:      String,
    /// `OIDC` issuer override (default `https://accounts.google.com`). For
    /// `Google`-compatible stand-ins; the runtime's SSRF guards still apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub discovery_url:     Option<String>,
}

/// `[auth.social.github]` — `GitHub` `OAuth2` social-login client (#368).
///
/// `GitHub` serves no `OIDC` discovery document; the endpoints are the fixed
/// well-known paths under [`base_url`](Self::base_url) /
/// [`api_base_url`](Self::api_base_url), overridable for `GitHub` Enterprise
/// Server deployments.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitHubSocialConfig {
    /// `OAuth2` `client_id` of the `GitHub` OAuth app.
    pub client_id:         String,
    /// Name of the environment variable holding the client secret.
    pub client_secret_env: String,
    /// This server's `/auth/v1/callback` URL, registered with `GitHub`.
    pub redirect_uri:      String,
    /// Web base URL override (default `https://github.com`) — `GitHub` Enterprise
    /// Server. The runtime's SSRF guards still apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url:          Option<String>,
    /// API base URL override (default `https://api.github.com`) — `GitHub`
    /// Enterprise Server (`https://HOSTNAME/api/v3`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_base_url:      Option<String>,
}

/// `[auth.social.apple]` — Sign in with `Apple` (#943).
///
/// `Apple` has no `client_secret_env`: its client secret is an ES256 assertion
/// the runtime mints from the `.p8` key over
/// ([`team_id`](Self::team_id), [`key_id`](Self::key_id),
/// [`client_id`](Self::client_id)) and re-mints before expiry. Supply the key
/// through exactly one of [`private_key_env`](Self::private_key_env) or
/// [`private_key_path`](Self::private_key_path) — naming both, or neither, is a
/// configuration error rather than a silent preference.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AppleSocialConfig {
    /// The services ID registered with `Apple` (its `OAuth2` `client_id`, and
    /// the `aud` of every `id_token`).
    pub client_id:        String,
    /// `Apple` developer team ID — the client-secret assertion's `iss`.
    pub team_id:          String,
    /// Key ID of the `.p8` signing key — the assertion header's `kid`.
    pub key_id:           String,
    /// Name of the environment variable holding the `.p8` private key, PEM text
    /// and all. Mutually exclusive with
    /// [`private_key_path`](Self::private_key_path).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub private_key_env:  Option<String>,
    /// Filesystem path to the `.p8` private key. Mutually exclusive with
    /// [`private_key_env`](Self::private_key_env).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub private_key_path: Option<String>,
    /// This server's `/auth/v1/callback` URL, registered with `Apple`.
    pub redirect_uri:     String,
    /// Base URL override (default `https://appleid.apple.com`). This is also
    /// the issuer every `id_token` must name, so the two move together. The
    /// runtime's SSRF guards still apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url:         Option<String>,
}

/// `[auth.social.discord]` — `Discord` `OAuth2` social login (#944).
///
/// `Discord` reports `verified` on the user object, and the runtime honours it:
/// an unverified address keys on `(discord, id)` rather than on the email. That
/// check is why `discord` is in the default trusted-email set.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiscordSocialConfig {
    /// `OAuth2` `client_id` of the `Discord` application.
    pub client_id:         String,
    /// Name of the environment variable holding the client secret.
    pub client_secret_env: String,
    /// This server's `/auth/v1/callback` URL, registered with `Discord`.
    pub redirect_uri:      String,
    /// Base URL override (default `https://discord.com`). The runtime's SSRF
    /// guards still apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url:          Option<String>,
}

/// `[auth.social.facebook]` — `Facebook` `OAuth2` social login (#944).
///
/// `Facebook` publishes **no** email-verification signal, so every `Facebook`
/// identity keys on `(facebook, id)` and `facebook` is deliberately absent from
/// the default trusted-email set. The Graph API version is part of the request
/// path and `Meta` deprecates versions on a schedule, so it is configuration
/// rather than a constant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FacebookSocialConfig {
    /// `OAuth2` `client_id` (the `Meta` app ID).
    pub client_id:         String,
    /// Name of the environment variable holding the client secret.
    pub client_secret_env: String,
    /// This server's `/auth/v1/callback` URL, registered with `Meta`.
    pub redirect_uri:      String,
    /// Graph API version segment (default `v21.0`), e.g. `v22.0`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_version:       Option<String>,
    /// Web base URL override (default `https://www.facebook.com`) — the
    /// authorization dialog. The runtime's SSRF guards still apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url:          Option<String>,
    /// Graph API base URL override (default `https://graph.facebook.com`) — the
    /// token and profile endpoints. The runtime's SSRF guards still apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub graph_base_url:    Option<String>,
}